
A hands-on guide to fine-tuning a transformer that maps any job title to a clean seniority level – solving one of recruiting tech’s most persistent data problems. Built with open job-market data available on Hugging Face.
The problem: every company invents its own titles
Job titles are a mess. One company’s ‘Member of Technical Staff’ is another’s ‘Senior Software Engineer’; ‘Lead’, ‘Staff’, ‘Principal’, and ‘II/III’ are used inconsistently across employers and applicant tracking systems. For anyone building search, matching, analytics, or pay benchmarking on top of job data, this inconsistency is a silent killer: it fragments the same role into dozens of variants and breaks every downstream aggregation.
The fix is title normalization – mapping any raw title to a consistent taxonomy. In this guide you will fine-tune a transformer to predict a normalized seniority level from a raw job title, an approach that generalizes far better than brittle keyword rules.
What you will build
- A fine-tuned text classifier that maps a raw job title to a normalized seniority level
- A clean training pipeline using the Hugging Face datasets and transformers libraries
- An evaluation, an inference helper, and a clear path to production
Because the model learns from real titles paired with normalized labels, it handles novel and noisy titles that rule-based systems miss.
The fuel: real titles with normalized labels
Supervised normalization needs titles paired with clean labels. We use an open dataset of ~112,000 job postings across 23 applicant tracking systems; each posting includes the raw title plus a model-normalized seniority level (job_level_normalized) such as Intern, Entry, Mid, Senior, Lead, Principal, Director, VP, and Executive. It is free under CC BY 4.0 and available on Hugging Face.
Step 1 – Load and prepare labels
from datasets import load_dataset, ClassLabel
import pandas as pd
ds = load_dataset(“NextGig-Rocks/global-job-postings-multi-ats”, split=”train”)
df = ds.to_pandas()[[‘title’,’job_level_normalized’]].dropna()
# keep well-represented seniority classes
keep = [‘Intern’,’Entry’,’Mid’,’Senior’,’Lead’,’Principal’,’Director’,’VP’,’Executive’]
df = df[df[‘job_level_normalized’].isin(keep)]
labels = sorted(df[‘job_level_normalized’].unique())
label2id = {l:i for i,l in enumerate(labels)}
df[‘label’] = df[‘job_level_normalized’].map(label2id)
print(df[‘job_level_normalized’].value_counts())
Step 2 – Tokenize
Split into train/validation sets and tokenize the titles with a compact, fast base model.
from datasets import Dataset
from transformers import AutoTokenizer
data = Dataset.from_pandas(df[[‘title’,’label’]], preserve_index=False)
data = data.train_test_split(test_size=0.1, seed=42)
ckpt = “distilbert-base-uncased”
tok = AutoTokenizer.from_pretrained(ckpt)
def encode(b): return tok(b[‘title’], truncation=True, max_length=32)
data = data.map(encode, batched=True)
Step 3 – Fine-tune
Load the base model with a classification head and train with the Trainer API.
import numpy as np, evaluate
from transformers import (AutoModelForSequenceClassification,
TrainingArguments, Trainer, DataCollatorWithPadding)
model = AutoModelForSequenceClassification.from_pretrained(
ckpt, num_labels=len(labels),
id2label={i:l for l,i in label2id.items()}, label2id=label2id)
acc = evaluate.load(‘accuracy’)
f1 = evaluate.load(‘f1′)
def metrics(p):
preds = np.argmax(p.predictions, axis=1)
return {**acc.compute(predictions=preds, references=p.label_ids),
**f1.compute(predictions=preds, references=p.label_ids, average=’macro’)}
args = TrainingArguments(output_dir=’seniority-clf’, num_train_epochs=3,
per_device_train_batch_size=64, per_device_eval_batch_size=64,
eval_strategy=’epoch’, learning_rate=3e-5, load_best_model_at_end=True,
save_strategy=’epoch’, metric_for_best_model=’f1′)
trainer = Trainer(model=model, args=args,
train_dataset=data[‘train’], eval_dataset=data[‘test’],
tokenizer=tok, data_collator=DataCollatorWithPadding(tok),
compute_metrics=metrics)
trainer.train()
Step 4 – Evaluate and inspect
Check macro-F1 (robust to class imbalance) and look at the confusion between adjacent levels.
print(trainer.evaluate())
from sklearn.metrics import classification_report
import numpy as np
pred = trainer.predict(data[‘test’])
y_hat = np.argmax(pred.predictions, axis=1)
print(classification_report(pred.label_ids, y_hat, target_names=labels))
Adjacent seniority levels (Mid vs. Senior, Lead vs. Principal) are the hardest to separate – exactly the ambiguity that makes rule-based normalization fail, and where a learned model adds the most value.
Step 5 – Normalize a new title
from transformers import pipeline
clf = pipeline(‘text-classification’, model=trainer.model, tokenizer=tok)
for t in [‘Member of Technical Staff’,’Sr. SWE II’,’Head of Data’,’Engineering Intern’]:
print(t, ‘->’, clf(t)[0][‘label’])
From notebook to production
- Concatenate normalized_title or a snippet of the description with the title for more context.
- Push the fine-tuned model to the Hugging Face Hub and serve it via Inference Endpoints.
- Extend the taxonomy: train a second head for job function or department to fully normalize titles.
- Distill into an even smaller model (e.g. MiniLM) for low-latency, high-volume normalization.
- Add a confidence threshold so uncertain titles are routed for human review.
The same playbook, other high-value use cases
The load-label-fine-tune pattern generalizes across the dataset’s structured fields:
Job function / department classification
Predict the function or team from title and description to power faceted search.
Remote-vs-onsite and ‘AI job’ detection
Train binary classifiers on work_model or AI-relevance to enrich filtering.
Skills extraction fine-tuning
Use the paired posting/skills format to distill a large model’s extraction into a fast, self-hosted one.
Semantic job matching
Fine-tune sentence embeddings on skills and responsibilities for resume-to-job and job-to-job similarity.
Salary prediction
Combine the normalized title with skills and geography to model compensation.
Get the data and start building
The open dataset used in this guide is free under CC BY 4.0:
A note on responsible use: the normalized labels are model-generated and may contain occasional errors, and some seniority classes are rarer than others – weight or resample accordingly. The dataset is built for modeling and research, not for re-scraping or powering a live job board.
