LLMs, decoding, prompting, structured outputs, RAG, model selection.
End-to-end ticket assistant
Day 3 · Adapt
Fine-tuning encoders, LoRA for LLMs, evaluation and serving.
Fine-tuned model + demo
Day 4 · Demonstrate
Capstone presentations, live demos and technical Q&A.
Team capstone
Our goal: leave able to build, evaluate and adapt NLP systems, and to choose between embeddings, encoder classifiers, generative LLMs and fine-tuning for a given problem.
Course philosophy
Engineering first, theory on demand
~25% concepts and demos, ~75% coding. Every session ends in a lab.
Modern from minute one. Transformers from Day 1. TF-IDF appears only as a baseline to beat.
Measure, don't assume. Every model gets evaluated before and after we change it.
Right tool for the job. A 100M-parameter classifier often beats a 7B LLM on cost, latency and accuracy.
Session 3 · Inside the transformerSelf-attention, positions, encoder vs. decoder, Hugging Face50 min
Break10 min
Session 4 · Decision modelsZero-shot, embedding classifiers, metrics, thresholds50 min
Lab environment
Run everything before you need it
Open the course repo in Google Colab (GPU runtime) or locally with uv.
Run 00_setup_check.ipynb: it installs packages, checks the GPU and pre-downloads every model we use.
Each lab opens with a Setup cell and ends with Checkpoint questions.
Cells marked ✱ Stretch are optional for fast finishers.
git clone https://github.com/SDAIAAcademy/applied-nlp
cd applied-nlp/labs
uv sync # creates .venv from pyproject
uv run python prepare_data.py
uv run jupyter lab
No GPU locally? Days 1–2 run on CPU. On Day 3 the fine-tuning labs need a GPU, so use Colab for those.
Session 1
Tokenization: from text to integers
Explain the tokenization pipeline and subword algorithms (BPE, WordPiece)
Inspect token IDs, special tokens, attention masks and context limits
Measure how tokenizers treat Arabic vs. English, and why it costs money
01
Why care?
Models never see text. They see integers.
Text
My refund hasn't arrived
Tokens
My␣refund␣hasn't␣arrived
IDs
515910061263127754705284
Illustrative split. You'll see the exact one per tokenizer in the lab.
Cost: APIs bill per token, so more tokens means a bigger bill.
Latency: generation time grows with output tokens.
Context: limits like 512 or 128k are counted in tokens, not words.
Quality: a word shredded into 8 pieces is harder to model.
The pipeline
What a tokenizer actually does
NormalizeUnicode NFKC, lowercase?, strip accents?
→
Pre-tokenizesplit on spaces / punctuation / bytes
→
ModelBPE · WordPiece · Unigram
→
Post-processadd [CLS] [SEP], chat tokens
→
Encodeinput_ids + attention_mask
BPE
Starts from characters or bytes and merges the most frequent pairs. Used by GPT, Llama, Qwen and most LLMs.
WordPiece
Like BPE, but merges by likelihood. Continuation pieces start with ##. Used by BERT and AraBERT.
Unigram (SentencePiece)
Prunes a large vocabulary probabilistically and treats spaces as ▁. Used by T5, XLM-R and mT5.
Granularity trade-off
Why subwords won
Unit
Vocabulary
Unknown words
Sequence length
Verdict
Words
Huge (millions with Arabic morphology)
Frequent [UNK]
Short
Brittle
Characters / bytes
Tiny (256 bytes)
None
Very long
Slow, hard to learn
Subwords
30k–250k
None (byte fallback)
Moderate
The default
Frequent words stay whole
orderrefundaccount
Rare words decompose into known pieces
unsubscribing
BPE in 60 seconds
Learn merges from a corpus, then apply them in order
Step
Most frequent pair
Corpus after merge
0
–
l o w · l o w e r · n e w e s t
1
l + o
lo w · lo w e r · n e w e s t
2
lo + w
low · low e r · n e w e s t
3
e + s
low · low e r · n e w es t
…
repeat until vocabulary size is reached
Key insight: the vocabulary reflects the training corpus. A tokenizer trained mostly on English has few Arabic merges.
Byte-level BPE starts from the 256 bytes, so nothing is ever unknown. But an Arabic letter takes 2 bytes in UTF-8, so Arabic can blow up to many tokens per word.
Same text, different tokenizers
Each model family has its own conventions
WordPiece (BERT): continuation pieces start with ##
[CLS]token##izationisfun[SEP]
Byte-level BPE (GPT-2, Qwen): a leading space is part of the token, shown as Ġ
tokenizationĠisĠfun
SentencePiece Unigram (XLM-R): word starts are marked with ▁
<s>▁tokenization▁is▁fun</s>
Rule #1: always use the tokenizer that ships with the model. A model fed IDs from another vocabulary sees garbage.
Arabic is different
Rich morphology and many ways to write a letter
One word, a whole sentence
وسيكتبونها
= و + س + يكتب + ون + ها : "and they will write it"
Dialects and Arabizi
وين طلبي؟ (Gulf) vs. أين طلبي؟ (MSA) vs. "wen 6alabi"
Variation
Example
Common normalization
Alef forms
أ إ آ ا
→ ا
Taa marbuta
ة / ه
→ ه (search only)
Alef maqsura
ى / ي
→ ي
Diacritics
طَلَبٌ
→ طلب
Tatweel
طـــلب
→ طلب
Normalize for search and matching, but be careful normalizing text sent to an LLM. Its tokenizer was trained on raw text.
A metric you should track
Fertility: tokens per word
Fertility = n_tokens / n_words. Lower is usually better: cheaper, faster, more text fits in context.
English-centric byte-level tokenizers can produce several times more tokens for Arabic than for English.
Multilingual (XLM-R, Qwen) and Arabic-specific (AraBERT, Jais, ALLaM) tokenizers close the gap.
Same message → different bill and different context budget.
Your lab task: fill in this table
Tokenizer
EN fertility
AR fertility
gpt2
?
?
google-bert/bert-base-multilingual-cased
?
?
FacebookAI/xlm-roberta-base
?
?
aubmindlab/bert-base-arabertv02
?
?
Qwen/Qwen2.5-0.5B-Instruct
?
?
What the model receives
IDs, masks, padding and truncation
from transformers import AutoTokenizer
tok = AutoTokenizer.from_pretrained("FacebookAI/xlm-roberta-base")
batch = tok(
["Where is my order?", "أين طلبي؟ لم يصل بعد"],
padding=True, # pad to longest in batch
truncation=True, # cut at max_length
max_length=32,
return_tensors="pt",
)
batch["input_ids"].shape # torch.Size([2, n])
batch["attention_mask"][0] # 1 = real token, 0 = pad
tok.convert_ids_to_tokens(batch["input_ids"][1])
tok.model_max_length # 512
input_ids: integer positions in the vocabulary.
attention_mask: tells the model to ignore padding.
Truncation: silent data loss. Check the length distribution of your data first.
Special tokens: <s></s>[CLS] or chat markers like <|im_start|> carry meaning. Keep them.
Engineering checklist
Tokenization mistakes that reach production
Mixing a model with another model's tokenizer
Truncating the important part (e.g., the actual question at the end of a long email)
Estimating cost with word counts instead of token counts
Over-normalizing input to an LLM (removing diacritics or casing it relies on)
Forgetting the chat template for instruction models (Day 2)
Losing character offsets needed to highlight extracted spans
Assuming the context limit applies only to input. Output tokens count too.
Not testing on dialect, typos, emoji and code-switched text
You will compare five tokenizers on our support tickets and quantify the "Arabic token tax".
Tokenize a ticket, map IDs ↔ tokens, decode back 5'
Inspect special tokens, padding, attention masks 5'
Compute fertility for EN vs. AR across 5 tokenizers 8'
Write an Arabic normalizer; measure its effect 5'
Find the token-length percentile for max_length2'
✱ Train a tiny BPE tokenizer on the tickets
Checkpoint
A ticket is 60 words in English and 60 words in Arabic. Using GPT-2's tokenizer, which is most likely?
Both produce about the same number of tokens
The Arabic version produces many more tokens
Arabic triggers [UNK] tokens and loses information
B. GPT-2 uses byte-level BPE trained mostly on English: no [UNK]s, but few Arabic merges, so Arabic text splits into many short byte pieces.
Break
10-minute break
Next: Session 2 · Embeddings & semantic search
Session 2
Embeddings: meaning as geometry
Explain dense vectors, cosine similarity and pooling
Choose an embedding model for a multilingual use case
Build and evaluate a semantic search over historical tickets
02
From IDs to vectors
An embedding is a learned coordinate
A token ID indexes a row of an embedding matrix (vocab × d, e.g., 250k × 384).
Training pushes rows so that things used similarly end up close.
Transformers then make vectors contextual: the same token gets different vectors in different sentences.
A sentence embedding compresses a whole text into one vector, which is ideal for search and clustering.
2-D projection for intuition. Real embeddings have 384 to 1024+ dimensions.
Similarity
Cosine similarity: compare directions, not lengths
cos(a, b) = a·b / (‖a‖‖b‖)
Range −1 … 1. In practice most text pairs fall between 0 and 1.
Normalize once (‖v‖ = 1) and cosine becomes a plain dot product, so a matrix multiply ranks the whole corpus.
Scores are relative. A 0.82 means nothing across models; compare rankings, not raw values.
import numpy as np
def cosine(a, b):
return a @ b / (np.linalg.norm(a) * np.linalg.norm(b))
# normalized matrices: one matmul ranks everything
E = corpus_emb / np.linalg.norm(corpus_emb, axis=1, keepdims=True)
q = query_emb / np.linalg.norm(query_emb)
scores = E @ q # shape: (n_docs,)
top5 = np.argsort(-scores)[:5]
Sentence embeddings
Token vectors → pooling → one vector per text
Ticket"refund not received"
→
Encodertransformer (Session 3)
→
Token vectorsn × 384
→
Poolingmean over real tokens (or [CLS])
→
Normalize1 × 384, ‖v‖=1
Why not just average a raw BERT?
You can, but it's mediocre. Sentence-embedding models are further trained with contrastive pairs (question ↔ answer, paraphrases), so similar meanings are pulled together and the rest pushed apart.
Bi-encoder vs. cross-encoder
Bi-encoder: embed each text separately. Fast, indexable, used for retrieval. Cross-encoder: read both texts together. Accurate, slow, used for reranking the top-k (Day 2).
Model selection
Choosing an embedding model
Model
Languages
Dim
Params
Notes
sentence-transformers/all-MiniLM-L6-v2
English
384
22M
Tiny, fast baseline
paraphrase-multilingual-MiniLM-L12-v2
50+ incl. Arabic
384
118M
Simple, symmetric similarity
intfloat/multilingual-e5-small
~100 incl. Arabic
384
118M
Needs query: / passage: prefixes. Our default.
BAAI/bge-m3
100+
1024
568M
Strong, 8k context, heavier
Check MTEB for retrieval tasks in your language, then confirm on your own data.
Read the model card: prefixes, max length, pooling, license.
Never mix vectors from two models in one index.
Architecture
Semantic search = offline indexing + online query
Index time (once / nightly)
Historical tickets + resolutions
↓
encode("passage: …"), normalize
↓
Store vectors + metadata (NumPy, FAISS, pgvector…)
Query time (every request)
New ticket
↓
encode("query: …"), normalize
↓
Top-k by dot product → return cases + their resolutions
Code
Semantic search in ~15 lines
from sentence_transformers import SentenceTransformer
import faiss
model = SentenceTransformer("intfloat/multilingual-e5-small")
docs = history["text"].tolist()
doc_emb = model.encode([f"passage: {d}" for d in docs],
normalize_embeddings=True, batch_size=64, show_progress_bar=True)
index = faiss.IndexFlatIP(doc_emb.shape[1]) # inner product == cosine (normalized)
index.add(doc_emb)
def search(query, k=5):
q = model.encode([f"query: {query}"], normalize_embeddings=True)
scores, ids = index.search(q, k)
return history.iloc[ids[0]].assign(score=scores[0])
search("لم يصلني المبلغ المسترد") # Arabic query, English history
Evaluation
Is the search any good? Measure it.
Metric
Question it answers
Recall@k / Hit@k
Is a relevant result anywhere in the top k?
Precision@k
What fraction of the top k is relevant?
MRR
How high is the first relevant result? (1/rank)
nDCG@k
Graded relevance with position discount
Cheap relevance labels: in our data, a retrieved ticket with the same intent as the query counts as relevant. You get an eval set for free.
Better: 50–100 real queries with hand-judged relevant documents. Small, but it's ground truth.
Leakage: the query must not be in the index. Search with test tickets against train tickets.
Failure modes
Where pure semantic search fails
Exact identifiers: order #A-55821, product codes, IBANs. Embeddings blur these, so add keyword search (BM25) and combine → hybrid.
Negation: "I don't want a refund" sits close to "I want a refund".
Length mismatch: a 5-word query vs. a 2-page document; chunk the documents (Day 2).
Domain jargon the model never saw → evaluate, then consider fine-tuning embeddings.
Cross-lingual gaps: dialect queries against MSA or English documents. Test them explicitly.
Head (task-specific): classify · predict mask · next token
Positions: attention is order-blind, so position must be injected. Older models use learned absolute positions; modern LLMs use RoPE (rotary), which extends better to long contexts.
Residual + norm: lets very deep stacks train stably.
The head is small. Pretraining learns the body, and your task usually only needs a new head (Day 3).
Three families
Encoder, decoder, encoder–decoder
Encoder-only
Decoder-only
Encoder–decoder
Attention
Bidirectional: sees left and right
Causal: sees only the past
Bidirectional encoder + causal decoder
Pretraining
Masked language modeling
Next-token prediction
Span corruption / denoising
Best at
Understanding: classification, NER, embeddings
Generation, chat, reasoning, tools
Translation, summarization
Examples
BERT, XLM-R, AraBERT, CAMeLBERT, ModernBERT
GPT, Llama, Qwen, Jais, ALLaM
T5, mT5, BART, AraT5
In this course
Day 1 classifiers, Day 3 fine-tuning
Day 2 LLM apps, Day 3 LoRA
Mentioned only
Rule of thumb: if the output is a label, span or vector, start with an encoder. If the output is free text, you need a decoder.
Pretraining objective
Masked LM vs. next-token prediction
Encoder: fill the blank
My<mask>hasnotarrivedyet,Iordereditlastweek
Prediction
p
order
0.40
package
0.22
refund
0.08
Illustrative probabilities
Decoder: continue the text
Myorderhasnot…
Next token
p
arrived
0.52
been
0.31
shipped
0.07
Tomorrow: sampling from this distribution
Hugging Face mental model
Tokenizer + body + head
from transformers import AutoTokenizer, AutoModel
import torch
name = "google-bert/bert-base-multilingual-cased"
tok = AutoTokenizer.from_pretrained(name)
model = AutoModel.from_pretrained(name, output_attentions=True)
enc = tok("You charged me twice for one order", return_tensors="pt")
with torch.no_grad():
out = model(**enc)
out.last_hidden_state.shape # (batch, seq_len, hidden) = (1, n, 768)
len(out.attentions) # 12 layers
out.attentions[0].shape # (1, heads=12, n, n)
Class
Head
AutoModel
none: hidden states
…ForSequenceClassification
label per text
…ForTokenClassification
label per token (NER)
…ForMaskedLM
fill-mask
…ForCausalLM
next token (LLMs)
pipeline("task", model=…) wraps tokenize → model → post-process for quick experiments.
Practical model choice
Reading a model card like an engineer
Fit
Languages and dialects covered
Training data domain
Max sequence length
Cost
Parameters → memory, latency
CPU-viable? Quantized versions?
Throughput at your batch size
Risk
License (commercial use?)
Known biases and limitations
Maintained? Reproducible revision?
Pin the revision in production: from_pretrained(name, revision="<commit-sha>"), because models on the Hub can change under you.
Look at hidden states and attention, implement attention in NumPy, and watch context change a word's vector.
Implement scaled dot-product attention in 10 lines of NumPy 6'
Inspect hidden-state and attention shapes for a ticket 4'
Plot an attention heatmap for chosen layer/head 5'
Contextual embeddings: "charge" in two contexts 5'
Fill-mask in English and Arabic with XLM-R 5'
✱ Causal mask: compare encoder vs. decoder attention patterns
Checkpoint
You need to route 2 million tickets per day into 27 intents with low latency. Which family do you start with?
An encoder (e.g., XLM-R or a multilingual MiniLM) with a classification head
A 70B decoder LLM with a prompt listing all intents
An encoder–decoder translation model
A. The output is a label, the volume is high and latency matters. Small encoders are cheap, fast and accurate after fine-tuning. Keep the LLM for cases where you need generated text.
Break
10-minute break
Next: Session 4 · Decision models
Session 4
Decision models: when the answer is a label
Build zero-shot and embedding-based classifiers for ticket intent
Evaluate with per-class precision/recall/F1 and confusion matrices
Use thresholds to abstain and route uncertain cases to humans
Decision models are measurable, fast, cheap and auditable. They are often the backbone that LLM features sit on.
The ladder
Six ways to classify a ticket
Approach
Labelled data
Latency / cost
Typical quality
When
1 · Keyword rules
none
µs / free
brittle
Hard constraints, bootstrapping
2 · TF-IDF + logistic regression
hundreds+
µs / free
decent baseline
Always run it as a baseline
3 · Zero-shot (NLI / label embeddings)
none
ms / low
fair
Cold start, changing labels
4 · Frozen embeddings + linear head
tens–hundreds
ms / low
good
Fast, strong default
5 · Fine-tuned encoder
hundreds–thousands
ms / low
best per $
Day 3
6 · LLM prompting
few examples
100s ms–s / $$
good, variable
Day 2: low volume, nuance
Zero-shot classification
Reuse a model trained for "does A imply B?"
An NLI model scores whether a premise entails a hypothesis. Turn each label into a hypothesis:
Premise: "I was billed twice for the same order" Hypothesis: "This customer message is about payment."
No training data needed; labels can change at runtime.
Label wording matters a lot: "billing problem" ≠ "PAYMENT".
Cost scales with the number of labels (one pass per label).
import torch
from transformers import pipeline
clf = pipeline(
"zero-shot-classification",
model="MoritzLaurer/mDeBERTa-v3-base-xnli-multilingual-nli-2mil7",
dtype=torch.float32, # checkpoint is fp16: slow on CPU
)
labels = ["refund", "delivery", "payment problem",
"account access", "order changes", "invoice"]
clf("لقد تم خصم المبلغ مرتين من بطاقتي",
candidate_labels=labels,
hypothesis_template="This customer message is about {}.")
# {'labels': [...], 'scores': [...]} label wording changes the ranking
Embeddings + a linear head
The best value-for-effort classifier
from sentence_transformers import SentenceTransformer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import classification_report
enc = SentenceTransformer("intfloat/multilingual-e5-small")
X_tr = enc.encode(["query: " + t for t in train.text], normalize_embeddings=True)
X_te = enc.encode(["query: " + t for t in test.text], normalize_embeddings=True)
clf = LogisticRegression(max_iter=2000, C=4.0)
clf.fit(X_tr, train.intent)
pred = clf.predict(X_te)
proba = clf.predict_proba(X_te).max(axis=1) # confidence
print(classification_report(test.intent, pred, digits=3))
Encoder is frozen: encode once, then train in seconds on CPU. Swapping classifiers is cheap.
Bonus: the same vectors power search. One embedding pass, two features.
Compare against TF-IDF + LR. If the gain is small, ask why.
Evaluation
Accuracy is not enough
Metric
Meaning for "complaint" class
Precision
Of tickets we flagged as complaint, how many were?
Recall
Of real complaints, how many did we catch?
F1
Harmonic mean of the two
Macro-F1
Average over classes: rare classes count equally
Weighted-F1
Average weighted by class frequency
Imbalance trap: if 95% of tickets are "other", a model that always says "other" has 95% accuracy and zero use.
Confusion matrix shows which classes get mixed up (e.g., get_refund vs track_refund). That's where you focus labelling and fixes.
Slice it: report English vs. Arabic, short vs. long, dialect vs. MSA.
Thresholds and abstention
Let the model say "I'm not sure"
Raising the threshold improves accuracy on what you keep, but more goes to humans.
def route(text, tau=0.6):
p = clf.predict_proba(embed([text]))[0]
i = p.argmax()
if p[i] < tau:
return {"intent": None, "action": "human_review"}
return {"intent": clf.classes_[i], "conf": float(p[i])}
Pick τ from validation data, not test.
Thresholds can be per class: stricter for costly errors.
Raw probabilities are often over-confident. Calibrate if the score drives decisions.
Think like the business
Not all errors cost the same
Missed urgent complaint
Customer churns, social-media escalation. High cost, so optimize recall for complaint and payment_issue.
Wrong queue
An agent re-routes it, costing minutes. Medium cost.
Newsletter mislabeled
Barely noticed. Low cost, so automate aggressively.
Write the cost table first, then pick metrics and thresholds that minimise expected cost. That's what you defend in your capstone.
Climb the ladder (TF-IDF → zero-shot → embeddings + LR), evaluate properly, add abstention, and combine it with this morning's search.
TF-IDF + logistic regression baseline 5'
Zero-shot NLI on categories; try two label wordings 7'
Embeddings + logistic regression on intents 5'
Report, confusion matrix, EN vs. AR slice 5'
Coverage–accuracy curve; choose τ on validation 4'
Integrate: process_ticket() = classify + top-3 similar 4'
✱ kNN-vote classifier from the search index; compare errors
Day 1 deliverable
What you built today
TicketEN or AR
→
Embedmultilingual-e5-small
→
Searchtop-k similar historical cases
→
Classifyintent + confidence → abstain
→
EvaluateHit@5, MRR, macro-F1, by language
Remember
Tokens drive cost and context. Measure fertility for Arabic.
Remember
Embeddings + a linear head is a strong default. Always compare with a baseline.
Remember
Pick thresholds from error costs, on validation data.
Capstone: start thinking
Form teams of 2–3 by the end of today
1 · Ticket Assistant
Classify tickets, retrieve similar cases, draft grounded replies.
2 · Arabic Document Intelligence
Extract and categorize information from Arabic documents; answer questions with evidence.
3 · Voice of Customer
Classify feedback, cluster issues with embeddings, generate insight summaries.
Tonight (optional, 30 min): pick a use case and find or describe your data. Bring one sentence: "Our system takes ___ and produces ___, measured by ___."
Tomorrow · Day 2
LLMs & Applied NLP Systems
From predicting labels to generating grounded, structured answers.