Skip to content

Commit 53e656f

Browse files
Sid Mohanclaude
andcommitted
Implement v1.1 model quality improvements
Address v1 weaknesses: Tier 1 recall 0.722 (target 0.98) and Tier 2 recall 0.934 (target 0.95) caused by 323x entity frequency imbalance. Changes: - Tier-weighted CRF loss: sequences with critical PII get higher loss weight (3x for Tier 1, 2x for Tier 2, 1.5x for Tier 3) - Oversampling: duplicate Tier 1 entity examples x3 in training set (applied after train/val/test split to avoid data leakage) - Halved backbone LR (2e-5 -> 1e-5) to prevent late-epoch regression observed in v1 at epochs 6-7 - Cosine LR schedule (from linear) for smoother decay - 15 epochs (from 10) with 500-step warmup - Entity frequency audit script for data analysis Preflight passed on RTX 3090: BF16 loss 1166.0 -> 1150.6 (decreasing). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 1ebfb3e commit 53e656f

7 files changed

Lines changed: 370 additions & 86 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
model:
2+
backbone: microsoft/deberta-v3-xsmall
3+
char_embed_dim: 50
4+
char_vocab_size: 256
5+
char_cnn_filters: [50, 50, 50]
6+
char_cnn_widths: [3, 4, 5]
7+
max_char_len: 20
8+
dropout: 0.1
9+
10+
data:
11+
max_seq_len: 256
12+
val_ratio: 0.1
13+
test_ratio: 0.1
14+
seed: 42
15+
# Oversample examples containing Tier 1 entities (SSN, Credit Card, etc.)
16+
oversample_tiers: [1]
17+
oversample_factor: 3
18+
19+
training:
20+
epochs: 15
21+
batch_size: 8
22+
gradient_accumulation_steps: 4 # effective batch = 32
23+
lr_backbone: 1.0e-5 # halved from v1 (2e-5) to prevent late-epoch regression
24+
lr_head: 1.0e-3
25+
lr_scheduler_type: cosine # smoother decay than linear
26+
warmup_steps: 500 # ~1 epoch warmup at 4237 steps/epoch
27+
weight_decay: 0.01
28+
eval_strategy: epoch
29+
save_strategy: epoch
30+
metric_for_best_model: overall_f1
31+
logging_steps: 50
32+
dataloader_num_workers: 4
33+
save_total_limit: 3
34+
output_dir: output-v1.1
35+
run_name: pii-ner-v1.1-rtx3090
36+
37+
# Tier-weighted CRF loss: sequences with critical PII get higher loss weight
38+
tier_weights:
39+
1: 3.0 # SSN, Credit Card, Bank Account, Passport, DL, Tax ID
40+
2: 2.0 # Person, Email, Phone, DOB, Address, IP
41+
3: 1.5 # Username, Date, Location, Org, URL, etc.
42+
4: 1.0 # Domain-specific types
43+
44+
wandb:
45+
enabled: true
46+
project: datafog-pii-ner

pii-ner-v1/notebooks/full_training.ipynb

Lines changed: 6 additions & 73 deletions
Large diffs are not rendered by default.

pii-ner-v1/scripts/entity_audit.py

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
#!/usr/bin/env python3
2+
"""Audit entity type frequencies across all training datasets.
3+
4+
Counts entity occurrences per type and per dataset to identify
5+
class imbalance issues.
6+
"""
7+
8+
import sys
9+
from collections import Counter
10+
from pathlib import Path
11+
12+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
13+
14+
from datasets import load_dataset
15+
from datafog_pii_ner.data.label_schema import (
16+
ALL_ENTITY_TYPES,
17+
TIER_1,
18+
TIER_2,
19+
TIER_3,
20+
TIER_4,
21+
TIER_MAP,
22+
map_label,
23+
)
24+
from datafog_pii_ner.data.dataset import DATASET_CONFIGS, _spans_to_bio, _detect_columns, _normalize_tags
25+
26+
import re
27+
_WORD_RE = re.compile(r"\S+")
28+
29+
30+
def count_entities_token_format(raw, dataset_name):
31+
"""Count entities in token-format dataset (AI4Privacy)."""
32+
token_col, tag_col = _detect_columns(raw)
33+
34+
tag_names = None
35+
try:
36+
tag_names = raw.features[tag_col].feature.names
37+
except Exception:
38+
pass
39+
40+
entity_counts = Counter()
41+
examples_with_entity = Counter()
42+
43+
for i, example in enumerate(raw):
44+
tags = _normalize_tags(example[tag_col], dataset_name, tag_names)
45+
types_in_example = set()
46+
for tag in tags:
47+
if tag.startswith("B-"):
48+
entity_type = tag[2:]
49+
entity_counts[entity_type] += 1
50+
types_in_example.add(entity_type)
51+
for t in types_in_example:
52+
examples_with_entity[t] += 1
53+
54+
if (i + 1) % 50000 == 0:
55+
print(f" Processed {i+1} examples...")
56+
57+
return entity_counts, examples_with_entity, len(raw)
58+
59+
60+
def count_entities_span_format(raw, dataset_name, text_col, spans_col):
61+
"""Count entities in span-format dataset (Nemotron, Gretel)."""
62+
entity_counts = Counter()
63+
examples_with_entity = Counter()
64+
65+
for i, example in enumerate(raw):
66+
_, bio_tags = _spans_to_bio(example[text_col], example[spans_col], dataset_name)
67+
types_in_example = set()
68+
for tag in bio_tags:
69+
if tag.startswith("B-"):
70+
entity_type = tag[2:]
71+
entity_counts[entity_type] += 1
72+
types_in_example.add(entity_type)
73+
for t in types_in_example:
74+
examples_with_entity[t] += 1
75+
76+
if (i + 1) % 50000 == 0:
77+
print(f" Processed {i+1} examples...")
78+
79+
return entity_counts, examples_with_entity, len(raw)
80+
81+
82+
def main():
83+
total_counts = Counter()
84+
total_examples = Counter()
85+
dataset_stats = {}
86+
87+
for name, config in DATASET_CONFIGS.items():
88+
print(f"\n{'='*60}")
89+
print(f"Loading {name} from {config['path']}...")
90+
print(f"{'='*60}")
91+
92+
raw = load_dataset(config["path"], split=config["split"])
93+
94+
# Filter to English
95+
lang_col = config.get("language_col", "language")
96+
lang_val = config.get("language_value", "en")
97+
if lang_col in raw.column_names:
98+
raw = raw.filter(lambda x: x[lang_col] == lang_val, desc="Filtering English")
99+
print(f" Filtered to {len(raw)} English examples")
100+
101+
if config.get("format") == "span":
102+
counts, ex_counts, n = count_entities_span_format(
103+
raw, name, config["text_col"], config["spans_col"]
104+
)
105+
else:
106+
counts, ex_counts, n = count_entities_token_format(raw, name)
107+
108+
dataset_stats[name] = {
109+
"total_examples": n,
110+
"entity_counts": counts,
111+
"examples_with_entity": ex_counts,
112+
}
113+
114+
total_counts += counts
115+
total_examples += ex_counts
116+
117+
print(f"\n{name}: {n} examples")
118+
for etype in sorted(counts.keys(), key=lambda x: counts[x], reverse=True):
119+
tier = TIER_MAP.get(etype, "?")
120+
print(f" T{tier} {etype:30s} {counts[etype]:>8,} entities in {ex_counts[etype]:>8,} examples")
121+
122+
# Summary
123+
print(f"\n{'='*60}")
124+
print("COMBINED ENTITY FREQUENCY AUDIT")
125+
print(f"{'='*60}")
126+
127+
total_entities = sum(total_counts.values())
128+
print(f"\nTotal entities: {total_entities:,}")
129+
130+
for tier_name, tier_list in [("TIER 1 (target >=0.98)", TIER_1),
131+
("TIER 2 (target >=0.95)", TIER_2),
132+
("TIER 3 (target >=0.90)", TIER_3),
133+
("TIER 4 (target >=0.85)", TIER_4)]:
134+
print(f"\n--- {tier_name} ---")
135+
for etype in tier_list:
136+
count = total_counts.get(etype, 0)
137+
ex_count = total_examples.get(etype, 0)
138+
pct = 100.0 * count / total_entities if total_entities > 0 else 0
139+
print(f" {etype:30s} {count:>8,} ({pct:5.2f}%) in {ex_count:>8,} examples")
140+
141+
# Imbalance ratio
142+
most_common = total_counts.most_common(1)[0] if total_counts else ("?", 0)
143+
least_common = total_counts.most_common()[-1] if total_counts else ("?", 0)
144+
if least_common[1] > 0:
145+
ratio = most_common[1] / least_common[1]
146+
print(f"\nImbalance ratio: {most_common[0]}({most_common[1]:,}) / "
147+
f"{least_common[0]}({least_common[1]:,}) = {ratio:.0f}x")
148+
149+
# Types with zero occurrences
150+
zero_types = [t for t in ALL_ENTITY_TYPES if total_counts.get(t, 0) == 0]
151+
if zero_types:
152+
print(f"\nZERO OCCURRENCE TYPES: {zero_types}")
153+
154+
155+
if __name__ == "__main__":
156+
main()

pii-ner-v1/scripts/run_training.py

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737

3838
from datafog_pii_ner.data.collator import PiiDataCollator
3939
from datafog_pii_ner.data.dataset import load_pii_datasets
40-
from datafog_pii_ner.data.label_schema import NUM_LABELS
40+
from datafog_pii_ner.data.label_schema import NUM_LABELS, build_label_weights
4141
from datafog_pii_ner.model.pii_model import PiiNerConfig, PiiNerModel
4242
from datafog_pii_ner.training.metrics import compute_metrics
4343
from datafog_pii_ner.training.train import PiiTrainer
@@ -241,13 +241,16 @@ def train(config: dict, precision: dict, logger: logging.Logger, status: StatusT
241241
# --- Data ---
242242
logger.info("Loading datasets (may take a few minutes on first run)...")
243243
status.update(stage="loading_data")
244+
data_cfg = config["data"]
244245
datasets = load_pii_datasets(
245246
tokenizer=tokenizer,
246-
max_seq_len=config["data"]["max_seq_len"],
247+
max_seq_len=data_cfg["max_seq_len"],
247248
max_char_len=config["model"]["max_char_len"],
248-
val_ratio=config["data"]["val_ratio"],
249-
test_ratio=config["data"]["test_ratio"],
250-
seed=config["data"].get("seed", 42),
249+
val_ratio=data_cfg["val_ratio"],
250+
test_ratio=data_cfg["test_ratio"],
251+
seed=data_cfg.get("seed", 42),
252+
oversample_tiers=data_cfg.get("oversample_tiers"),
253+
oversample_factor=data_cfg.get("oversample_factor", 2),
251254
)
252255
logger.info(f"Train: {len(datasets['train']):,} Val: {len(datasets['validation']):,} "
253256
f"Test: {len(datasets['test']):,}")
@@ -271,6 +274,17 @@ def train(config: dict, precision: dict, logger: logging.Logger, status: StatusT
271274
trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
272275
logger.info(f"Parameters: {param_count:,} total, {trainable:,} trainable")
273276

277+
# --- Tier-weighted loss ---
278+
tier_weights_cfg = config.get("training", {}).get("tier_weights")
279+
if tier_weights_cfg:
280+
# Convert string keys from YAML to ints
281+
tw = {int(k): float(v) for k, v in tier_weights_cfg.items()}
282+
weights = build_label_weights(tw)
283+
model.crf_head.set_label_weights(torch.tensor(weights, dtype=torch.float32))
284+
logger.info(f"Tier-weighted CRF loss: {tw}")
285+
else:
286+
logger.info("CRF loss: uniform (no tier weighting)")
287+
274288
# --- Collator ---
275289
collator = PiiDataCollator(tokenizer=tokenizer, max_char_len=config["model"]["max_char_len"])
276290

@@ -285,14 +299,22 @@ def train(config: dict, precision: dict, logger: logging.Logger, status: StatusT
285299
wandb_cfg = config.get("wandb", {})
286300
report_to = "wandb" if wandb_cfg.get("enabled", False) else "none"
287301

302+
# Warmup: prefer warmup_steps over warmup_ratio (ratio is deprecated in HF 5.2+)
303+
warmup_kwargs = {}
304+
if "warmup_steps" in tcfg:
305+
warmup_kwargs["warmup_steps"] = tcfg["warmup_steps"]
306+
elif "warmup_ratio" in tcfg:
307+
warmup_kwargs["warmup_ratio"] = tcfg["warmup_ratio"]
308+
288309
training_args = TrainingArguments(
289310
output_dir=output_dir,
290311
num_train_epochs=tcfg["epochs"],
291312
per_device_train_batch_size=tcfg["batch_size"],
292313
per_device_eval_batch_size=tcfg["batch_size"],
293314
gradient_accumulation_steps=tcfg.get("gradient_accumulation_steps", 1),
294315
learning_rate=tcfg["lr_backbone"],
295-
warmup_ratio=tcfg.get("warmup_ratio", 0.1),
316+
lr_scheduler_type=tcfg.get("lr_scheduler_type", "linear"),
317+
**warmup_kwargs,
296318
weight_decay=tcfg.get("weight_decay", 0.01),
297319
fp16=precision["fp16"],
298320
bf16=precision["bf16"],

pii-ner-v1/src/datafog_pii_ner/data/dataset.py

Lines changed: 75 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from transformers import PreTrainedTokenizerFast
1818

1919
from .char_vocab import text_to_char_ids
20-
from .label_schema import LABEL_TO_ID, map_label
20+
from .label_schema import LABEL_TO_ID, TIER_1, TIER_2, map_label
2121

2222
logger = logging.getLogger(__name__)
2323

@@ -333,6 +333,62 @@ def preprocess_token(example):
333333
return processed
334334

335335

336+
def _build_oversample_label_ids(tiers: list[int]) -> set[int]:
337+
"""Build set of label IDs that belong to the specified tiers."""
338+
tier_types = []
339+
if 1 in tiers:
340+
tier_types.extend(TIER_1)
341+
if 2 in tiers:
342+
tier_types.extend(TIER_2)
343+
344+
label_ids = set()
345+
for etype in tier_types:
346+
b_key = f"B-{etype}"
347+
i_key = f"I-{etype}"
348+
if b_key in LABEL_TO_ID:
349+
label_ids.add(LABEL_TO_ID[b_key])
350+
if i_key in LABEL_TO_ID:
351+
label_ids.add(LABEL_TO_ID[i_key])
352+
return label_ids
353+
354+
355+
def _oversample_rare_entities(
356+
dataset: Dataset,
357+
oversample_tiers: list[int],
358+
oversample_factor: int,
359+
) -> Dataset:
360+
"""Oversample examples containing rare entity types.
361+
362+
Identifies examples that contain entities from the specified tiers,
363+
then duplicates them `oversample_factor` times.
364+
"""
365+
target_label_ids = _build_oversample_label_ids(oversample_tiers)
366+
if not target_label_ids:
367+
return dataset
368+
369+
# Find indices of examples containing target entities
370+
rare_indices = []
371+
for i in range(len(dataset)):
372+
labels = dataset[i]["labels"]
373+
if any(label_id in target_label_ids for label_id in labels):
374+
rare_indices.append(i)
375+
376+
if not rare_indices:
377+
logger.warning("No examples found containing target tier entities for oversampling")
378+
return dataset
379+
380+
logger.info(
381+
f"Oversampling: {len(rare_indices)} examples with tier {oversample_tiers} entities "
382+
f"x{oversample_factor} ({len(rare_indices) * oversample_factor} additional examples)"
383+
)
384+
385+
# Create duplicated dataset
386+
oversampled_indices = rare_indices * oversample_factor
387+
oversampled = dataset.select(oversampled_indices)
388+
389+
return concatenate_datasets([dataset, oversampled])
390+
391+
336392
def load_pii_datasets(
337393
tokenizer: PreTrainedTokenizerFast,
338394
max_seq_len: int = 256,
@@ -341,8 +397,16 @@ def load_pii_datasets(
341397
val_ratio: float = 0.1,
342398
test_ratio: float = 0.1,
343399
seed: int = 42,
400+
oversample_tiers: list[int] | None = None,
401+
oversample_factor: int = 2,
344402
) -> DatasetDict:
345-
"""Load all PII datasets, combine, and split into train/val/test."""
403+
"""Load all PII datasets, combine, and split into train/val/test.
404+
405+
Args:
406+
oversample_tiers: List of tier numbers to oversample (e.g., [1, 2]).
407+
None disables oversampling.
408+
oversample_factor: Number of times to duplicate rare-entity examples.
409+
"""
346410
all_datasets = []
347411
for name in DATASET_CONFIGS:
348412
try:
@@ -361,16 +425,23 @@ def load_pii_datasets(
361425
combined = concatenate_datasets(all_datasets)
362426
logger.info(f"Combined: {len(combined)} examples")
363427

364-
# Split: train / val / test
428+
# Split: train / val / test (BEFORE oversampling to avoid data leakage)
365429
test_size = test_ratio
366430
val_size = val_ratio / (1 - test_ratio) # Adjust for two-stage split
367431

368432
split1 = combined.train_test_split(test_size=test_size, seed=seed)
369433
split2 = split1["train"].train_test_split(test_size=val_size, seed=seed)
370434

435+
train_ds = split2["train"]
436+
437+
# Oversample rare entities in training set only
438+
if oversample_tiers:
439+
train_ds = _oversample_rare_entities(train_ds, oversample_tiers, oversample_factor)
440+
logger.info(f"Train after oversampling: {len(train_ds)} examples")
441+
371442
return DatasetDict(
372443
{
373-
"train": split2["train"],
444+
"train": train_ds,
374445
"validation": split2["test"],
375446
"test": split1["test"],
376447
}

0 commit comments

Comments
 (0)