Token Classification
Transformers
ONNX
Safetensors
English
distilbert
resume-parsing
ner
resume
cv
information-extraction
Instructions to use oksomu/resume-ner with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use oksomu/resume-ner with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("token-classification", model="oksomu/resume-ner")# Load model directly from transformers import AutoTokenizer, AutoModelForTokenClassification tokenizer = AutoTokenizer.from_pretrained("oksomu/resume-ner") model = AutoModelForTokenClassification.from_pretrained("oksomu/resume-ner", device_map="auto") - Notebooks
- Google Colab
- Kaggle
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 +30 -18
- docs/implementation_guide.md +347 -0
- model.safetensors +1 -1
- onnx/model.onnx +1 -1
- onnx/model_quantized.onnx +1 -1
- training/build_gold_labels.py +2 -2
- training/build_long_resumes.py +0 -0
- training/convert_dataturks.py +0 -147
- training/data/{resume_resource_gold.json → gold/resume_resource_gold.json} +0 -0
- training/data/{resume_resource_labeled.json → gold/resume_resource_labeled.json} +0 -0
- training/data/kaggle_train.json +3 -0
- training/data/{dataturks_raw.json → sources/dataturks_raw.json} +0 -0
- training/data/sources/kaggle_labels.jsonl +0 -0
- training/data/sources/kaggle_resumes.csv +3 -0
- training/data/{long_resumes.json → sources/long_resumes.json} +0 -0
- training/data/sources/resume_resource_texts.json +0 -0
- training/generate_from_structured.py +2 -2
- training/label_resume_pdfs.py +0 -124
- training/structured_postprocess.py +2 -2
- training/tagging.py +28 -0
- training/train_ner.py +18 -4
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
|
| 39 |
|
| 40 |
-
- entity F1: **97.
|
| 41 |
-
- structured micro F1: **
|
| 42 |
-
-
|
| 43 |
-
- noisy resume F1: **
|
| 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: **
|
| 195 |
-
- macro field F1: **98.
|
| 196 |
-
- clean-resume micro F1: **99.
|
| 197 |
-
- noisy-resume micro F1: **
|
| 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
|
| 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:
|
| 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:
|
| 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:
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)}
|