|
| 1 | +# DataFog PII-NER: Training Chronicle |
| 2 | + |
| 3 | +A record of building a 22.7M parameter PII detection model from architecture research through first production training run and v1.1 improvements. |
| 4 | + |
| 5 | +--- |
| 6 | + |
| 7 | +## Phase 0: Research & Architecture Design |
| 8 | + |
| 9 | +**Commits:** `755f034` → `889a36e` |
| 10 | + |
| 11 | +We started with an extensive research phase — 2,800+ lines of reports across 8 documents surveying 26 model architectures, 9 PII datasets, and the competitive landscape (GLiNER2, Presidio, Microsoft). The research identified three genuine gaps in the PII NER literature: |
| 12 | + |
| 13 | +1. No existing model combines character-level pattern recognition with contextual embeddings for PII |
| 14 | +2. No tiered evaluation framework prioritizes critical PII (SSN, credit cards) over general entities |
| 15 | +3. No small (<50M param) model specifically targets PII at production latency (<50ms) |
| 16 | + |
| 17 | +**Architecture decision:** DeBERTa-v3-xsmall (22M params, pretrained) + CharCNN (0.3M) + Gating Fusion (0.1M) + CRF Head (0.2M). The key insight: structured PII like SSNs and credit cards have rigid character patterns (XXX-XX-XXXX), while soft PII like names and addresses need contextual understanding. The gating fusion lets the model dynamically weight character features vs. contextual features per token. |
| 18 | + |
| 19 | +**Data:** 360K examples from three open-licensed HuggingFace datasets — AI4Privacy (200K, Apache 2.0), NVIDIA Nemotron-PII (100K, CC-BY-4.0), and Gretel Synthetic Finance (56K, Apache 2.0). Unified into a 4-tier taxonomy of 44 canonical entity types (89 BIO labels). |
| 20 | + |
| 21 | +--- |
| 22 | + |
| 23 | +## Phase 1: Smoke Test (Overfit Validation) |
| 24 | + |
| 25 | +**Commits:** `9bea601` → `9922e44` |
| 26 | + |
| 27 | +The first milestone was proving the model wiring works by overfitting 100 examples for 50 epochs on a free Colab T4. |
| 28 | + |
| 29 | +### Run 1: Flat Learning Rate — Total Failure |
| 30 | + |
| 31 | +Used a single 5e-4 learning rate for the entire model. After 50 epochs: F1 = 0.000. Not a single entity predicted. The model learned to predict "O" (not-an-entity) for every token — a local minimum that reduces loss because ~85% of tokens are "O". |
| 32 | + |
| 33 | +**Root cause:** A flat LR simultaneously destroys the pretrained DeBERTa weights (too high for fine-tuning) and learns the CRF head too slowly (too low for random initialization). The pretrained/random asymmetry demands different learning rates. |
| 34 | + |
| 35 | +### Run 2: Differential Learning Rates — Success |
| 36 | + |
| 37 | +Set backbone LR to 2e-5 (gentle fine-tuning) and head LR to 1e-3 (fast learning from scratch) — a 50x ratio. Added 10% warmup and weight decay 0.01. |
| 38 | + |
| 39 | +The training curve was dramatic: 11 epochs of zero F1 (CRF learning internal transition probabilities), then a phase transition at epoch 12 where the first entities appeared. By epoch 50: **F1 = 0.947**, near-perfect overfit. |
| 40 | + |
| 41 | +Entities emerged in order of frequency and pattern distinctiveness: Person (epoch 12) → Location (15) → Email/Credit Card (17) → Date (18) → Phone (20) → SSN (30). This order made intuitive sense — common contextual entities first, then pattern-based entities. |
| 42 | + |
| 43 | +**Lesson learned:** Differential learning rates aren't optional when combining pretrained and randomly initialized components. This is well-known in NLP but the failure mode with CRF (silent all-O predictions with decreasing loss) is uniquely deceptive. |
| 44 | + |
| 45 | +We also had to adjust success thresholds. CRF loss operates on a fundamentally different scale than cross-entropy (sequence-level partition function over 89^seq_len paths). A CRF loss of 5.3 at convergence means ~45% probability on the exact correct sequence — excellent, but nowhere near 0.1. |
| 46 | + |
| 47 | +### Colab Integration Issues |
| 48 | + |
| 49 | +Spent 10 commits fixing Colab environment issues: GPU memory attribute names changed between PyTorch versions, pip install bracket escaping, AI4Privacy column name detection, HuggingFace `List` feature type breaking `getattr`, and FP16 numerical instability in the CRF head. |
| 50 | + |
| 51 | +--- |
| 52 | + |
| 53 | +## Phase 2: The NaN Gauntlet (Full Training Debugging) |
| 54 | + |
| 55 | +**Commits:** `8da5cba` → `c43788b` |
| 56 | + |
| 57 | +Scaling from 100 examples to 360K revealed three distinct sources of NaN, each requiring a different fix. This was the hardest part of the project. |
| 58 | + |
| 59 | +### NaN Source 1: CRF Emission Overflow |
| 60 | + |
| 61 | +**Symptom:** Occasional NaN loss during training. |
| 62 | +**Cause:** The CRF's log-sum-exp forward algorithm overflows when emission values are extreme. |
| 63 | +**Fix:** Clamp emissions to [-100, 100]. Safe because the CRF only uses relative differences between scores. |
| 64 | + |
| 65 | +### NaN Source 2: AdamW Bias-Correction Amplification |
| 66 | + |
| 67 | +**Symptom:** Training loss appears as 0.000000 from step 2 onward (HF Trainer v5.0 renders NaN as 0). All metrics zero. |
| 68 | +**Cause:** AdamW's adaptive scaling makes the effective update ≈ ±lr × sign(gradient), independent of gradient magnitude. With standard eps=1e-8, every backbone weight changes by ±2e-5 per step — enough to push DeBERTa's internal representations into NaN territory on PyTorch 2.9+. |
| 69 | + |
| 70 | +We confirmed this through systematic ablation: |
| 71 | +- Default AdamW: NaN at step 2 |
| 72 | +- Reference AdamW (no CUDA fusion): NaN at step 2 — not a CUDA kernel bug |
| 73 | +- Manual SGD (p -= lr * grad): OK — raw updates are safe |
| 74 | +- Frozen DeBERTa (head only): OK — head learns without backbone |
| 75 | +- **AdamW with eps=1.0 for backbone: OK — fix confirmed** |
| 76 | + |
| 77 | +**Fix:** Set eps=1.0 in the backbone parameter group. This dampens adaptive scaling so updates are proportional to gradient magnitude (SGD-like) rather than a fixed ±lr. The head retains standard eps=1e-8 because it benefits from adaptive scaling as a randomly initialized component. |
| 78 | + |
| 79 | +### NaN Source 3: BF16 Mantissa Precision |
| 80 | + |
| 81 | +**Symptom:** Identical to NaN #2 — training loss 0.000000, all metrics zero. Even with the eps=1.0 fix present. |
| 82 | +**Cause:** BF16 has only 7 bits of mantissa (vs 10 for FP16, 23 for FP32). DeBERTa v3's disentangled attention with relative position encodings involves large attention scores before softmax, exponentiation, and layer norm with small denominators. Under BF16, these operations lose enough precision to produce NaN — especially with real text at full sequence length, where attention patterns are more concentrated than synthetic random data. |
| 83 | + |
| 84 | +**Why the preflight didn't catch it:** The original validation used batch_size=4 and seq_len=32 with random data. Smaller sequences and random tokens produce diffuse attention patterns that don't trigger BF16 overflow. |
| 85 | + |
| 86 | +### NaN Source 4: FP16 Gradient Scaler Crash |
| 87 | + |
| 88 | +**Symptom:** `ValueError: Attempting to unscale FP16 gradients` — Accelerate's gradient scaler crashes with custom optimizer parameter groups. |
| 89 | +**Cause:** HF Accelerate 1.x+ changed how it handles mixed precision with custom optimizers. The gradient scaler expected specific parameter group structure. |
| 90 | + |
| 91 | +### The Final Precision Strategy |
| 92 | + |
| 93 | +After fighting all four NaN sources, we landed on a robust auto-selection strategy: |
| 94 | + |
| 95 | +1. Try BF16 first (no gradient scaler, simpler) — works on RTX 3090 (compute 8.6) |
| 96 | +2. Fall back to FP32 if BF16 fails |
| 97 | +3. Skip FP16 entirely (gradient scaler incompatibility is unfixable without forking Accelerate) |
| 98 | +4. Always force FP32 for CRF layer via `fused.float()` + `autocast(enabled=False)` |
| 99 | + |
| 100 | +The preflight check was upgraded to use full batch size/sequence length, 5 steps, explicit NaN checks on loss and all parameters, and loss-decreasing verification. |
| 101 | + |
| 102 | +--- |
| 103 | + |
| 104 | +## Phase 3: V1 Full Training |
| 105 | + |
| 106 | +**Commit:** `1ebfb3e` |
| 107 | + |
| 108 | +Trained on A100 (Colab) with BF16, 10 epochs, effective batch size 32. |
| 109 | + |
| 110 | +### Training Curve |
| 111 | + |
| 112 | +| Epoch | Val Loss | F1 | Notes | |
| 113 | +|-------|----------|------|-------| |
| 114 | +| 1 | 13.96 | 0.818 | Strong start — pretrained backbone carrying | |
| 115 | +| 3 | 7.86 | 0.889 | Steady improvement | |
| 116 | +| 5 | 6.17 | **0.903** | **Best checkpoint** | |
| 117 | +| 6 | 10.66 | 0.886 | Regression begins | |
| 118 | +| 7 | 10.24 | 0.886 | Train loss spiked 2.96 → 5.81 | |
| 119 | + |
| 120 | +Best model restored from epoch 5 via `load_best_model_at_end=True`. |
| 121 | + |
| 122 | +### Test Set Results |
| 123 | + |
| 124 | +| Metric | Score | |
| 125 | +|--------|-------| |
| 126 | +| Overall F1 | 0.904 | |
| 127 | +| Overall Precision | 0.907 | |
| 128 | +| Overall Recall | 0.902 | |
| 129 | + |
| 130 | +### Tier Recall vs Targets |
| 131 | + |
| 132 | +| Tier | Recall | Target | Status | |
| 133 | +|------|--------|--------|--------| |
| 134 | +| Tier 1 (SSN, Credit Card, Passport, ...) | 0.722 | >= 0.98 | **FAIL** | |
| 135 | +| Tier 2 (Person, Email, Phone, ...) | 0.934 | >= 0.95 | **FAIL** | |
| 136 | +| Tier 3 (Username, Date, Location, ...) | 0.919 | >= 0.90 | PASS | |
| 137 | +| Tier 4 (Medical Record, Employee ID, ...) | 0.866 | >= 0.85 | PASS | |
| 138 | + |
| 139 | +### What We Learned |
| 140 | + |
| 141 | +**Overall F1 of 0.904 is strong for a 22.7M param model** — competitive with much larger models (Knowledgator GLiNER-PII best F1: 0.81). But the tier breakdown revealed a critical gap: the entities that matter most (financial, identity documents) have the worst recall. |
| 142 | + |
| 143 | +**Late-epoch regression** at epochs 6-7 indicated the backbone LR of 2e-5 was too aggressive for extended training. The cosine schedule or lower LR was needed. |
| 144 | + |
| 145 | +Model uploaded to HuggingFace Hub under the DataFog org. |
| 146 | + |
| 147 | +--- |
| 148 | + |
| 149 | +## Phase 4: Diagnosing the Tier 1 Gap |
| 150 | + |
| 151 | +**Commit:** `53e656f` |
| 152 | + |
| 153 | +### Entity Frequency Audit |
| 154 | + |
| 155 | +Ran a comprehensive audit across all 360K training examples. The results explained everything: |
| 156 | + |
| 157 | +| Entity Type | Count | % of Total | F1 | |
| 158 | +|-------------|-------|-----------|------| |
| 159 | +| DATE | 169,799 | 19.1% | 0.960 | |
| 160 | +| PERSON | 163,418 | 18.4% | 0.951 | |
| 161 | +| LOCATION | 97,834 | 11.0% | 0.918 | |
| 162 | +| ... | | | | |
| 163 | +| BANK_ACCOUNT | 1,001 | 0.11% | 0.791 | |
| 164 | +| PASSPORT_NUMBER | 526 | **0.06%** | **0.469** | |
| 165 | + |
| 166 | +**323x imbalance** between the most common entity (DATE: 170K) and the rarest (PASSPORT_NUMBER: 526). Direct correlation between frequency and model performance. The model isn't architecturally weak on Tier 1 — it just doesn't see enough examples. |
| 167 | + |
| 168 | +Additionally, **16 entity types have zero training examples** (NATIONALITY, ETHNICITY, RELIGION, MARITAL_STATUS, and 12 others). These exist in our taxonomy but none of the three datasets annotate them. |
| 169 | + |
| 170 | +--- |
| 171 | + |
| 172 | +## Phase 5: V1.1 Improvements |
| 173 | + |
| 174 | +**Commits:** `53e656f` → `350a1b1` |
| 175 | + |
| 176 | +Three targeted fixes based on the audit findings: |
| 177 | + |
| 178 | +### 1. Tier-Weighted CRF Loss |
| 179 | + |
| 180 | +Sequences containing critical PII now get proportionally higher loss weight: |
| 181 | +- Tier 1 entities: 3x weight (SSN, Credit Card, Passport, etc.) |
| 182 | +- Tier 2 entities: 2x weight (Person, Email, Phone, etc.) |
| 183 | +- Tier 3 entities: 1.5x weight |
| 184 | +- Tier 4 entities: 1x weight (baseline) |
| 185 | + |
| 186 | +Implementation: CRF returns per-sequence log-likelihoods (reduction="none"), each weighted by the max label weight in that sequence. This means a batch with one SSN example and seven date-only examples will allocate 3x more gradient signal to the SSN example. |
| 187 | + |
| 188 | +### 2. Oversampling Rare Entities |
| 189 | + |
| 190 | +Examples containing Tier 1 entities are duplicated 3x in the training set. Applied after train/val/test split to avoid data leakage. This doesn't create new data but ensures the model sees critical PII examples ~4x more often per epoch. |
| 191 | + |
| 192 | +### 3. Training Dynamics |
| 193 | + |
| 194 | +- Backbone LR halved: 2e-5 → 1e-5 (prevents the epoch 6-7 regression) |
| 195 | +- Cosine LR schedule (smoother decay than linear) |
| 196 | +- 15 epochs (from 10) with 500-step warmup |
| 197 | +- Best checkpoint selection by `overall_f1` |
| 198 | + |
| 199 | +### Preflight Verification |
| 200 | + |
| 201 | +Ran on RTX 3090: BF16 loss 1166.0 → 1150.6 (decreasing). All changes verified compatible with the precision strategy. |
| 202 | + |
| 203 | +### Inference Pipeline |
| 204 | + |
| 205 | +Built `PiiPipeline` — a simple API for PII detection: |
| 206 | + |
| 207 | +```python |
| 208 | +from datafog_pii_ner.inference import PiiPipeline |
| 209 | + |
| 210 | +pipeline = PiiPipeline.from_pretrained("DataFog/pii-ner-v1") |
| 211 | +entities = pipeline("My SSN is 123-45-6789") |
| 212 | +# [PiiEntity(text='123-45-6789', label='SSN', start=10, end=21, tier=1)] |
| 213 | +``` |
| 214 | + |
| 215 | +Supports HuggingFace Hub loading, character-level offsets, BIO→span decoding, and batch input. 9 tests, all passing (40 total across the project). |
| 216 | + |
| 217 | +--- |
| 218 | + |
| 219 | +## Commit Log Summary |
| 220 | + |
| 221 | +| Phase | Commits | Key Outcome | |
| 222 | +|-------|---------|-------------| |
| 223 | +| Research | 2 | Architecture decision, design doc | |
| 224 | +| Smoke Test | 18 | F1=0.947 on 100 examples, Colab env fixes | |
| 225 | +| NaN Debugging | 5 | Four distinct NaN sources identified and fixed | |
| 226 | +| Precision Strategy | 3 | Auto-select BF16→FP32 fallback | |
| 227 | +| V1 Full Training | 1 | F1=0.904 on 360K examples, model on HuggingFace | |
| 228 | +| V1.1 Improvements | 3 | Tier-weighted loss, oversampling, inference pipeline | |
| 229 | +| **Total** | **32** | | |
| 230 | + |
| 231 | +--- |
| 232 | + |
| 233 | +## Open Questions for V1.1+ |
| 234 | + |
| 235 | +1. **Will tier-weighted loss + oversampling close the Tier 1 gap?** The 323x imbalance is severe. Oversampling helps but doesn't create genuinely new examples. If Tier 1 recall stays below 0.90, we may need synthetic data augmentation. |
| 236 | + |
| 237 | +2. **16 zero-occurrence entity types** — NATIONALITY, ETHNICITY, RELIGION, MARITAL_STATUS, MEDICAL_RECORD, STUDENT_ID, DEVICE_ID, CRYPTO_WALLET, INSURANCE_NUMBER, SALARY, CRIMINAL_RECORD, POLITICAL_AFFILIATION, SEXUAL_ORIENTATION, HEALTH_CONDITION, GENETIC_DATA, TRADE_UNION. These can't be learned without new data sources. Should we drop them from the taxonomy or find additional datasets? |
| 238 | + |
| 239 | +3. **ONNX export** — The CRF Viterbi decode doesn't export cleanly from pytorch-crf. May need a pure-PyTorch reimplementation (~50 lines) for the ONNX path. |
| 240 | + |
| 241 | +4. **Backbone scaling** — DeBERTa-v3-xsmall (22M) was chosen for latency. If we need more capacity for Tier 1, DeBERTa-v3-small (44M) or DeBERTa-v3-base (86M) are the next steps, with corresponding latency trade-offs. |
0 commit comments