How to fine-tune pre-trained language models for your specific NLP tasks.
| Approach | Data Needed | Latency | Accuracy | Cost |
|---|---|---|---|---|
| Zero-shot (prompt a large LLM) | 0 examples | High (API call) | Good for general tasks | Per-token API cost |
| Few-shot (in-context examples) | 3-10 examples | High (longer prompt) | Better than zero-shot | Higher per-token cost |
| Fine-tune small model | 100-10k examples | Low (local inference) | Best for specific tasks | One-time GPU cost |
| Fine-tune with LoRA | 100-10k examples | Low | Near full fine-tune | Lower GPU cost |
Fine-tune when: You have labeled data, need low latency, want to avoid API costs, or need domain-specific accuracy that general models cannot match.
| Task | Recommended Base | Why |
|---|---|---|
| Text classification | distilbert-base-uncased | Fast, 60% fewer params than BERT, 97% accuracy |
| Sentiment analysis | distilbert-base-uncased | Same — sentiment is classification |
| NER | bert-base-cased | Case matters for entity recognition |
| Multi-label | roberta-base | Better at nuanced multi-class decisions |
| Domain-specific | allenai/scibert_scivocab_uncased | Match your domain (sci, bio, legal, fin) |
| Low-resource language | xlm-roberta-base | 100+ language support |
from nlp_starter_kit.fine_tuning import FineTuningConfig, HuggingFaceFineTuner
config = FineTuningConfig(
model_name="distilbert-base-uncased",
num_epochs=3,
batch_size=16,
learning_rate=2e-5,
)
tuner = HuggingFaceFineTuner(config)
tuner.prepare_data(
train_texts=["Great product!", "Terrible service", ...],
train_labels=["positive", "negative", ...],
eval_texts=["Loved it!", "Would not recommend", ...],
eval_labels=["positive", "negative", ...],
)
metrics = tuner.train()
print(f"Accuracy: {metrics['eval_accuracy']}")
tuner.save_model("./my-classifier")LoRA (Low-Rank Adaptation) freezes the base model and injects small trainable matrices into attention layers. Benefits:
config = FineTuningConfig(
model_name="bert-large-uncased",
use_peft=True,
lora_r=8, # Rank — lower = fewer params, less expressive
lora_alpha=16, # Scaling factor — typically 2x lora_r
lora_dropout=0.05, # Regularization
lora_target_modules=["query", "value"], # Which attention matrices
learning_rate=3e-4, # LoRA uses higher LR than full fine-tuning
num_epochs=5,
batch_size=8,
gradient_accumulation_steps=4, # Effective batch = 32
fp16=True,
)| Rank (r) | Trainable Params | Use Case |
|---|---|---|
| 4 | ~0.3% of total | Small dataset (<1k), overfitting risk |
| 8 | ~0.6% of total | Default — good balance |
| 16 | ~1.2% of total | Larger datasets, complex tasks |
| 32 | ~2.4% of total | When you need near-full-fine-tune quality |
| 64+ | ~5%+ of total | Rarely needed — consider full fine-tuning |
The kit provides two schedulers, both matching Hugging Face Trainer behavior:
LR
│ /‾‾‾‾‾‾‾‾‾‾‾‾\
│ / \
│/ \
└──────────────────── Steps
warmup decay
Good for most tasks. The warmup prevents early training instability.
LR
│ /‾‾‾‾‾\_
│ / ‾‾\_
│/ ‾‾‾‾
└──────────────────── Steps
warmup cosine decay
Better for longer training runs. The gradual decay lets the model explore longer before converging.
| Dataset Size | Epochs | LR | Batch Size | Weight Decay |
|---|---|---|---|---|
| < 500 | 10-20 | 1e-5 | 8 | 0.1 |
| 500-5k | 3-5 | 2e-5 | 16 | 0.01 |
| 5k-50k | 2-3 | 3e-5 | 32 | 0.01 |
| 50k+ | 1-2 | 5e-5 | 64+ | 0.001 |
If your GPU cannot fit batch_size=32, use gradient accumulation:
batch_size=8,
gradient_accumulation_steps=4, # Effective batch = 8 × 4 = 32The optimizer updates after accumulating gradients from 4 mini-batches, giving the same training dynamics as a larger batch without the memory cost.
Always evaluate during training to catch overfitting early:
config = FineTuningConfig(
eval_strategy="epoch", # Evaluate after each epoch
save_strategy="epoch", # Save checkpoint after each epoch
save_total_limit=3, # Keep only 3 most recent checkpoints
)Watch for:
1. Using the wrong casing. If you're doing NER, use a cased model (bert-base-cased). Uncased models lose important entity signals.
2. Too many epochs on small data. With <1000 examples, 3 epochs of full fine-tuning will overfit. Use LoRA or early stopping.
3. Not shuffling training data. If all positive examples come before negative examples, the model learns order, not content. Hugging Face Trainer shuffles by default.
4. Ignoring class imbalance. If 90% of your data is one class, the model will just predict that class. Use weighted loss or oversample the minority class.
5. Not saving the tokenizer. When you save a fine-tuned model, always save the tokenizer too — it defines how text is converted to input IDs.
How to build robust text cleaning and normalization pipelines that handle real-world data.
Raw text from the wild is messy. Web scrapes contain HTML tags, user-generated content has inconsistent casing and Unicode, CSVs from different systems encode the same characters differently. If you skip preprocessing, your model trains on noise.
The NLP Starter Kit provides three preprocessing layers, each solving a distinct problem:
| Layer | Problem | Example |
|---|---|---|
| Cleaning | Structural noise | HTML tags, URLs, email addresses |
| Normalization | Encoding inconsistency | café vs cafe\u0301, mixed case |
| Tokenization | Unit definition | What counts as a "word"? |
Raw Text → Cleaner → Normalizer → Tokenizer → Clean Tokens
Each stage is independently configurable. You can use any combination — skip cleaning if your data is already clean, skip tokenization if you just need normalized strings.
from nlp_starter_kit.text_processor import TextProcessor, CleaningConfig
processor = TextProcessor(
cleaning=CleaningConfig(
strip_html=True,
remove_urls=True,
remove_emails=True,
remove_mentions=False,
custom_patterns=[
r"\[(?:ref|cite|note):\S+\]", # Remove inline references
r"(?:Advertisement|Sponsored)\s*$", # Remove ad markers
],
),
normalize=True,
normalizer_kwargs={"lowercase": True, "strip_accents": False},
)
result = processor.process(html_content)
clean_text = result.normalized# For social media, you often WANT mentions and hashtags as features
processor = TextProcessor(
cleaning=CleaningConfig(
strip_html=False,
remove_urls=True,
remove_mentions=False, # Keep @mentions as features
remove_hashtags=False, # Keep hashtags
remove_punctuation=False, # Punctuation carries sentiment
),
normalize=True,
normalizer_kwargs={"lowercase": True},
tokenize=True,
tokenizer_kwargs={"lowercase": True, "keep_punctuation": True},
)# For search, strip everything down to normalized word stems
processor = TextProcessor(
cleaning=CleaningConfig(
strip_html=True,
remove_urls=True,
remove_emails=True,
remove_punctuation=True,
remove_numbers=False, # Numbers can be search terms
remove_stopwords=True, # "the", "is", "at" add no search value
min_token_length=2, # Single-char tokens are noise
),
normalize=True,
normalizer_kwargs={
"lowercase": True,
"strip_accents": True, # café = cafe for search
"unicode_form": "NFKC",
},
)Unicode normalization is the most misunderstood preprocessing step. Here is when to use each form:
| Form | Effect | Use When |
|---|---|---|
| NFC | Compose characters (é stays as é) | Default for most text storage |
| NFD | Decompose characters (é → e + ◌́) | When you need to strip accents |
| NFKC | Compose + decompose compatibility chars (fi→fi, ²→2) | ML pipelines (recommended) |
| NFKD | Decompose everything | Rarely used directly |
Always use NFKC for ML. It normalizes ligatures (fi→fi), superscripts (²→2), and fullwidth characters (A→A) that would otherwise create duplicate vocabulary entries.
The built-in stopword list covers ~175 English function words. But stopword removal is not always beneficial:
Remove stopwords when:
Keep stopwords when:
For large datasets, always use batch methods:
# Slow — creates a new processor per call
results = [TextProcessor().process(t) for t in texts]
# Fast — reuses compiled patterns and state
processor = TextProcessor(cleaning=CleaningConfig(remove_urls=True))
results = processor.process_batch(texts)The batch methods avoid recompiling regex patterns on every call. For 100k+ texts, this makes a measurable difference.
1. Cleaning before normalization: Always clean first. HTML entity decoding (part of cleaning) can introduce characters that normalization needs to handle.
2. Over-aggressive cleaning: Removing all punctuation destroys sentence boundaries, which matters for sentence-level tasks. Only remove punctuation if you truly don't need it.
3. Not handling encoding: If your input files mix UTF-8 and Latin-1, normalize encoding _before_ the text pipeline. Python's str type is always Unicode, but reading from files can produce garbage if the encoding is wrong.
4. Forgetting offset tracking: If you clean text and then run NER on it, the entity character offsets will not match the original text. Either run NER before cleaning, or use the tokenizer's offset tracking to map back.