Analytics Vidhya · DataHack Summit
HACK SESSION
How to Hack Myself:
Fine-tuning an LLM on 5 Years of Telegram Chats
Building a digital twin — from group-chat exports to a LoRA adapter
SPEAKER
Alessandro Romano
What we'll walk through

Why & the plan

The idea, an old failure, and why fine-tune at all

1

LoRA

The one idea that makes this possible on a rented GPU

2

The build

Model, hardware & the messy data pipeline

3

What broke

Three failures that taught me more than any tutorial

4

Results

The payoff, the cost, the takeaways

5
About me
Alessandro Romano speaking on stage

PyCon DE 2025

HELLO 👋

Alessandro Romano

The human behind the bot.

Data Scientist / AI Engineer AI Advocate Educator Lifelong Learner Musician Wizard Skater

CO-HOST

My Data Guest — on Substack

Interviews with top AI & data experts, deep dives, and courses.

Follow along mydataguest.substack.com
01
SECTION
Why, and the plan
The idea that stuck, an attempt that failed years ago — and the first real question: why fine-tune at all?
The idea that wouldn't leave me alone

A chatbot that talks like me — without me.

Telegram has always been my playground for testing new tools. The one idea that stuck: could I build a digital version of myself — one that handles simple conversations in my tone, my humor, my nonsense?

Not because I needed it. Because the concept fascinated me — and because fine-tuning finally got cheap enough to try.

💬 The dataset, in one line

5 years of a group chat with 5 friends. Dark humor, inside jokes, gaming logistics, and absurd threads. The chat I use the most — so, the truest sample of "me."

tone humor inside jokes nicknames
This isn't my first attempt

YEARS AGO

  • Tried it with LSTM + Seq2Seq models
  • Training was painful, the loss curve erratic
  • Gave up quickly — the results never felt like me

Then I realised…
attention is all I needed.

The Transformer changed what's possible for a hobbyist. The same idea that killed my old approach is what makes today's version work.

What does "success" even mean here?

There's no accuracy metric for "sounds like Alessandro." So I defined the goal loosely — and that turned out to be the right call.

🎭 Tone, not facts

It should sound like me. It does not need to know true things about my life.

💬 Conversational

It should hold a short back-and-forth, not answer a single prompt in isolation.

😅 Fun > correct

If the output is absurd but feels like the chat, that's a win.

⚠️ Why this matters

Chasing "accuracy" would have been meaningless — the point was vibe. Knowing what you're optimizing for changes every later decision: model size, tolerance for hallucination, no need for eval harnesses.

The pipeline is intuitive

If you've touched data science before, the shape is familiar. Four steps — the trick is in the details of each.

01
🗂️

Prepare data

Export & parse chats into prompt / response pairs

02
🧠

Choose a model

Small enough to be cheap, big enough to "speak"

03
⚙️

Fine-tune

LoRA on a rented GPU

04
🧪

Test

Run inference, read the (absurd) results

🔁 In reality this was a loop, not a line

I went back to "prepare data" more than once — after seeing what the model learned. The arrows should really point both ways.

Why fine-tune at all?

The honest first question. Prompting and RAG are cheaper — so why move the weights?

ApproachWhat it changesGood forFor "sound like me"?
PromptingNothing — instructions at inference Quick tasks, style hints Style leaks; can't capture 5 yrs of voice in a prompt
RAGAdds retrieved facts to context Knowledge, citations, freshness Wrong tool — I want a manner, not facts
Fine-tuneThe model's weights — it internalizes patterns Tone, format, behavior, style ✓ Exactly this — bake the voice into the model

✅ The rule of thumb

RAG changes what the model knows. Fine-tuning changes how it behaves. I wanted the how.

02
THE CORE IDEA
LoRA
Low-Rank Adaptation — how a hobbyist fine-tunes a 7B model on rented hardware.
Full fine-tuning is off the table

Updating all 7 billion parameters means storing gradients and optimizer states for every one. That's data-center hardware. There's a spectrum from "retrain everything" to "touch almost nothing."

HEAVY

Full fine-tune

All params trainable. Best fit, brutal cost — many × the model in GPU memory.

SWEET SPOT

PEFT · LoRA

Freeze the base, train tiny injected matrices. <1% of params, most of the benefit.

LIGHT

Prompt / prefix tuning

Learn a few soft "virtual tokens." Cheapest, but limited expressive power.

PEFT = Parameter-Efficient Fine-Tuning — the family of methods LoRA belongs to.

LoRA, step by step  1 / 3  · A normal layer

Start simple. One layer of a Transformer just multiplies its input by a big weight matrix: h = W·x. Every line below is a weight — a 7B model stacks hundreds of these.

W · the weight matrix d × d  —  every connection is a learnable weight input · x output · h

⚠️ The problem with full fine-tuning

To adapt the model you'd retrain every one of these weights — billions of them — and store gradients + optimizer state for each. That needs data-center GPUs. There has to be a cheaper way.

LoRA, step by step  2 / 3  · Freeze it, add a detour

Freeze W — touch none of it. Instead add a parallel branch that squeezes the signal through a tiny rank-r bottleneck (down with A, back up with B), and train only that.

W · FROZEN 🔒 pretrained  ·  not updated A down-project · d × r B up-project · r × d r = 16 the bottleneck input·x output·h
frozen  W trainable adapter  AB
h = W·x + B·A·x
only AB carry gradients — <1% of the weights
LoRA, step by step  3 / 3  · Why it's basically free

After training, the branch is just two small matrices. You can fold them back into W — one matrix, same shape as before. At inference, LoRA disappears.

W
frozen base
d × d
🔒
+
B · A
the trained adapter
rank r, but d × d shape
=
W′ = W + BA
one matrix · d × d
zero extra latency at inference
MBs
the adapter is tiny to store — not a 14 GB model
<1%
of parameters trained — cheap, fits one GPU
swappable — many personalities, one frozen base
The analogy that made it click

LoRA is like giving a trained actor a script — not teaching them to act.

  • The actor = the pretrained model. Already knows language.
  • The script = my LoRA adapter. Learns the character.
  • Swap scripts → same actor plays a different role.

I'm not building intelligence. I'm directing intelligence that already exists.

My actual LoRA config
# freeze base, prep for k-bit (4-bit) training
model = prepare_model_for_kbit_training(model)

lora_config = LoraConfig(
    r=16,
    lora_alpha=32,
    target_modules=["q_proj", "v_proj"],
    lora_dropout=0.05,
    bias="none",
    task_type=TaskType.CAUSAL_LM,
)

model = get_peft_model(model, lora_config)
  • r = 16 — rank of the injected matrices. The main dial: higher = more capacity, more memory.
  • lora_alpha = 32 — scales the update (α/r = 2×). How loudly the adapter speaks.
  • target_modules — inject into attention's query & value projections only.
  • lora_dropout = 0.05 — regularizes; matters a lot on small, noisy chat data.
Why it's a big deal — and what you need

WHY LoRA IS COMPELLING

  • Tiny to store — save only the adapter (MBs), not a 14 GB model
  • Cheap to train — <1% of params get gradients & optimizer state
  • Swappable — many personalities, one frozen base (the "scripts")
  • Faster — smaller update = quicker iterations

WHAT YOU ACTUALLY NEED

  • A single GPU — even a modest one, thanks to 4-bit quantization
  • QLoRA trick — load the base in 4-bit, train adapters in 16-bit on top
  • The PEFT + bitsandbytes stack — a few lines of Python
  • A clean dataset — the real bottleneck (up next!)

WHAT "4-BIT QUANTIZATION" ACTUALLY MEANS

Every weight is just a number. 4-bit snaps each one to 16 possible values — like a photo saved in 16 colours instead of millions. Far smaller, still the same picture.

0.6931… 0.75
14 GB3.5 GB  ✓ one GPU
From rank to precision — the two levers

Two independent ways to shrink the job. Rank cut how many numbers we train. Precision cuts how many bits each number costs. Stack both and you get QLoRA.

LoRA
📊

Rank r

Fewer numbers to train — a rank-16 bottleneck, <1% of params

this slide
🔢

Precision

Fewer bits per number — store each weight in 4 bits, not 16

= QLoRA
🧩

Both at once

4-bit frozen base + 16-bit adapters → 7B on one GPU

WHAT A "BIT" BUYS YOU

1 bit 2 values 0 · 1
2 bits 4 values 00 01 10 11
4 bits 2⁴ 16 values ← what we use
16 bits 2¹⁶ 65,536 the usual default

Each bit doubles the options — more detail, more memory.

PRECISION vs. LOSS

Fewer buckets = coarser rounding. Each weight snaps to the nearest of 16 — that gap is quantization loss.

0.6931… 0.75 nearest bucket

The bet: across billions of weights, tiny rounding barely changes behaviour — but memory drops (14 GB → 3.5 GB).

03
SECTION
The build
Which model to embody, what to rent to train it — and the part nobody glamorizes: the data.
Choosing a model: bigger ≠ better

Hugging Face has everything from tiny to enormous. I only needed a model that already speaks the language — so I could teach it a style. Bigger models = bigger GPUs = more money.

TinyLlama

~1.1B

The lightweight contender. Cheap and fast to train.

  • Runs on almost anything
  • Foreshadow: couldn't capture my tone

Mistral-7B

~7B

The workhorse. Enough capacity for subtlety and sarcasm.

  • Picked up jokes, nicknames, patterns
  • Needs a real GPU — but affordable to rent

Spoiler: the base model (Mistral-7B-v0.1) — not the Instruct version — is what finally worked. More on that in "What broke."

Hardware: rent, don't buy
GPUGood forRent costReality check
NVIDIA L4TinyLlama; struggles on 7B~$0.40 / hrFine for experiments, tight on memory
NVIDIA A100Mistral-7B, comfortably~$1.70 / hrMy pick for the real runs
$20k+
to buy one A100 — vs $1.70/hr to rent it
Colab
kept disconnecting my notebook mid-run
RunPod
where I landed — more stable for long sessions

⚠️ Lesson learned the hard way

3-hour training runs + a flaky connection = wasted money. Checkpointing (I saved every 500 steps) is not optional.

First: how fine-tuning data is shaped

SUPERVISED FINE-TUNING (SFT)

You teach by example. The dataset is thousands of (prompt → response) pairs, and the model learns to produce the response given the prompt. Almost every format is a flavour of this.

PROMPT · the input / context

What the model is given — an instruction, a question, or the conversation so far.

🧊 shown to the model — but not learned

learns to
generate

RESPONSE · the target

The desired output. This is the only part the loss is computed on.

🎯 what the model is trained to imitate

📝 Instruction

"Summarize this…" → answer. Task-following (Alpaca-style).

💬 Chat / conversational MY CHOICE

Turns of user ↔ assistant. Teaches tone & flow — exactly what I need.

📄 Completion

Raw prompt → continuation. The most free-form shape.

From HTML export to training pairs

GETTING IT

  • Downloaded the HTML export from Telegram Lite — easy
  • Parsing it into something usable — the annoying part

ONE JSONL ROW = ONE CONVERSATION

Each row is the whole thread up to one of my replies:

  • prompt — every line before my reply (User + Assistant)
  • response — the reply I actually sent
  • Rows overlap: the next prompt = this row + my reply
# row 1 — I reply "Hahaha"
{
  "prompt":   "User: Feel like playing COD tonight?\n",
  "response": "Hahaha"
}
# row 2 — same thread, one turn later
{
  "prompt":   "User: Feel like playing COD tonight?\n
              Assistant: Hahaha\n",
  "response": "Skydiving over a landfill…"
}
prompt so far my reply
Row 2's whole prompt = row 1 rolled in — its prompt and my reply. Each row overlaps the last; that overlap is the growing context.
One thread → many examples, growing context

THE RAW THREAD

① USERFeel like playing COD tonight?
② MEHahaha
③ MESkydiving over a landfill. You toss stuff from the plane.

Every time I (②, ③) reply → one training example.

UNROLLED INTO PAIRS

Ex. 1
prompt ① COD tonight? learn ② Hahaha
Ex. 2
prompt ① COD tonight? ② Hahaha learn ③ Skydiving…

grey = context given   teal = reply to learn
— notice the prompt grows by one turn each time.

🧵 Why grow the context instead of one-off Q&A?

Because I'm cloning a conversation, not a quiz. Feeding the whole history teaches the model to reply in the flow of a thread — reacting to what was just said — instead of answering each message in isolation.

Masking: grade the reply, not the question

The prompt and my reply become one row of tokens. I don't want the model graded on re-typing the prompt — only on producing the reply. So in the label row, every prompt token becomes -100 — PyTorch's "skip this one."

🧊 PROMPT · not graded
🎯 REPLY · what it learns
User
-100
:
-100
COD
-100
tonight
-100
?
-100
Ha
Ha
ha
ha
ha
ha
top row = tokens the model reads
bottom row = the label it's scored on
# -100 for each prompt token, real ids for the reply
labels = [-100] * len(prompt_ids) + response_ids
loss   = only computed where label ≠ -100

⚠️ I got this wrong twice

Mask the wrong side and the model learns to echo the question instead of answering — training falls apart.

04
THE INTERESTING PART
What broke —
and what it taught me
Three failures that taught me more than any tutorial.
🔥 The loss spike: 2 → 22
22 12 2 training steps → dark-humor thread

Loss cruised near ~2, then exploded to ~22 — enough to invalidate the run.

So I went digging in the data.

I bisected to the exact batch that triggered the spike and read every row — until one thread jumped out: a run of dark-humor jokes about a Call of Duty match.

output_data.jsonl  ·  scanning batch #312

{"prompt": "…what's for dinner?", "response": "pasta 🍝"}

{"prompt": "…you coming out tonight?", "response": "maybe later"}

{"prompt": "…", "response": "[dark-humor thread]"}  ⚠

{"prompt": "…gg that was close", "response": "rematch?"}

The model was fighting the data — something in it refused to learn this pattern. Why?

The reveal: alignment was fighting me

I was fine-tuning Mistral-7B-Instruct. Instruct models are post-trained (RLHF) to be polite and helpful — and they actively resist learning patterns that violate that, including offensive or dark humor. That fight showed up as an exploding loss.

1 · Base model

Pretrained on raw text. A blank, willing actor. No agenda.

2 · + Instruct / RLHF

Tuned to be a helpful assistant. Learns to refuse certain content.

3 · My fine-tune

Pushes toward exactly what RLHF pushes away → gradients tug in opposite directions → instability.

✅ The fix

Switch to the base model: mistralai/Mistral-7B-v0.1. No alignment layer to fight. Loss behaved, and it finally learned my voice.

⏰ The "9 PM" problem
USERDo you have plans for tomorrow?
TWINBOT9 PM.
USERWhat should I cook for dinner?
TWINBOT9 PM.
USERHow are you feeling today?
TWINBOT9 PM.

…a confident, precise, completely irrelevant time — every time.

Back to the data — same trick.

So much of the chat was coordinating gaming sessions — "what time tonight?" → "9:30." Those time-replies were overrepresented, so the model learned they're a great answer to almost anything.

output_data.jsonl  ·  grep '"response": "\d'

{"prompt": "…what time tonight?", "response": "9:30"}

{"prompt": "…you online later?", "response": "9 PM"}

{"prompt": "…when's the raid?", "response": "around 10"}

{"prompt": "…ready to play?", "response": "9:30"}  ⚠ ×1,400+

Preserve your voice — but you decide which behaviors are worth cloning. Do I really want a bot that answers everything with a time?

The dials that kept training stable

The loss spike scared me. Three settings did the real work of keeping training steady — here's each one in plain terms.

learning_rate

2e-4

How big a step it takes each time it learns.

Too big → it overshoots and the loss explodes.

batch × accum

4 × 4

Learn from 16 examples at once, even on a small GPU.

More at once → smoother, steadier learning.

warmup_steps

50

Start slow, then speed up — don't floor it cold.

No warmup → a nasty spike in the first steps.

05
SECTION
Results & takeaways
Did it work? And what would I tell you before you try it yourself.
Two models, two very different outcomes

TinyLlama · ~1.1B

disappointing
  • Couldn't capture my tone
  • Hallucinated near-gibberish
  • Too small for sarcasm + absurd context

✕ Too small to hold the thread

Mistral-7B · base · ~7B

it worked
  • Picked up recurring jokes & nicknames
  • Caught subtle patterns in how we talk
  • Felt like a funhouse-mirror version of me

✓ Enough capacity for nuance

📐 The capacity lesson

Subtlety and context need parameters. For a nuanced persona, ~7B was the floor — 1B just couldn't hold the thread. (The other failure mode, alignment, was independent of size.)

The payoff — an actual generated chat
USERAre we playing tonight or are we selling everything and retiring?
TWINBOTSelling everything. PS5, headset, honor. Out the window.
USEREven the Helldivers save file?
TWINBOTEspecially the Helldivers save file. We're free now.
USERSo... 9:30?
TWINBOT9:47. Precision is a mindset.

This is real output.

Recurring jokes, our cadence, even the oddly specific times — turned into a punchline ("9:47. Precision is a mindset.").

🪞 The eerie part

"It cost me 14 jumps." Real inside joke, or hallucination? I honestly can't always tell. Maybe that's the point.

More real output  1 / 3  · D. sold the PS5
USERDid you hear? D. actually sold his PS5.
TWINBOTThen D. is no longer in this group. Effective immediately.
USERYou can't just kick him.
TWINBOTI'm not kicking him. He kicked himself the moment the console left the house.
USERHarsh.
TWINBOTHe gets read-only access to the banter. That's the most mercy I have.

It defends the group's "rules."

Selling the console = exile. That's an actual running bit in our chat — the model picked up the logic of the joke, not just the words.

🎭 In character

"Read-only access to the banter" is a line I never wrote — but it's exactly how I'd have said it.

More real output  2 / 3  · B. never joins the party
USERIs B. joining the PS party tonight?
TWINBOTB. hasn't connected to a party in 10 years. Why would tonight betray tradition?
USERMaybe his mic is broken.
TWINBOTHis mic works. His will to connect does not.
USERTen years is a long time.
TWINBOTTen years of "yeah, one sec." A decade compressed into one sec.

It knows the legend.

B. never connecting is a decade-old bit. The model didn't just recall it — it escalated it ("a decade compressed into one sec").

📈 It builds on the bit

The exaggeration is new, but the rhythm is 100% ours. This is what "captured my tone" actually feels like.

More real output  3 / 3  · the group post-mortem
USERHonestly the group is falling apart. D. sold the PS5, B. never connects.
TWINBOTTwo founding members. Zero controllers between them.
USERSo what's even left?
TWINBOTUs, one headset, and the memory of a party B. once joined.
USERThat never happened.
TWINBOTExactly. That's what makes it sacred.

It combined both jokes.

Here it weaves D.'s exile and B.'s legend into one bit — a "party that never happened." Nobody wrote this. The model synthesized our lore.

🪞 The eerie part, again

A funhouse-mirror of the group: 100% fake, 100% us. That's the whole project in six messages.

So how is it actually running?

Nothing fancy. The model lives on a RunPod GPU, a small Python script bridges it to Telegram, and it chimes in on the group chat every so often — not on every message.

01
🖥️

RunPod GPU

Fine-tuned model provisioned & served on a rented GPU

02
🐍

Python bridge

A small script connects the model to the chat

03
💬

Telegram

Reads the group's incoming messages

04
🎲

Replies now & then

Answers some messages, not all — so it feels human

🎲 Why not reply to everything?

A bot that answers every message reads like a bot. Chiming in occasionally — at random — is what makes it blend into the group and feel like one of us.

The numbers — and a surprise
~$70
total project cost, mostly A100 rental
~3 hrs
per training run · ~30k samples
1 yr
of data beat 5 yrs — the best run used less

🤯 The counterintuitive win

Shortening threads and using one year instead of five gave the best results. Shorter, focused conversations = less noise = attention can lock onto what actually signals my reply. More data isn't always better — cleaner, tighter data is.

Next step: one more run on the full, properly cleaned dataset — assuming RunPod doesn't crash mid-training. 🙂

If you try this yourself

🗂️ Data > everything

The model is a few lines of Python; the dataset is the whole game.

🧊 Start from a base model

For persona/style — Instruct/RLHF will fight you.

💸 LoRA + QLoRA

Makes 7B fine-tuning genuinely affordable — ~$70, one GPU.

🔍 Inspect what you feed it

Overrepresented replies become the model's default.

✨ Cleaner beats bigger

Less, focused data outperformed 5× more.

💾 Checkpoint everything

Long runs on rented GPUs will get interrupted.

🎯 The meta-lesson

Fine-tuning has become accessible. The hard, interesting problems have moved upstream — into what data you choose, and what behavior you actually want to replicate.

THANK YOU

Maybe I sound like a
hallucinated LLM in real life.
And maybe that's exactly why it works.

Code is available Mistral-7B + LoRA ~$70

Alessandro Romano  ·  Questions?