Somasundaram Ayyappan Claude Opus 4.6 (1M context) commited on
Commit
ae7305b
·
1 Parent(s): 4553d68

Add Kaggle silver training data, retrain model, reorganize data directory

Browse files

- Add 2,483 Kaggle resumes with Gemini-extracted labels + BIO tagging + noise augmentation
- Retrain model (25 epochs): entity F1 97.77%, clean F1 99.18%
- Reorganize training/data/ into sources/, gold/ subdirectories
- Add implementation guide (docs/implementation_guide.md)
- Remove one-off scripts (build_long_resumes, convert_dataturks, label_resume_pdfs)
- Fix Windows UTF-8 encoding in structured_postprocess and train_ner
- Add --extra-train flag to train_ner.py for loading additional training files
- Export updated ONNX + quantized model

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

README.md CHANGED
@@ -35,12 +35,12 @@ Fine-tuned DistilBERT model for extracting structured information from resume te
35
 
36
  ## Latest NER benchmark
37
 
38
- Latest retrain on RTX 3080 with noise-augmented data (3x augmentation, 25 epochs):
39
 
40
- - entity F1: **97.62%**
41
- - structured micro F1: **98.16%**
42
- - long resume micro F1: **91.7%** (resumes >512 tokens)
43
- - noisy resume F1: **72.67%** (OCR/scraped text)
44
 
45
  These numbers come from entity-level exact-match evaluation with `seqeval` and the structured extraction benchmark.
46
 
@@ -127,6 +127,8 @@ Section detection (`training/section_detector.py`) fills entities the NER model
127
 
128
  All post-processing rules are config-driven for cross-language portability.
129
 
 
 
130
  ## Training data
131
 
132
  Current checked-in dataset snapshot in `training/data/` was built from mixed sources:
@@ -136,14 +138,25 @@ Current checked-in dataset snapshot in `training/data/` was built from mixed sou
136
  - 12 manual resume templates across tech and non-tech domains
137
  - 50 hand-crafted long resumes (>512 tokens) for chunked inference training
138
  - 93 gold-labeled resume-resource PDFs (hand-annotated, 100% match rate)
 
139
  - 2x noise augmentation for OCR robustness (separator swaps, char corruption, case changes)
140
 
 
 
 
 
 
 
 
 
 
 
 
 
141
  Current public rebuild path uses:
142
 
143
  - `training/generate_from_structured.py` — orchestrates all sources + augmentation
144
- - `training/convert_dataturks.py` — DataTurks converter
145
  - `training/manual_resumes.py` — short manual templates
146
- - `training/build_long_resumes.py` — 50 long dense resumes
147
  - `training/build_gold_labels.py` — gold-labeled PDF resumes
148
  - `training/noise_augment.py` — noise augmentation for OCR robustness
149
 
@@ -191,11 +204,10 @@ python -m training.benchmark_structured --model-dir .
191
 
192
  Latest internal structured benchmark on current validation set:
193
 
194
- - overall micro F1: **98.16%**
195
- - macro field F1: **98.65%**
196
- - clean-resume micro F1: **99.31%**
197
- - noisy-resume micro F1: **72.67%**
198
- - long resume (>512 tokens) micro F1: **91.69%**
199
 
200
  This benchmark uses in-repo structured post-processing for both gold spans and model predictions. Section-aware chunked inference handles resumes exceeding the 512-token context window. These numbers are intended for internal regression tracking, not external leaderboard claims.
201
 
@@ -240,9 +252,9 @@ Full details: [`docs/export.md`](docs/export.md)
240
 
241
  Current ONNX export was validated against PyTorch on same sample input:
242
 
243
- - PyTorch vs ONNX logits: `allclose=True`
244
  - PyTorch vs ONNX predictions: `argmax_equal=True`
245
- - PyTorch vs quantized ONNX predictions: minor diff on punctuation tokens (expected for INT8)
246
 
247
  This is main safety check when training and export happen in separate environments.
248
 
@@ -250,10 +262,8 @@ This is main safety check when training and export happen in separate environmen
250
 
251
  - `training/train_ner.py` — training loop
252
  - `training/generate_from_structured.py` — dataset builder orchestrating all sources + augmentation
253
- - `training/convert_dataturks.py` — DataTurks converter
254
- - `training/manual_resumes.py` — short manual templates
255
- - `training/build_long_resumes.py` — 50 long dense resume templates (>512 tokens)
256
  - `training/build_gold_labels.py` — gold-labeled PDF resume builder
 
257
  - `training/noise_augment.py` — noise augmentation for OCR robustness
258
  - `training/labels.py` — shared label definitions
259
  - `training/dataset_utils.py` — dedupe / split / manifest helpers
@@ -262,13 +272,15 @@ This is main safety check when training and export happen in separate environmen
262
  - `training/structured_postprocess.py` — config-driven post-processing and grouping
263
  - `training/section_detector.py` — section-aware entity extraction
264
  - `training/benchmark_structured.py` — structured benchmark with chunked inference
 
265
  - `training/synthetic_assets.py` — synthetic source assets/helpers
266
  - `training/synthetic_formats.py` — 8 resume format builders (A-H)
267
- - `training/label_resume_pdfs.py` — PDF extraction and auto-labeling
268
  - `training/export_onnx.py` — CLI wrapper for ONNX export
269
  - `training/validate_onnx.py` — PyTorch vs ONNX parity check
270
  - `training/quantize_onnx.py` — ONNX quantization helper
271
  - `training/requirements-export.txt` — separate ONNX export env
 
 
272
  - `docs/export.md` — ONNX export and validation notes
273
 
274
  ## Limitations
 
35
 
36
  ## Latest NER benchmark
37
 
38
+ Latest retrain on RTX 3080 with noise-augmented data + Kaggle silver labels (25 epochs):
39
 
40
+ - entity F1: **97.77%**
41
+ - structured micro F1: **97.88%**
42
+ - clean resume F1: **99.18%**
43
+ - noisy resume F1: **69.24%** (OCR/scraped text)
44
 
45
  These numbers come from entity-level exact-match evaluation with `seqeval` and the structured extraction benchmark.
46
 
 
127
 
128
  All post-processing rules are config-driven for cross-language portability.
129
 
130
+ For a detailed implementation walkthrough of the full pipeline, see [`docs/implementation_guide.md`](docs/implementation_guide.md).
131
+
132
  ## Training data
133
 
134
  Current checked-in dataset snapshot in `training/data/` was built from mixed sources:
 
138
  - 12 manual resume templates across tech and non-tech domains
139
  - 50 hand-crafted long resumes (>512 tokens) for chunked inference training
140
  - 93 gold-labeled resume-resource PDFs (hand-annotated, 100% match rate)
141
+ - 2,483 Kaggle resumes with Gemini-extracted silver labels + BIO tagging
142
  - 2x noise augmentation for OCR robustness (separator swaps, char corruption, case changes)
143
 
144
+ Data layout:
145
+
146
+ ```
147
+ training/data/
148
+ ├── ner_train.json # Main training set
149
+ ├── ner_val.json # Validation split
150
+ ├── kaggle_train.json # Kaggle BIO training (silver + noise-augmented)
151
+ ├── gold/ # Hand-annotated evaluation data
152
+ ├── sources/ # Raw source data (CSV, JSONL, etc.)
153
+ └── resume_resource/ # Source PDFs
154
+ ```
155
+
156
  Current public rebuild path uses:
157
 
158
  - `training/generate_from_structured.py` — orchestrates all sources + augmentation
 
159
  - `training/manual_resumes.py` — short manual templates
 
160
  - `training/build_gold_labels.py` — gold-labeled PDF resumes
161
  - `training/noise_augment.py` — noise augmentation for OCR robustness
162
 
 
204
 
205
  Latest internal structured benchmark on current validation set:
206
 
207
+ - overall micro F1: **97.88%**
208
+ - macro field F1: **98.47%**
209
+ - clean-resume micro F1: **99.18%**
210
+ - noisy-resume micro F1: **69.24%**
 
211
 
212
  This benchmark uses in-repo structured post-processing for both gold spans and model predictions. Section-aware chunked inference handles resumes exceeding the 512-token context window. These numbers are intended for internal regression tracking, not external leaderboard claims.
213
 
 
252
 
253
  Current ONNX export was validated against PyTorch on same sample input:
254
 
255
+ - PyTorch vs ONNX logits: `allclose=True` (max diff: 0.000013)
256
  - PyTorch vs ONNX predictions: `argmax_equal=True`
257
+ - PyTorch vs quantized ONNX predictions: minor diff (expected for INT8)
258
 
259
  This is main safety check when training and export happen in separate environments.
260
 
 
262
 
263
  - `training/train_ner.py` — training loop
264
  - `training/generate_from_structured.py` — dataset builder orchestrating all sources + augmentation
 
 
 
265
  - `training/build_gold_labels.py` — gold-labeled PDF resume builder
266
+ - `training/manual_resumes.py` — short manual templates
267
  - `training/noise_augment.py` — noise augmentation for OCR robustness
268
  - `training/labels.py` — shared label definitions
269
  - `training/dataset_utils.py` — dedupe / split / manifest helpers
 
272
  - `training/structured_postprocess.py` — config-driven post-processing and grouping
273
  - `training/section_detector.py` — section-aware entity extraction
274
  - `training/benchmark_structured.py` — structured benchmark with chunked inference
275
+ - `training/analyze_structured_errors.py` — per-resume error analysis
276
  - `training/synthetic_assets.py` — synthetic source assets/helpers
277
  - `training/synthetic_formats.py` — 8 resume format builders (A-H)
 
278
  - `training/export_onnx.py` — CLI wrapper for ONNX export
279
  - `training/validate_onnx.py` — PyTorch vs ONNX parity check
280
  - `training/quantize_onnx.py` — ONNX quantization helper
281
  - `training/requirements-export.txt` — separate ONNX export env
282
+ - `run_inference.py` — single-resume inference script
283
+ - `docs/implementation_guide.md` — detailed pre/post processing implementation guide
284
  - `docs/export.md` — ONNX export and validation notes
285
 
286
  ## Limitations
docs/implementation_guide.md ADDED
@@ -0,0 +1,347 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Resume NER: Pre and Post Processing Implementation Guide
2
+
3
+ This document explains the full inference pipeline from raw resume text to structured output, covering all pre-processing, model inference, and post-processing steps driven by `resume_config.json`.
4
+
5
+ ## Pipeline Overview
6
+
7
+ ```
8
+ Raw PDF/Text
9
+ |
10
+ v
11
+ [1. Pre-processing] ← resume_config.json → pre_processing
12
+ |
13
+ v
14
+ [2. Tokenization] ← distilbert-base-cased tokenizer
15
+ |
16
+ v
17
+ [3. NER Inference] ← DistilBERT token classification (27 labels)
18
+ |
19
+ v
20
+ [4. Span Assembly] ← BIO → character-offset spans
21
+ |
22
+ v
23
+ [5. Section Detection] ← Rule-based gap-filling for SKILLS, CERTS, LANGUAGES
24
+ |
25
+ v
26
+ [6. Post-processing] ← resume_config.json → post_processing
27
+ |
28
+ v
29
+ Structured JSON output
30
+ ```
31
+
32
+ ---
33
+
34
+ ## 1. Pre-processing (`text_preprocess.py`)
35
+
36
+ Config section: `resume_config.json → pre_processing`
37
+
38
+ Normalizes raw PDF extraction artifacts before the model sees the text. All rules are config-driven.
39
+
40
+ ### Steps (in order):
41
+
42
+ 1. **CRLF normalization** - Convert `\r\n` and `\r` to `\n`
43
+
44
+ 2. **Dash normalization** (`normalize_dashes: true`)
45
+ - Replace em-dash `—` and en-dash `–` with hyphen `-`
46
+ - Configured via `dash_replacements` map
47
+
48
+ 3. **Bullet normalization** (`normalize_bullets: true`)
49
+ - Replace unicode bullets (`●`, `•`, `▪`, `■`, `▸`, `►`, `‣`, `⁃`) with `"- "`
50
+ - Characters listed in `bullet_chars`, replacement in `bullet_replacement`
51
+
52
+ 4. **Multi-space collapse** (`collapse_multi_spaces: true`)
53
+ - Reduce runs of 2+ spaces to single space
54
+
55
+ 5. **Label stripping** (`strip_labels: ["Phone:", "Email:"]`)
56
+ - Remove literal prefixes like "Phone:" or "Email:" that add noise
57
+
58
+ 6. **Skill table expansion** (`expand_skill_tables: true`)
59
+ - Detects two-column "Category: skill1, skill2" tables common in resumes
60
+ - Expands them into flat lists for better NER tagging
61
+ - Recognizes categories from `skill_table_categories` list
62
+ - Limits: `table_prose_max_words: 15`, `table_continuation_max_chars: 60`
63
+
64
+ ### Usage:
65
+
66
+ ```python
67
+ from training.text_preprocess import preprocess_resume_text
68
+
69
+ # Uses resume_config.json from current directory
70
+ clean_text = preprocess_resume_text(raw_text)
71
+
72
+ # Or with explicit config path:
73
+ from training.text_preprocess import ResumeTextPreprocessor
74
+ pp = ResumeTextPreprocessor("/path/to/model_dir")
75
+ clean_text = pp.preprocess(raw_text)
76
+ ```
77
+
78
+ ---
79
+
80
+ ## 2. Tokenization & Chunking
81
+
82
+ Model max sequence length: **512 tokens** (DistilBERT).
83
+
84
+ For resumes exceeding 512 tokens, section-aware chunking is used (`benchmark_structured.py → chunked_predicted_spans`):
85
+
86
+ 1. Split text at `\n\n` (paragraph) boundaries
87
+ 2. Greedily group consecutive sections into chunks that fit within 512 tokens
88
+ 3. Run inference on each chunk independently
89
+ 4. Map character offsets back to original text
90
+
91
+ This preserves entity context within natural resume sections (Experience, Education, Skills).
92
+
93
+ ---
94
+
95
+ ## 3. NER Inference
96
+
97
+ Model: `distilbert-base-cased` fine-tuned for token classification.
98
+
99
+ **27 BIO labels:**
100
+
101
+ | Entity | B-tag | I-tag | Description |
102
+ |--------|-------|-------|-------------|
103
+ | NAME | 1 | 2 | Person's full name |
104
+ | EMAIL | 3 | 4 | Email address |
105
+ | PHONE | 5 | 6 | Phone number |
106
+ | LOCATION | 7 | 8 | City, state, country |
107
+ | COMPANY | 9 | 10 | Employer name |
108
+ | TITLE | 11 | 12 | Job title |
109
+ | DATE | 13 | 14 | Employment/education dates |
110
+ | DEGREE | 15 | 16 | Academic degree |
111
+ | INSTITUTION | 17 | 18 | School/university |
112
+ | FIELD | 19 | 20 | Field of study |
113
+ | SKILL | 21 | 22 | Technical/professional skill |
114
+ | CERT | 23 | 24 | Certification |
115
+ | LANGUAGE | 25 | 26 | Spoken language |
116
+
117
+ Tag `0` = O (outside any entity).
118
+
119
+ ### Subword alignment:
120
+
121
+ The tokenizer splits words into subword tokens. During training:
122
+ - First subword of a word: gets the word's BIO label
123
+ - Continuation subwords: B-X converts to I-X, other labels propagate
124
+ - Special tokens ([CLS], [SEP], [PAD]): label = -100 (ignored in loss)
125
+
126
+ ---
127
+
128
+ ## 4. Span Assembly
129
+
130
+ Convert BIO predictions back to character-offset spans:
131
+
132
+ ```python
133
+ @dataclass
134
+ class Span:
135
+ label: str # Entity type (NAME, COMPANY, etc.)
136
+ text: str # Extracted text
137
+ start: int # Character offset start
138
+ end: int # Character offset end
139
+ score: float # Confidence (1.0 for argmax)
140
+ ```
141
+
142
+ Rules:
143
+ - B-X starts a new span
144
+ - I-X continues the current span (including whitespace gaps between subwords)
145
+ - O or different entity type closes the current span
146
+
147
+ ---
148
+
149
+ ## 5. Section Detection (`section_detector.py`)
150
+
151
+ Rule-based gap-filling that runs AFTER NER. Catches entities the model missed using section context:
152
+
153
+ - Detects section headers (SKILLS, CERTIFICATIONS, LANGUAGES, EDUCATION) by keyword matching
154
+ - Within detected sections, extracts untagged text as entities
155
+ - Especially useful for skills lists that the model partially tags
156
+
157
+ ---
158
+
159
+ ## 6. Post-processing (`structured_postprocess.py`)
160
+
161
+ Config section: `resume_config.json → post_processing`
162
+
163
+ Transforms raw spans into clean structured JSON.
164
+
165
+ ### 6.1 Span Merging
166
+
167
+ ```json
168
+ "span_merge_max_gap": 3,
169
+ "span_merge_labels": ["TITLE", "COMPANY"]
170
+ ```
171
+
172
+ Adjacent spans of same type (TITLE or COMPANY) separated by <= 3 characters are merged. Handles cases where the model splits "Senior Software Engineer" into multiple spans.
173
+
174
+ ### 6.2 Entity Validation Rules
175
+
176
+ Each entity type has validation rules in `entity_rules`:
177
+
178
+ **COMPANY:**
179
+ - `min_length: 4` — reject spans shorter than 4 chars
180
+ - `gazetteer_bypass: true` — known companies from `companies.json` skip length check
181
+ - `strip_trailing_state_code: true` — remove trailing US state codes ("Acme Inc. CA" → "Acme Inc.")
182
+
183
+ **TITLE:**
184
+ - `min_length: 2`
185
+ - `exceptions: ["VP", "PA", "RN", "MD", "DO", "QA"]` — short titles that are valid
186
+
187
+ **SKILL:**
188
+ - `min_length: 4`
189
+ - `uppercase_bypass: true` — short all-caps skills (AWS, GCP) pass
190
+ - `exceptions: ["Go", "R", "C", "C#", "F#", "D"]` — valid short skills
191
+ - `blocked_words` — language proficiency descriptors ("native", "fluent", "bilingual") filtered out
192
+ - `aliases` — normalize variants ("nodejs" → "node.js", "cpp" → "c++")
193
+
194
+ **EMAIL:**
195
+ - `require: "@"` — must contain @
196
+ - `reject_patterns: ["//", "www."]` — filter URLs misclassified as emails
197
+ - `strip_prefixes: ["Esq.", "Dr.", ...]` — remove honorifics attached by OCR
198
+
199
+ **DATE:**
200
+ - `min_length: 3`
201
+ - `date_words` list validates month names
202
+ - `present_words: ["present", "current"]` — recognized as end-date markers
203
+
204
+ ### 6.3 Text Cleanup
205
+
206
+ ```json
207
+ "space_collapse_pairs": [
208
+ [" . ", "."],
209
+ [" + + ", "++"],
210
+ [" # ", "#"],
211
+ [" ,", ","]
212
+ ]
213
+ ```
214
+
215
+ Fixes tokenizer-induced spacing artifacts in extracted text (e.g., "C + +" → "C++").
216
+
217
+ ### 6.4 Seniority Inference
218
+
219
+ Determines career level from title keywords and experience duration:
220
+
221
+ ```json
222
+ "seniority_keywords": {
223
+ "Executive": ["cto", "ceo", ...],
224
+ "Senior": ["senior", "sr.", "lead", "director", ...],
225
+ "Junior": ["junior", "intern", "trainee", ...]
226
+ }
227
+ ```
228
+
229
+ Fallback by years of experience:
230
+ ```json
231
+ "seniority_by_years": { "Staff": 15, "Senior": 8, "Mid": 3, "Junior": 0 }
232
+ ```
233
+
234
+ ### 6.5 Country Detection
235
+
236
+ 1. Phone prefix matching (`phone_country_prefixes`)
237
+ 2. Location span matching against `city_country_map.json` (317 cities)
238
+ 3. US state code detection (`us_states` list)
239
+ 4. Country name aliases ("usa" → "United States")
240
+
241
+ ### 6.6 Experience Years Calculation
242
+
243
+ - Parse start/end dates from DATE spans
244
+ - `max_experience_months: 600` — cap at 50 years
245
+ - `present_words` treated as current date
246
+
247
+ ---
248
+
249
+ ## Structured Output Format
250
+
251
+ ```json
252
+ {
253
+ "personal": {
254
+ "name": "string",
255
+ "email": "string",
256
+ "phone": "string",
257
+ "location": "string"
258
+ },
259
+ "experience": [
260
+ {
261
+ "title": "string",
262
+ "company": "string",
263
+ "start_date": "string",
264
+ "end_date": "string"
265
+ }
266
+ ],
267
+ "education": [
268
+ {
269
+ "degree": "string",
270
+ "field": "string",
271
+ "institution": "string"
272
+ }
273
+ ],
274
+ "skills": ["string"],
275
+ "certifications": ["string"],
276
+ "seniority": "Executive|Principal|Staff|Senior|Mid|Junior",
277
+ "country": "string",
278
+ "experience_years": number
279
+ }
280
+ ```
281
+
282
+ ---
283
+
284
+ ## Training Configuration
285
+
286
+ | Parameter | Value |
287
+ |-----------|-------|
288
+ | Base model | `distilbert-base-cased` |
289
+ | Max sequence length | 512 |
290
+ | Epochs | 25 |
291
+ | Batch size | 8 |
292
+ | Learning rate | 3e-5 |
293
+ | Weight decay | 0.01 |
294
+ | Warmup steps | 20 |
295
+ | Metric for best model | entity_f1 |
296
+ | Noise augmentation | 2x multiplier |
297
+
298
+ ### Training Data Sources
299
+
300
+ | File | Records | Description |
301
+ |------|---------|-------------|
302
+ | `ner_train.json` | ~3,647 | Synthetic + manual + DataTurks (with noise augmentation) |
303
+ | `kaggle_train.json` | ~7,449 | Kaggle resumes: 2,483 clean + 4,966 noise-augmented |
304
+
305
+ ### Evaluation
306
+
307
+ | File | Records | Description |
308
+ |------|---------|-------------|
309
+ | `ner_val.json` | 652 | Validation split |
310
+ | `gold/resume_resource_gold.json` | 93 | Hand-annotated gold standard |
311
+
312
+ ---
313
+
314
+ ## Quick Start: Running Inference
315
+
316
+ ```python
317
+ import torch
318
+ from transformers import AutoModelForTokenClassification, AutoTokenizer
319
+ from training.benchmark_structured import chunked_predicted_spans
320
+ from training.structured_postprocess import StructuredPostProcessor
321
+
322
+ # Load model
323
+ tokenizer = AutoTokenizer.from_pretrained("path/to/model")
324
+ model = AutoModelForTokenClassification.from_pretrained("path/to/model")
325
+ model.eval()
326
+ postprocessor = StructuredPostProcessor("path/to/model")
327
+
328
+ # Run pipeline
329
+ from training.text_preprocess import ResumeTextPreprocessor
330
+ pp = ResumeTextPreprocessor("path/to/model")
331
+ clean_text = pp.preprocess(raw_resume_text)
332
+
333
+ _, spans = chunked_predicted_spans(clean_text, model, tokenizer)
334
+ result = postprocessor.build_structured_resume_from_spans(spans, clean_text)
335
+ ```
336
+
337
+ ---
338
+
339
+ ## File Reference
340
+
341
+ | File | Role |
342
+ |------|------|
343
+ | `resume_config.json` | All pre/post processing rules |
344
+ | `label_config.json` | Label ↔ ID mappings |
345
+ | `city_country_map.json` | City → country lookup |
346
+ | `training/data/companies.json` | Company name gazetteer |
347
+ | `training/data/titles.json` | Job title gazetteer |
model.safetensors CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:47161c540cc9b6b9f5e6be734efc29e650539d1d4eb897f53063d86ab1bfec76
3
  size 260859036
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:dbd306d5b166d786a0aa3ce4fff045a07617b6e43181d5a014fa154bc8bcb5bb
3
  size 260859036
onnx/model.onnx CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:20d09f456686cd72701e2189e388fb3e90c1cec14d4c1dd6c93dddf9d7a98cfb
3
  size 260958312
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6b0e3d608be7ce113f9eb25396c87d05da211e1cd1a6a88762f6fba67b83e06c
3
  size 260958312
onnx/model_quantized.onnx CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:0839099137e08b8799a96501c65f17a3bb0dc6a1e167b7622056eb052418da14
3
  size 65622482
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:c2423a3b24dcaca62686156276c6f0a970737639c22531d6c3e02a54c700a427
3
  size 65622482
training/build_gold_labels.py CHANGED
@@ -47,7 +47,7 @@ def find_span_in_text(span_text: str, full_text: str) -> bool:
47
 
48
 
49
  def main():
50
- texts = json.load(open(Path(__file__).parent / "data" / "resume_resource_texts.json"))
51
  annotations = load_all_annotations()
52
 
53
  print(f"Resumes: {len(annotations)}")
@@ -119,7 +119,7 @@ def main():
119
  for label, count in label_counts.most_common():
120
  print(f" {label:15s}: {count}")
121
 
122
- out_path = Path(__file__).parent / "data" / "resume_resource_gold.json"
123
  with open(out_path, "w") as f:
124
  json.dump({"data": examples}, f)
125
  print(f"\nWrote {len(examples)} examples to {out_path}")
 
47
 
48
 
49
  def main():
50
+ texts = json.load(open(Path(__file__).parent / "data" / "sources" / "resume_resource_texts.json"))
51
  annotations = load_all_annotations()
52
 
53
  print(f"Resumes: {len(annotations)}")
 
119
  for label, count in label_counts.most_common():
120
  print(f" {label:15s}: {count}")
121
 
122
+ out_path = Path(__file__).parent / "data" / "gold" / "resume_resource_gold.json"
123
  with open(out_path, "w") as f:
124
  json.dump({"data": examples}, f)
125
  print(f"\nWrote {len(examples)} examples to {out_path}")
training/build_long_resumes.py DELETED
The diff for this file is too large to render. See raw diff
 
training/convert_dataturks.py DELETED
@@ -1,147 +0,0 @@
1
- """Convert DataTurks resume NER dataset to BIO format and merge with existing data."""
2
-
3
- from __future__ import annotations
4
-
5
- import json
6
- from collections import Counter
7
- from pathlib import Path
8
-
9
- try:
10
- from training.dataset_utils import dedupe_examples, stable_split_examples, write_dataset
11
- from training.labels import ID2LABEL, LABEL2ID
12
- except ModuleNotFoundError:
13
- from dataset_utils import dedupe_examples, stable_split_examples, write_dataset
14
- from labels import ID2LABEL, LABEL2ID
15
-
16
- DATA_DIR = Path(__file__).parent / "data"
17
-
18
- LABEL_MAP = {
19
- "Name": "NAME",
20
- "Email Address": "EMAIL",
21
- "Location": "LOCATION",
22
- "Companies worked at": "COMPANY",
23
- "Designation": "TITLE",
24
- "Skills": "SKILL",
25
- "Degree": "DEGREE",
26
- "College Name": "INSTITUTION",
27
- "Graduation Year": "DATE",
28
- "Years of Experience": "DATE",
29
- }
30
-
31
-
32
- def convert_dataturks_sample(item: dict, source_id: str | None = None) -> dict | None:
33
- content = item.get("content", "")
34
- annotations = item.get("annotation") or []
35
- if not content or not annotations:
36
- return None
37
-
38
- tokens = content.split()[:512]
39
- if len(tokens) < 10:
40
- return None
41
-
42
- char_to_word = {}
43
- pos = 0
44
- for idx, word in enumerate(tokens):
45
- start = content.find(word, pos)
46
- if start == -1:
47
- start = pos
48
- for char_idx in range(start, start + len(word)):
49
- char_to_word[char_idx] = idx
50
- pos = start + len(word)
51
-
52
- labels = ["O"] * len(tokens)
53
- for annotation in annotations:
54
- ann_labels = annotation.get("label", [])
55
- points = annotation.get("points", [])
56
- if not ann_labels or not points:
57
- continue
58
-
59
- our_label = LABEL_MAP.get(ann_labels[0])
60
- if not our_label:
61
- continue
62
-
63
- for point in points:
64
- tagged_words = sorted({
65
- char_to_word[char_idx]
66
- for char_idx in range(point.get("start", 0), point.get("end", 0) + 1)
67
- if char_idx in char_to_word
68
- })
69
- for offset, word_idx in enumerate(tagged_words):
70
- if word_idx < len(labels) and labels[word_idx] == "O":
71
- labels[word_idx] = f"B-{our_label}" if offset == 0 else f"I-{our_label}"
72
-
73
- tagged = sum(1 for label in labels if label != "O")
74
- if tagged < 3:
75
- return None
76
-
77
- return {
78
- "tokens": tokens,
79
- "ner_tags": [LABEL2ID[label] for label in labels],
80
- "metadata": {
81
- "source": "dataturks",
82
- "source_id": source_id or "dataturks:unknown",
83
- "group_id": source_id or "dataturks:unknown",
84
- },
85
- }
86
-
87
-
88
- def main() -> None:
89
- data = []
90
- decode_errors = 0
91
- with open(DATA_DIR / "dataturks_raw.json") as f:
92
- for line_no, line in enumerate(f, start=1):
93
- line = line.strip()
94
- if not line:
95
- continue
96
- try:
97
- data.append(json.loads(line))
98
- except json.JSONDecodeError:
99
- decode_errors += 1
100
- print(f"Skipped invalid JSON line {line_no}")
101
-
102
- converted = []
103
- converted_skipped = 0
104
- for idx, item in enumerate(data):
105
- result = convert_dataturks_sample(item, source_id=f"dataturks:{idx}")
106
- if result:
107
- converted.append(result)
108
- else:
109
- converted_skipped += 1
110
-
111
- with open(DATA_DIR / "ner_train.json") as f:
112
- train_payload = json.load(f)
113
- with open(DATA_DIR / "ner_val.json") as f:
114
- val_payload = json.load(f)
115
-
116
- combined, duplicates_removed = dedupe_examples(train_payload["data"] + val_payload["data"] + converted)
117
- train, val = stable_split_examples(combined, train_ratio=0.8)
118
- manifest = {
119
- "builder": "convert_dataturks.py",
120
- "sources": {
121
- "dataturks_raw_lines": len(data),
122
- "dataturks_decode_errors": decode_errors,
123
- "dataturks_converted": len(converted),
124
- "dataturks_skipped": converted_skipped,
125
- "duplicates_removed": duplicates_removed,
126
- },
127
- }
128
- write_dataset(train, val, DATA_DIR, manifest=manifest)
129
-
130
- print(f"DataTurks raw: {len(data)} samples")
131
- print(f"DataTurks converted: {len(converted)}")
132
- print(f"Combined unique: {len(combined)}")
133
- print(f"Train: {len(train)}, Val: {len(val)}")
134
-
135
- label_counts = Counter()
136
- for example in combined:
137
- for tag_id in example["ner_tags"]:
138
- label = ID2LABEL[tag_id]
139
- if label != "O":
140
- label_counts[label[2:]] += 1
141
- print("Label distribution:")
142
- for label, count in label_counts.most_common():
143
- print(f" {label:15s}: {count}")
144
-
145
-
146
- if __name__ == "__main__":
147
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
training/data/{resume_resource_gold.json → gold/resume_resource_gold.json} RENAMED
File without changes
training/data/{resume_resource_labeled.json → gold/resume_resource_labeled.json} RENAMED
File without changes
training/data/kaggle_train.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:905ebc8ef723a3ad1d07e28b26c1bd7e8416896c3570d9dbb30d2b49572cc4d7
3
+ size 54797265
training/data/{dataturks_raw.json → sources/dataturks_raw.json} RENAMED
File without changes
training/data/sources/kaggle_labels.jsonl ADDED
The diff for this file is too large to render. See raw diff
 
training/data/sources/kaggle_resumes.csv ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:15188f30175c6cb3268754c4ec7bd861abd38b94e742ba009a7977163c4e94ef
3
+ size 15112023
training/data/{long_resumes.json → sources/long_resumes.json} RENAMED
File without changes
training/data/sources/resume_resource_texts.json ADDED
The diff for this file is too large to render. See raw diff
 
training/generate_from_structured.py CHANGED
@@ -239,7 +239,7 @@ def main():
239
  from build_long_resumes import build_examples as build_long_examples
240
 
241
  dataturks = []
242
- with open(DATA_DIR / "dataturks_raw.json") as f:
243
  for line in f:
244
  line = line.strip()
245
  if line:
@@ -255,7 +255,7 @@ def main():
255
  long_resumes = build_long_examples()
256
 
257
  resume_resource = []
258
- rr_path = DATA_DIR / "resume_resource_gold.json"
259
  if rr_path.exists():
260
  with open(rr_path) as f:
261
  resume_resource = json.load(f)["data"]
 
239
  from build_long_resumes import build_examples as build_long_examples
240
 
241
  dataturks = []
242
+ with open(DATA_DIR / "sources" / "dataturks_raw.json") as f:
243
  for line in f:
244
  line = line.strip()
245
  if line:
 
255
  long_resumes = build_long_examples()
256
 
257
  resume_resource = []
258
+ rr_path = DATA_DIR / "gold" / "resume_resource_gold.json"
259
  if rr_path.exists():
260
  with open(rr_path) as f:
261
  resume_resource = json.load(f)["data"]
training/label_resume_pdfs.py DELETED
@@ -1,124 +0,0 @@
1
- """Extract text from resume PDFs and assign BIO gold labels.
2
-
3
- Uses kreuzberg for PDF extraction and the current NER model for initial
4
- label predictions, then applies heuristic corrections.
5
-
6
- Run: python -m training.label_resume_pdfs
7
- """
8
-
9
- import asyncio
10
- import json
11
- from pathlib import Path
12
-
13
- import torch
14
- from kreuzberg import extract_file
15
- from transformers import AutoModelForTokenClassification, AutoTokenizer
16
-
17
- from training.benchmark_structured import chunked_predicted_spans
18
- from training.labels import LABEL2ID, ID2LABEL
19
-
20
- PDF_DIR = Path("training/data/resume_resource")
21
- OUT_PATH = Path("training/data/resume_resource_labeled.json")
22
-
23
-
24
- async def extract_all(pdf_dir: Path) -> list[tuple[str, str]]:
25
- pdfs = sorted(pdf_dir.glob("*.pdf"))
26
-
27
- async def extract_one(p):
28
- try:
29
- result = await extract_file(p)
30
- return p.stem, result.content
31
- except Exception:
32
- return p.stem, None
33
-
34
- results = await asyncio.gather(*[extract_one(p) for p in pdfs])
35
- return [(name, text) for name, text in results if text and len(text.split()) >= 20]
36
-
37
-
38
- def spans_to_bio(tokens: list[str], spans: list, text: str) -> list[int]:
39
- labels = [0] * len(tokens)
40
- char_to_token = {}
41
- pos = 0
42
- for i, tok in enumerate(tokens):
43
- idx = text.find(tok, pos)
44
- if idx == -1:
45
- idx = pos
46
- for c in range(idx, idx + len(tok)):
47
- char_to_token[c] = i
48
- pos = idx + len(tok)
49
-
50
- for span in spans:
51
- token_indices = set()
52
- for c in range(span.start, span.end):
53
- if c in char_to_token:
54
- token_indices.add(char_to_token[c])
55
- token_indices = sorted(token_indices)
56
- if not token_indices:
57
- continue
58
- b_label = LABEL2ID.get(f"B-{span.label}", 0)
59
- i_label = LABEL2ID.get(f"I-{span.label}", 0)
60
- for j, ti in enumerate(token_indices):
61
- if labels[ti] == 0:
62
- labels[ti] = b_label if j == 0 else i_label
63
-
64
- return labels
65
-
66
-
67
- def main():
68
- print("Loading model...")
69
- tokenizer = AutoTokenizer.from_pretrained(".")
70
- model = AutoModelForTokenClassification.from_pretrained(".")
71
- model.eval()
72
-
73
- print("Extracting PDFs...")
74
- extracted = asyncio.run(extract_all(PDF_DIR))
75
- print(f"Extracted {len(extracted)} resumes")
76
-
77
- examples = []
78
- skipped = 0
79
- for name, text in extracted:
80
- text = text.strip()
81
- _, pred_spans = chunked_predicted_spans(text, model, tokenizer)
82
- tokens = text.split()
83
- if len(tokens) < 20:
84
- skipped += 1
85
- continue
86
- ner_tags = spans_to_bio(tokens, pred_spans, text)
87
- tagged = sum(1 for t in ner_tags if t != 0)
88
- if tagged < 5:
89
- skipped += 1
90
- continue
91
- examples.append({
92
- "tokens": tokens,
93
- "ner_tags": ner_tags,
94
- "metadata": {
95
- "source": "resume_resource",
96
- "source_id": f"resume_resource:{name}",
97
- "group_id": f"resume_resource:{name}",
98
- },
99
- })
100
-
101
- print(f"Labeled: {len(examples)}, Skipped: {skipped}")
102
-
103
- token_counts = [len(e["tokens"]) for e in examples]
104
- print(f"Token counts: min={min(token_counts)}, max={max(token_counts)}, avg={sum(token_counts)//len(token_counts)}")
105
-
106
- from collections import Counter
107
- label_counts = Counter()
108
- for e in examples:
109
- for tag in e["ner_tags"]:
110
- label = ID2LABEL[tag]
111
- if label != "O":
112
- label_counts[label[2:]] += 1
113
- print("\nLabel distribution:")
114
- for label, count in label_counts.most_common():
115
- print(f" {label:15s}: {count}")
116
-
117
- output = {"data": examples}
118
- with open(OUT_PATH, "w") as f:
119
- json.dump(output, f)
120
- print(f"\nWrote {len(examples)} examples to {OUT_PATH}")
121
-
122
-
123
- if __name__ == "__main__":
124
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
training/structured_postprocess.py CHANGED
@@ -20,12 +20,12 @@ class Span:
20
  class StructuredPostProcessor:
21
  def __init__(self, model_dir: str | Path):
22
  self.model_dir = Path(model_dir)
23
- with open(self.model_dir / "resume_config.json") as fh:
24
  self.config = json.load(fh)
25
  companies_path = self.model_dir / "companies.json"
26
  self.companies = set()
27
  if companies_path.exists():
28
- with open(companies_path) as fh:
29
  data = json.load(fh)
30
  self.companies = {company.lower() for companies in data.values() for company in companies}
31
  self.multi_word_skills = {skill.lower() for skill in self.config.get("multi_word_skills", [])}
 
20
  class StructuredPostProcessor:
21
  def __init__(self, model_dir: str | Path):
22
  self.model_dir = Path(model_dir)
23
+ with open(self.model_dir / "resume_config.json", encoding="utf-8") as fh:
24
  self.config = json.load(fh)
25
  companies_path = self.model_dir / "companies.json"
26
  self.companies = set()
27
  if companies_path.exists():
28
+ with open(companies_path, encoding="utf-8") as fh:
29
  data = json.load(fh)
30
  self.companies = {company.lower() for companies in data.values() for company in companies}
31
  self.multi_word_skills = {skill.lower() for skill in self.config.get("multi_word_skills", [])}
training/tagging.py CHANGED
@@ -1,5 +1,8 @@
1
  from __future__ import annotations
2
 
 
 
 
3
 
4
  def normalize_token(text: str) -> str:
5
  return text.strip().rstrip(",.;:|")
@@ -53,3 +56,28 @@ def tag_exact_words(tokens: list[str], labels: list[str], text: str, label_type:
53
  labels[idx] = f"B-{label_type}" if j == 0 else f"I-{label_type}"
54
  return i + len(words)
55
  return start_from
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from __future__ import annotations
2
 
3
+ from training.labels import LABEL2ID
4
+ from training.structured_postprocess import Span
5
+
6
 
7
  def normalize_token(text: str) -> str:
8
  return text.strip().rstrip(",.;:|")
 
56
  labels[idx] = f"B-{label_type}" if j == 0 else f"I-{label_type}"
57
  return i + len(words)
58
  return start_from
59
+
60
+
61
+ def spans_to_bio(tokens: list[str], spans: list[Span], text: str) -> list[int]:
62
+ labels = [0] * len(tokens)
63
+ char_to_token = {}
64
+ pos = 0
65
+ for i, tok in enumerate(tokens):
66
+ idx = text.find(tok, pos)
67
+ if idx == -1:
68
+ idx = pos
69
+ for char_idx in range(idx, idx + len(tok)):
70
+ char_to_token[char_idx] = i
71
+ pos = idx + len(tok)
72
+
73
+ for span in spans:
74
+ token_indices = sorted({char_to_token[char_idx] for char_idx in range(span.start, span.end) if char_idx in char_to_token})
75
+ if not token_indices:
76
+ continue
77
+ b_label = LABEL2ID.get(f"B-{span.label}", 0)
78
+ i_label = LABEL2ID.get(f"I-{span.label}", 0)
79
+ for offset, token_idx in enumerate(token_indices):
80
+ if labels[token_idx] == 0:
81
+ labels[token_idx] = b_label if offset == 0 else i_label
82
+
83
+ return labels
training/train_ner.py CHANGED
@@ -40,13 +40,25 @@ BASE_MODELS = {
40
  }
41
 
42
 
43
- def load_ner_data(split: str) -> tuple[list, dict]:
44
  path = DATA_DIR / f"ner_{split}.json"
45
  if not path.exists():
46
  raise FileNotFoundError(f"Missing {path}. Generate training data first.")
47
- with open(path) as f:
48
  payload = json.load(f)
49
- return payload["data"], payload["meta"]
 
 
 
 
 
 
 
 
 
 
 
 
50
 
51
 
52
  def tokenize_and_align(examples, tokenizer, label_list, max_length=512):
@@ -132,6 +144,8 @@ def main() -> None:
132
  parser.add_argument("--lr", type=float, default=3e-5)
133
  parser.add_argument("--max-length", type=int, default=512)
134
  parser.add_argument("--seed", type=int, default=42)
 
 
135
  args = parser.parse_args()
136
 
137
  set_global_seed(args.seed)
@@ -139,7 +153,7 @@ def main() -> None:
139
  model_name = BASE_MODELS[args.base_model]
140
  output_dir = OUTPUT_DIR / args.base_model
141
 
142
- train_data, meta = load_ner_data("train")
143
  val_data, _ = load_ner_data("val")
144
  label_list = meta["label_list"]
145
  label2id = {label: idx for idx, label in enumerate(label_list)}
 
40
  }
41
 
42
 
43
+ def load_ner_data(split: str, extra_train_files: list[str] | None = None) -> tuple[list, dict]:
44
  path = DATA_DIR / f"ner_{split}.json"
45
  if not path.exists():
46
  raise FileNotFoundError(f"Missing {path}. Generate training data first.")
47
+ with open(path, encoding="utf-8") as f:
48
  payload = json.load(f)
49
+ data = payload["data"]
50
+ meta = payload["meta"]
51
+
52
+ if split == "train" and extra_train_files:
53
+ for extra in extra_train_files:
54
+ extra_path = DATA_DIR / extra
55
+ if extra_path.exists():
56
+ with open(extra_path, encoding="utf-8") as f:
57
+ extra_payload = json.load(f)
58
+ data.extend(extra_payload["data"])
59
+ print(f" + {extra}: {len(extra_payload['data'])} records")
60
+
61
+ return data, meta
62
 
63
 
64
  def tokenize_and_align(examples, tokenizer, label_list, max_length=512):
 
144
  parser.add_argument("--lr", type=float, default=3e-5)
145
  parser.add_argument("--max-length", type=int, default=512)
146
  parser.add_argument("--seed", type=int, default=42)
147
+ parser.add_argument("--extra-train", nargs="*", default=["kaggle_train.json"],
148
+ help="Extra training JSON files in data/ dir")
149
  args = parser.parse_args()
150
 
151
  set_global_seed(args.seed)
 
153
  model_name = BASE_MODELS[args.base_model]
154
  output_dir = OUTPUT_DIR / args.base_model
155
 
156
+ train_data, meta = load_ner_data("train", extra_train_files=args.extra_train)
157
  val_data, _ = load_ner_data("val")
158
  label_list = meta["label_list"]
159
  label2id = {label: idx for idx, label in enumerate(label_list)}