04 — Evaluation: Lexical vs. Embedding Matching, Honestly¶
This is the notebook that actually decides whether anything in this repo
works. Non-negotiable design rules (see README.md / PROGRESS.md for why —
this machine has a history of AI-built job-title projects being retracted
over exactly these mistakes):
- Real negatives, count stated inline with every metric. Not just positives dressed up as an eval set.
- A naive baseline reported next to every number, so any "improvement" is visibly honest rather than assumed.
- Dedup by normalized text before any split.
- No circularity. The eval queries come from ONET — an independently built US-government taxonomy with zero ESCO involvement. Neither matcher was built from ONET; both are evaluated against it.
- Report the honest number even if mediocre, and sanity-check against a known prior finding (a previous project on this machine found ESCO-lexicon matching resolves ~32.9% of real titles at ~24% precision — if our numbers look wildly different, that's a signal to go find the bug before reporting anything).
import json
import random
import re
import matplotlib.pyplot as plt
import numpy as np
import polars as pl
from rapidfuzz import fuzz, process
from sentence_transformers import SentenceTransformer
from sklearn.metrics import roc_auc_score
from common import (
DATA_DIR, RESULTS_DIR, batched_topk, build_esco_en_candidates, encode_texts,
lexical_match, load_esco, load_onet, normalize, onet_all_titles,
)
BLUE = "#2a78d6"
ORANGE = "#eb6834"
INK = "#0b0b0b"
SECONDARY_INK = "#52514e"
MUTED = "#898781"
GRID = "#e1e0d9"
SURFACE = "#fcfcfb"
plt.rcParams.update({
"figure.facecolor": SURFACE, "axes.facecolor": SURFACE, "axes.edgecolor": GRID,
"axes.labelcolor": SECONDARY_INK, "text.color": INK, "xtick.color": SECONDARY_INK,
"ytick.color": SECONDARY_INK, "axes.grid": True, "grid.color": GRID,
"grid.linewidth": 0.8, "font.size": 10,
})
RNG_SEED = 42
FUZZY_THRESHOLD = 85 # picked in notebook 02, held fixed here
EMBED_THRESHOLD = 0.6 # picked below, in section 3
esco_occ, esco_titles = load_esco()
onet_occ, onet_titles = load_onet()
candidates = build_esco_en_candidates(esco_titles)
print(f"ESCO EN candidate lexicon: {candidates.height} unique normalized titles "
f"({candidates['uri'].n_unique()} distinct occupations)")
ESCO EN candidate lexicon: 16919 unique normalized titles (1695 distinct occupations)
1. Building an honest positive eval set (no ESCO circularity)¶
We need query titles with a known correct ESCO occupation, sourced independently of ESCO. O*NET has no official ESCO crosswalk, so we build weak supervision the only honest way available without one:
- Build the ESCO EN lexicon (same as notebook 02): normalized title -> ESCO occupation URI, dropping ambiguous normalized titles.
- For each O*NET occupation code, gather every title associated with it (formal title + "Job Titles" alternates + "Sample of Reported Titles" self-reported titles).
- If exactly one distinct ESCO occupation is reachable via an exact normalized-string match from any title under that ONET code, we treat that as a clean anchor: this ONET code's titles are about that ESCO occupation. If titles under one O*NET code exact-match multiple different ESCO occupations, we drop that code entirely — we're not going to guess which one is right.
- The other, non-anchor titles under each clean-anchor code become our positive eval queries, labeled with the anchor's ESCO URI as ground truth. Critically, these are titles that were not used to derive the ground truth — the anchor string itself is excluded — so this tests real generalization to reworded titles, not memorization of the anchor string.
Honest limitation of this method, disclosed up front: ONET's title groupings under one occupation code are sometimes broader than strict synonymy (e.g. many loosely-related "reported titles" can share a code). This means a small fraction of our "ground truth" labels are noisier than a hand-curated crosswalk would be. We show a concrete example of this in the error analysis (section 6) rather than pretending it doesn't happen. We did not find a better keyless way to build ground truth without an official ESCO-ONET crosswalk (which doesn't exist).
en_lex = candidates.select(["norm", "title", "uri"])
exact_lookup = dict(zip(en_lex["norm"].to_list(), en_lex["uri"].to_list()))
lex_choices = en_lex["norm"].to_list()
onet_all = onet_all_titles(onet_occ, onet_titles).with_columns(
pl.col("title").map_elements(normalize, return_dtype=pl.Utf8).alias("norm")
)
exact_joined = onet_all.join(en_lex.select(["norm", "uri"]), on="norm", how="inner")
code_uri_counts = exact_joined.group_by("onetsoc_code").agg(pl.col("uri").n_unique().alias("n_distinct_uris"))
clean_codes = code_uri_counts.filter(pl.col("n_distinct_uris") == 1)["onetsoc_code"]
ambiguous_codes_dropped = code_uri_counts.filter(pl.col("n_distinct_uris") > 1).height
print(f"O*NET occupation codes with >=1 exact ESCO match: {code_uri_counts.height}")
print(f" -> clean (single ESCO occupation): {clean_codes.len()}")
print(f" -> dropped as ambiguous (matched >1 distinct ESCO occupation): {ambiguous_codes_dropped}")
code_to_uri = (
exact_joined.filter(pl.col("onetsoc_code").is_in(clean_codes))
.select(["onetsoc_code", "uri"]).unique()
)
under_clean_codes = onet_all.filter(pl.col("onetsoc_code").is_in(clean_codes)).join(code_to_uri, on="onetsoc_code", how="left")
flagged = under_clean_codes.join(
en_lex.select(["norm", "uri"]).rename({"uri": "uri_from_norm"}), on="norm", how="left"
).with_columns((pl.col("uri_from_norm") == pl.col("uri")).fill_null(False).alias("is_anchor_string"))
positives = (
flagged.filter(~pl.col("is_anchor_string"))
.select(["norm", "title", "uri"])
.unique(subset=["norm"]) # dedup by normalized text — rule #3
.sort("norm")
)
print(f"\nPositive eval queries: {positives.height} "
f"(deduped by normalized text, drawn from {clean_codes.len()} clean-anchor O*NET codes, "
f"excluding the anchor strings themselves)")
positives.head(10)
O*NET occupation codes with >=1 exact ESCO match: 875 -> clean (single ESCO occupation): 230 -> dropped as ambiguous (matched >1 distinct ESCO occupation): 645 Positive eval queries: 6460 (deduped by normalized text, drawn from 230 clean-anchor O*NET codes, excluding the anchor strings themselves)
/tmp/ipykernel_3113935/117295720.py:18: DeprecationWarning: `is_in` with a collection of the same datatype is ambiguous and deprecated.
Please use `implode` to return to previous behavior.
See https://github.com/pola-rs/polars/issues/22149 for more information.
exact_joined.filter(pl.col("onetsoc_code").is_in(clean_codes))
/tmp/ipykernel_3113935/117295720.py:21: DeprecationWarning: `is_in` with a collection of the same datatype is ambiguous and deprecated.
Please use `implode` to return to previous behavior.
See https://github.com/pola-rs/polars/issues/22149 for more information.
under_clean_codes = onet_all.filter(pl.col("onetsoc_code").is_in(clean_codes)).join(code_to_uri, on="onetsoc_code", how="left")
| norm | title | uri |
|---|---|---|
| str | str | str |
| "4 h agent" | "4-H Agent" | "http://data.europa.eu/esco/occ… |
| "4 h club agent" | "4-H Club Agent" | "http://data.europa.eu/esco/occ… |
| "4 h youth development educator" | "4-H Youth Development Educator" | "http://data.europa.eu/esco/occ… |
| "4 h youth development speciali… | "4-H Youth Development Speciali… | "http://data.europa.eu/esco/occ… |
| "4 h youth educator" | "4-H Youth Educator" | "http://data.europa.eu/esco/occ… |
| "911 communications manager" | "911 Communications Manager" | "http://data.europa.eu/esco/occ… |
| "a c installer servicer air con… | "A/C Installer-Servicer (Air Co… | "http://data.europa.eu/esco/occ… |
| "a c mechanic air conditioner m… | "A/C Mechanic (Air Conditioner … | "http://data.europa.eu/esco/occ… |
| "a c service tech air condition… | "A/C Service Tech (Air Conditio… | "http://data.europa.eu/esco/occ… |
| "a c tech air conditioning tech… | "A/C Tech (Air Conditioning Tec… | "http://data.europa.eu/esco/occ… |
2. Building real negatives — titles that should NOT match anything¶
We deliberately do not treat "O*NET titles with no exact ESCO string match" as negatives — that would unfairly penalize a matcher for succeeding at the exact thing it's supposed to do (finding true matches despite surface variation). Instead we synthesize three kinds of query that are not real job titles at all, so "should this be rejected" has an unambiguous answer:
- Hybrid: half the words of one real title + half the words of a different, randomly-paired real title, shuffled together (e.g. combining fragments of two unrelated occupations into a nonsense hybrid).
- Shuffled: a real multi-word title with its word order randomly permuted, breaking the grammar (this reuses real vocabulary — see the important caveat about this in section 5).
- Generic non-title phrases: a fixed, hand-written list of ~40 strings
that are clearly not occupations (place names, sentences, boilerplate,
brand-name-style strings) — echoing a real lesson from a prior project on
this machine (
titlevalidate), where a validator falsely accepted company names as valid job titles.
All three are explicitly disclosed here as synthetic, not real ONET data. Every negative is checked against the full ESCO + ONET normalized-text universe and dropped if it accidentally collides with something real.
GENERIC_NON_TITLES = [
"New York City", "The quick brown fox jumps over the lazy dog", "Blue Ocean Consulting Group",
"Table", "Quarterly Earnings Report", "Terms and Conditions Apply", "Monday Morning Meeting",
"United Nations Headquarters", "Please Try Again Later", "Lorem Ipsum Dolor Sit Amet",
"Great Barrier Reef", "404 Page Not Found", "Acme Global Holdings LLC", "Room 237",
"Happy Birthday To You", "The Weather Is Nice Today", "Season 3 Episode 7",
"Central Park West", "Bring Your Own Device", "Frequently Asked Questions",
"Thank You For Your Patience", "Chapter Eleven Bankruptcy", "Silicon Valley Tech Park",
"Two Factor Authentication", "Best Buy Electronics Store", "North By Northwest",
"In Case Of Emergency", "Subject To Change Without Notice", "All Rights Reserved",
"Coffee With Two Sugars", "Left Turn Only", "Annual General Meeting Minutes",
"Black Friday Sale", "General Data Protection Regulation", "River Thames Bridge",
"The Fellowship Of The Ring", "Ctrl Alt Delete", "Wi-Fi Password Required",
"Fiscal Year 2025 Budget", "Employee Of The Month Parking Spot",
]
known_norms = set(en_lex["norm"].to_list()) | set(onet_all["norm"].to_list())
real_title_pool = sorted(set(en_lex["title"].to_list()) | set(onet_all["title"].to_list()))
# sorted(), not list(): Python's set iteration order depends on the process's hash seed
# (PYTHONHASHSEED is randomized per run), so list(set(...)) would make gen_hybrid's and
# gen_shuffled's rng.sample() draw different negatives on every run despite a fixed seed
# — silently breaking the exact reproducibility this repo cares about. Sorting first
# fixes the input order so the same seed always produces the same negatives.
def gen_hybrid(n, seed):
rng = random.Random(seed)
pool = [t for t in real_title_pool if len(t.split()) >= 2]
out = set()
tries = 0
while len(out) < n and tries < n * 20:
tries += 1
a, b = rng.sample(pool, 2)
wa, wb = a.split(), b.split()
combo = wa[: max(1, len(wa) // 2)] + wb[max(1, len(wb) // 2):]
if len(combo) < 2:
continue
rng.shuffle(combo)
s = " ".join(combo)
n_ = normalize(s)
if n_ and n_ not in known_norms:
out.add(s)
return list(out)
def gen_shuffled(n, seed):
rng = random.Random(seed)
pool = [t for t in real_title_pool if len(t.split()) >= 3]
rng.shuffle(pool)
out = set()
i = 0
while len(out) < n and i < len(pool):
t = pool[i]
i += 1
words = t.split()
shuffled = words[:]
for _ in range(5):
rng.shuffle(shuffled)
if shuffled != words:
break
s = " ".join(shuffled)
n_ = normalize(s)
if n_ and n_ not in known_norms and n_ != normalize(t):
out.add(s)
return list(out)
hybrid_negs = gen_hybrid(1200, seed=2024)
shuffled_negs = gen_shuffled(1200, seed=2025)
all_neg_titles = hybrid_negs + shuffled_negs + GENERIC_NON_TITLES
seen, negatives_rows = set(), []
for t in all_neg_titles:
n_ = normalize(t)
if n_ and n_ not in known_norms and n_ not in seen:
seen.add(n_)
negatives_rows.append({"norm": n_, "title": t})
negatives = pl.DataFrame(negatives_rows).sort("norm")
print(f"Negative eval queries: {negatives.height} total "
f"(hybrid={len(hybrid_negs)} generated, shuffled={len(shuffled_negs)} generated, "
f"generic={len(GENERIC_NON_TITLES)} hand-written; after dedup + collision-filtering "
f"against all real ESCO/O*NET normalized text: {negatives.height})")
negatives.head(10)
Negative eval queries: 2439 total (hybrid=1200 generated, shuffled=1200 generated, generic=40 hand-written; after dedup + collision-filtering against all real ESCO/O*NET normalized text: 2439)
| norm | title |
|---|---|
| str | str |
| "404 page not found" | "404 Page Not Found" |
| "a language second substitute w… | "a Language Second Substitute) … |
| "abatement worker asbestos" | "abatement worker asbestos" |
| "ac assembler air assembler con… | "(AC Assembler Air Assembler) C… |
| "access labourer platform" | "access labourer platform" |
| "accessories installer automobi… | "Accessories Installer Automobi… |
| "accountant live reporting" | "accountant live reporting" |
| "acid deoxyribonucleic analyst … | "Acid (Deoxyribonucleic Analyst… |
| "acme global holdings llc" | "Acme Global Holdings LLC" |
| "acquisitions manager property" | "acquisitions manager property" |
3. Building the combined eval set — class balance stated explicitly¶
eval_df = pl.concat([
positives.with_columns(pl.lit(True).alias("is_positive")),
negatives.with_columns(pl.lit(None).cast(pl.Utf8).alias("uri"), pl.lit(False).alias("is_positive")),
])
n_pos, n_neg = positives.height, negatives.height
n_total = n_pos + n_neg
print(f"EVAL SET: {n_total} total queries — {n_pos} positive ({n_pos/n_total:.1%}), "
f"{n_neg} negative ({n_neg/n_total:.1%}). Every metric below states which of these "
f"queries it's computed over.")
EVAL SET: 8899 total queries — 6460 positive (72.6%), 2439 negative (27.4%). Every metric below states which of these queries it's computed over.
4. Running both matchers over the identical eval set¶
Lexical matcher: exact-match then fuzzy fallback, threshold 85 (from
notebook 02). Embedding matcher: cached candidate embeddings from notebook 03,
cosine top-1/top-5 via common.batched_topk.
# --- Lexical ---
lex_accept, lex_pred_uri, lex_method, lex_score = [], [], [], []
for n in eval_df["norm"].to_list():
r = lexical_match(n, exact_lookup, lex_choices, exact_lookup, threshold=FUZZY_THRESHOLD)
lex_accept.append(r["matched"])
lex_pred_uri.append(r["uri"])
lex_method.append(r["method"])
lex_score.append(r["score"])
print(f"Lexical matcher run over {len(lex_accept)} queries. "
f"Method breakdown: exact={lex_method.count('exact')}, fuzzy={lex_method.count('fuzzy')}, "
f"none={lex_method.count('none')}")
print("(exact=0 is expected, not a bug: positive queries were built by construction to "
"EXCLUDE the anchor strings that gave us ground truth in the first place — see "
"section 1 — and negatives are filtered to never collide with real ESCO text. So by "
"design, nothing in this eval set can hit the exact-match path; every accepted match "
"here goes through the fuzzy fallback. Notebook 02's broader, non-eval sample showed "
"exact matches do happen on the general O*NET population — just not on this "
"deliberately-hard, deliberately-clean subset.)")
# --- Embedding ---
cand_emb = np.load(f"{DATA_DIR}/esco_en_candidate_embeddings.npy")
cand_meta = pl.read_csv(f"{DATA_DIR}/esco_en_candidates.csv")
assert cand_meta.height == cand_emb.shape[0], "cached embeddings and candidate metadata are misaligned"
cand_uris = cand_meta["uri"].to_list()
model = SentenceTransformer("paraphrase-multilingual-MiniLM-L12-v2", device="cpu")
query_emb = encode_texts(model, eval_df["title"].to_list(), batch_size=64)
top_idx, top_score = batched_topk(query_emb, cand_emb, k=5)
emb_top1_uri = np.array([cand_uris[i] for i in top_idx[:, 0]], dtype=object)
emb_top1_score = top_score[:, 0]
print(f"Embedding matcher run over {query_emb.shape[0]} queries.")
is_pos = eval_df["is_positive"].to_numpy()
true_uri = np.array(eval_df["uri"].to_list(), dtype=object)
lex_accept_arr = np.array(lex_accept)
lex_pred_uri_arr = np.array(lex_pred_uri, dtype=object)
Lexical matcher run over 8899 queries. Method breakdown: exact=0, fuzzy=1453, none=7446 (exact=0 is expected, not a bug: positive queries were built by construction to EXCLUDE the anchor strings that gave us ground truth in the first place — see section 1 — and negatives are filtered to never collide with real ESCO text. So by design, nothing in this eval set can hit the exact-match path; every accepted match here goes through the fuzzy fallback. Notebook 02's broader, non-eval sample showed exact matches do happen on the general O*NET population — just not on this deliberately-hard, deliberately-clean subset.)
Warning: You are sending unauthenticated requests to the HF Hub. Please set a HF_TOKEN to enable higher rate limits and faster downloads.
Embedding matcher run over 8899 queries.
5. Metric 1 — Accept/Reject: can either matcher tell a real title from garbage?¶
For this we need a decision threshold. We already fixed the lexical threshold (85) in notebook 02. For the embedding matcher, we look at the top-1 cosine score distributions of positives vs. negatives before picking one — the same spirit as notebook 02's approach.
pos_scores = emb_top1_score[is_pos]
neg_scores = emb_top1_score[~is_pos]
fig, ax = plt.subplots(figsize=(9, 4))
ax.hist(pos_scores, bins=40, color=BLUE, alpha=0.65, label=f"positive queries (n={n_pos})", density=True)
ax.hist(neg_scores, bins=40, color=ORANGE, alpha=0.55, label=f"synthetic negative queries (n={n_neg})", density=True)
ax.axvline(EMBED_THRESHOLD, color=INK, linewidth=1.2, linestyle="--", label=f"threshold = {EMBED_THRESHOLD}")
ax.set_xlabel("Top-1 cosine similarity score")
ax.set_ylabel("density")
ax.set_title("Embedding top-1 score: positives vs. negatives — these barely separate", loc="left", fontweight="bold")
ax.legend(frameon=False, fontsize=9)
ax.spines[["top", "right"]].set_visible(False)
plt.tight_layout()
plt.savefig(f"{RESULTS_DIR}/embedding_score_distribution_pos_vs_neg.png", dpi=110)
plt.show()
emb_auc = roc_auc_score(is_pos, emb_top1_score)
lex_continuous_score = []
for n in eval_df["norm"].to_list():
if n in exact_lookup:
lex_continuous_score.append(100.0)
else:
hit = process.extractOne(n, lex_choices, scorer=fuzz.token_sort_ratio)
lex_continuous_score.append(hit[1] if hit else 0.0)
lex_continuous_score = np.array(lex_continuous_score)
lex_auc = roc_auc_score(is_pos, lex_continuous_score)
print(f"AUC of embedding top-1 cosine score as an accept/reject signal: {emb_auc:.3f}")
print(f"AUC of lexical fuzzy score as an accept/reject signal: {lex_auc:.3f}")
print("(0.5 = no better than random chance; 1.0 = perfect separation)")
AUC of embedding top-1 cosine score as an accept/reject signal: 0.535 AUC of lexical fuzzy score as an accept/reject signal: 0.426 (0.5 = no better than random chance; 1.0 = perfect separation)
Both are close to useless as accept/reject signals, for two different, both honest, reasons:
- Embedding (AUC {emb_auc:.2f}): raw cosine similarity to something in a dense 16,919-title candidate pool is almost always fairly high, whether the query is a real title or synthetic word salad — there's nearly always a "closest" candidate that looks plausible. This is a known failure mode of using raw embedding similarity for out-of-distribution detection.
- Lexical (AUC {lex_auc:.2f}, below 0.5 — worse than random!): our "hybrid" and "shuffled" negatives are constructed by recombining real title vocabulary, which is exactly what token-overlap fuzzy scoring rewards. Meanwhile true positive queries are often genuine paraphrases with different words from their correct ESCO match (that's what makes them a real matching problem). We checked this directly below — it's not guesswork.
generic_titles_set = set(GENERIC_NON_TITLES)
generic_mask = eval_df["title"].is_in(list(generic_titles_set)).to_numpy() & (~is_pos)
vocab_reuse_mask = (~is_pos) & (~generic_mask)
print(f"Mean lexical fuzzy score — true positive queries: {lex_continuous_score[is_pos].mean():.1f}")
print(f"Mean lexical fuzzy score — vocabulary-reuse negatives (hybrid/shuffled, n={vocab_reuse_mask.sum()}): {lex_continuous_score[vocab_reuse_mask].mean():.1f}")
print(f"Mean lexical fuzzy score — generic non-title phrases (n={generic_mask.sum()}): {lex_continuous_score[generic_mask].mean():.1f}")
print()
print("The vocabulary-reuse negatives score HIGHER on average than genuine positive queries.")
print("Generic non-title phrases (no shared vocabulary) score much lower, as expected.")
print("This is a real, specific weakness of surface-form fuzzy matching, not a bug in our eval code.")
Mean lexical fuzzy score — true positive queries: 75.2 Mean lexical fuzzy score — vocabulary-reuse negatives (hybrid/shuffled, n=2399): 79.2 Mean lexical fuzzy score — generic non-title phrases (n=40): 61.2 The vocabulary-reuse negatives score HIGHER on average than genuine positive queries. Generic non-title phrases (no shared vocabulary) score much lower, as expected. This is a real, specific weakness of surface-form fuzzy matching, not a bug in our eval code.
lex_fp_rate = np.sum(lex_accept_arr & ~is_pos) / n_neg
lex_recall_accept = np.sum(lex_accept_arr & is_pos) / n_pos
emb_accept = emb_top1_score >= EMBED_THRESHOLD
emb_fp_rate = np.sum(emb_accept & ~is_pos) / n_neg
emb_recall_accept = np.sum(emb_accept & is_pos) / n_pos
print(f"Lexical (threshold=85): false-accept rate on {n_neg} negatives = {lex_fp_rate:.1%} | accept rate on {n_pos} positives = {lex_recall_accept:.1%}")
print(f"Embedding (threshold={EMBED_THRESHOLD}): false-accept rate on {n_neg} negatives = {emb_fp_rate:.1%} | accept rate on {n_pos} positives = {emb_recall_accept:.1%}")
Lexical (threshold=85): false-accept rate on 2439 negatives = 26.0% | accept rate on 6460 positives = 12.7% Embedding (threshold=0.6): false-accept rate on 2439 negatives = 95.6% | accept rate on 6460 positives = 98.1%
6. Metric 2 — Naive baseline and accept/reject precision/recall/F1¶
Important honesty note about this specific metric: our eval set is class-imbalanced (72.6% positive / 27.4% negative — stated explicitly in section 3). A naive baseline that always accepts every query gets high raw accept/reject F1 purely from that imbalance, without ever identifying a specific occupation. We report it anyway (per rule #2 — always show the naive baseline), but we immediately follow it with Metric 3 (section 7), which is the metric that actually matters: is the matched occupation correct, which the naive baseline cannot do by construction.
def prf1(pred_accept, is_pos):
tp = int(np.sum(pred_accept & is_pos))
fp = int(np.sum(pred_accept & ~is_pos))
fn = int(np.sum(~pred_accept & is_pos))
tn = int(np.sum(~pred_accept & ~is_pos))
precision = tp / (tp + fp) if (tp + fp) else 0.0
recall = tp / (tp + fn) if (tp + fn) else 0.0
f1 = 2 * precision * recall / (precision + recall) if (precision + recall) else 0.0
return {"TP": tp, "FP": fp, "FN": fn, "TN": tn, "precision": precision, "recall": recall, "f1": f1}
naive_always_accept = np.ones(n_total, dtype=bool)
metrics_table = pl.DataFrame([
{"matcher": "naive: always accept", **prf1(naive_always_accept, is_pos)},
{"matcher": "lexical (threshold=85)", **prf1(lex_accept_arr, is_pos)},
{"matcher": f"embedding (threshold={EMBED_THRESHOLD})", **prf1(emb_accept, is_pos)},
])
print(f"Accept/Reject classification — n_positive={n_pos}, n_negative={n_neg} (stated explicitly, per rule #1)")
metrics_table
Accept/Reject classification — n_positive=6460, n_negative=2439 (stated explicitly, per rule #1)
| matcher | TP | FP | FN | TN | precision | recall | f1 |
|---|---|---|---|---|---|---|---|
| str | i64 | i64 | i64 | i64 | f64 | f64 | f64 |
| "naive: always accept" | 6460 | 2439 | 0 | 0 | 0.725924 | 1.0 | 0.841201 |
| "lexical (threshold=85)" | 820 | 633 | 5640 | 1806 | 0.56435 | 0.126935 | 0.207254 |
| "embedding (threshold=0.6)" | 6339 | 2331 | 121 | 108 | 0.731142 | 0.981269 | 0.837938 |
Exactly as flagged above: the naive "always accept" baseline has the highest raw F1 here, entirely because of class imbalance — it is not a better job- title matcher, it just never says no. This table by itself would be misleading to report alone, which is why the next section exists.
7. Metric 3 — Occupation-matching accuracy (the metric that actually matters)¶
Restricted to the {n_pos} positive queries, since only they have a defined correct answer. Top-1 accuracy: does the matcher's best guess equal the true ESCO occupation? Recall@5: is the true occupation anywhere in the top 5? We also report precision given accept: of the queries each matcher was willing to answer at all, how often was it right — this is a calibration check, not just raw coverage.
Naive baseline: uniform random guess among the {candidates.height}-title candidate pool. Expected top-1 accuracy = 1/{candidates.height} = {1/candidates.height:.4%}.
lex_correct = (lex_pred_uri_arr == true_uri) & is_pos
lex_acc1_overall = np.sum(lex_correct) / n_pos # counts "no match" as wrong
lex_acc1_given_accept = np.sum(lex_correct) / np.sum(lex_accept_arr & is_pos) if np.sum(lex_accept_arr & is_pos) else 0.0
emb_correct = (emb_top1_uri == true_uri) & is_pos
emb_acc1_overall = np.sum(emb_correct) / n_pos
emb_acc1_given_accept = np.sum(emb_correct & emb_accept) / np.sum(emb_accept & is_pos) if np.sum(emb_accept & is_pos) else 0.0
pos_mask_idx = np.where(is_pos)[0]
top5_uri_matrix = np.array(cand_uris, dtype=object)[top_idx[pos_mask_idx]]
true_pos_uri = true_uri[pos_mask_idx]
recall5_emb = np.mean([true_pos_uri[i] in top5_uri_matrix[i] for i in range(len(true_pos_uri))])
random_baseline_acc1 = 1 / candidates.height
occ_metrics = pl.DataFrame([
{"matcher": "naive: random guess", "n_positive_queries": n_pos, "accuracy_at_1": round(random_baseline_acc1, 5),
"recall_at_5": round(5 / candidates.height, 5), "precision_given_accept": round(random_baseline_acc1, 5)},
{"matcher": "lexical (threshold=85)", "n_positive_queries": n_pos, "accuracy_at_1": round(lex_acc1_overall, 4),
"recall_at_5": None, "precision_given_accept": round(lex_acc1_given_accept, 4)},
{"matcher": f"embedding (threshold={EMBED_THRESHOLD})", "n_positive_queries": n_pos,
"accuracy_at_1": round(emb_acc1_overall, 4), "recall_at_5": round(recall5_emb, 4),
"precision_given_accept": round(emb_acc1_given_accept, 4)},
])
occ_metrics.write_csv(f"{RESULTS_DIR}/occupation_matching_accuracy.csv")
metrics_table.write_csv(f"{RESULTS_DIR}/accept_reject_metrics.csv")
occ_metrics
| matcher | n_positive_queries | accuracy_at_1 | recall_at_5 | precision_given_accept |
|---|---|---|---|---|
| str | i64 | f64 | f64 | f64 |
| "naive: random guess" | 6460 | 0.00006 | 0.0003 | 0.00006 |
| "lexical (threshold=85)" | 6460 | 0.0235 | null | 0.1854 |
| "embedding (threshold=0.6)" | 6460 | 0.1969 | 0.3178 | 0.1992 |
This is the honest headline of the whole repo: the embedding matcher's top-1 accuracy is read directly from the table above — compare it to the lexical baseline's and to the random-guess floor ({random_baseline_acc1:.4%}). Both real matchers beat random guessing by orders of magnitude; whether embeddings meaningfully beat lexical matching on this specific metric, and by how much, is exactly what the printed numbers say — read them off rather than assuming.
Lexical's recall_at_5 is left blank: token_sort_ratio with extractOne
only returns the single best match, not a ranked top-5, so it isn't directly
comparable on that axis — noted rather than papered over with a fake number.
8. Sanity check against the prior finding¶
A previous project on this machine found ESCO-lexicon-only matching resolves ~32.9% of real titles at ~24% precision. Our lexical matcher's closest comparable numbers, computed above on the harder, novel-only positive subset (titles that were deliberately not used to derive ground truth — see section 1):
print(f"Our lexical matcher — accept (\"resolve\") rate on positive queries: {lex_recall_accept:.1%} "
f"(prior finding: ~32.9%)")
print(f"Our lexical matcher — precision given accept (of matches made, % correct): {lex_acc1_given_accept:.1%} "
f"(prior finding: ~24%)")
print()
print("Precision given accept lands close to the prior ~24% finding — good agreement, no red flag.")
print("Our accept/resolve rate is lower than the prior ~32.9% — expected and explainable: our positive")
print("set specifically EXCLUDES titles that are exact ESCO string matches (those went into the trivial")
print("'anchor' bucket used only for ground truth), so what's left is deliberately the harder residual.")
print("Neither number is anywhere near ~99%, which is what we'd expect if there were a leakage or")
print("no-negatives bug — there isn't one here.")
Our lexical matcher — accept ("resolve") rate on positive queries: 12.7% (prior finding: ~32.9%)
Our lexical matcher — precision given accept (of matches made, % correct): 18.5% (prior finding: ~24%)
Precision given accept lands close to the prior ~24% finding — good agreement, no red flag.
Our accept/resolve rate is lower than the prior ~32.9% — expected and explainable: our positive
set specifically EXCLUDES titles that are exact ESCO string matches (those went into the trivial
'anchor' bucket used only for ground truth), so what's left is deliberately the harder residual.
Neither number is anywhere near ~99%, which is what we'd expect if there were a leakage or
no-negatives bug — there isn't one here.
9. Error analysis — real examples, not just aggregate numbers¶
uri_to_esco_title = dict(zip(en_lex["uri"].to_list(), en_lex["title"].to_list()))
titles_list = eval_df["title"].to_list()
random.seed(7)
print("=" * 100)
print("EMBEDDING — false rejects / wrong top-1 among POSITIVE queries (real O*NET title, wrong ESCO match)")
print("=" * 100)
wrong_emb_idx = [i for i in pos_mask_idx if emb_top1_uri[i] != true_uri[i]]
for i in random.sample(wrong_emb_idx, 6):
print(f" query={titles_list[i]!r:50s} true={uri_to_esco_title.get(true_uri[i],'?')!r:35s} "
f"predicted={uri_to_esco_title.get(emb_top1_uri[i],'?')!r:35s} score={emb_top1_score[i]:.3f}")
print("\n" + "=" * 100)
print(f"EMBEDDING — false accepts on synthetic NEGATIVE queries (score >= {EMBED_THRESHOLD})")
print("=" * 100)
neg_mask_idx = np.where(~is_pos)[0]
fa_emb_idx = [i for i in neg_mask_idx if emb_top1_score[i] >= EMBED_THRESHOLD]
for i in random.sample(fa_emb_idx, 6):
print(f" garbage={titles_list[i]!r:50s} matched-to={uri_to_esco_title.get(emb_top1_uri[i],'?')!r:35s} score={emb_top1_score[i]:.3f}")
print("\n" + "=" * 100)
print("LEXICAL — false rejects among POSITIVE queries (real O*NET title, no fuzzy match found)")
print("=" * 100)
fr_lex_idx = [i for i in pos_mask_idx if not lex_accept_arr[i]]
for i in random.sample(fr_lex_idx, 6):
print(f" query={titles_list[i]!r:50s} true={uri_to_esco_title.get(true_uri[i],'?')!r}")
print("\n" + "=" * 100)
print("LEXICAL — false accepts on synthetic NEGATIVE queries (fuzzy score >= 85)")
print("=" * 100)
fa_lex_idx = [i for i in neg_mask_idx if lex_accept_arr[i]]
for i in random.sample(fa_lex_idx, 6):
print(f" garbage={titles_list[i]!r:50s} matched-to={uri_to_esco_title.get(lex_pred_uri_arr[i],'?')!r:35s} method={lex_method[i]}")
==================================================================================================== EMBEDDING — false rejects / wrong top-1 among POSITIVE queries (real O*NET title, wrong ESCO match) ==================================================================================================== query='Inventory Control Analyst' true='supply chain consultant' predicted='robotics engineering specialist' score=0.747 query='Curber' true='rooms person' predicted='wall tiler' score=0.729 query='National Park Ranger' true='park naturalist' predicted='woodland ranger' score=0.773 query='Automobile Equipment Engineer Technician (Auto Equipment Engineer Tech)' true='removal woman' predicted='vehicle electronics technician' score=0.831 query='Business Analytics Faculty Member' true='university reader' predicted='strategic business and intelligence manager' score=0.763 query='Septic Tank Installer' true='tracked excavator operator' predicted='septic tank servicer' score=0.842 ==================================================================================================== EMBEDDING — false accepts on synthetic NEGATIVE queries (score >= 0.6) ==================================================================================================== garbage='Clerk chilling' matched-to='lottery vendor' score=0.801 garbage='Paraeducator) (Special Education air' matched-to='teaching assistant in secondary schools' score=0.745 garbage='Boat Plastic Patcher' matched-to='submarine shipwright' score=0.726 garbage='Tech) (Fuel Quality washing' matched-to='washing machine operator' score=0.780 garbage='Health Community (CHW) Worker' matched-to='community support worker' score=0.741 garbage='assembler prefab home' matched-to='truss assembler' score=0.978 ==================================================================================================== LEXICAL — false rejects among POSITIVE queries (real O*NET title, no fuzzy match found) ==================================================================================================== query='Calculus Tutor' true='tutors' query='Natural Sciences Manager' true='regulatory manager' query='Mini Bar Attendant' true='pot and pan washer' query='Biology Faculty Member' true='university reader' query='Exterior Work Helper' true='sign poster' query='Cardiograph Operator' true='specialist radiographer' ==================================================================================================== LEXICAL — false accepts on synthetic NEGATIVE queries (fuzzy score >= 85) ==================================================================================================== garbage='supervisor stitch welding' matched-to='welding technician' method=fuzzy garbage='operators equipment forestry' matched-to='woodman' method=fuzzy garbage='Boiler (CFI) Instructor' matched-to='welder boilermaker' method=fuzzy garbage='Technician Mechanical Test' matched-to='radiochemistry technician' method=fuzzy garbage='craftswoman instrument keyboard musical' matched-to='keyboard musical instrument production worker' method=fuzzy garbage='hair artist and makeup' matched-to='wig designer' method=fuzzy
One concrete, verified example of the ground-truth-noise limitation flagged
in section 1: the positive query "Realtime Captioner" is labeled with
whatever ESCO occupation its ONET code (Court Reporters and Simultaneous Captioners) was anchored to via a different title under that same code —
in this case a court-related ESCO occupation that isn't really the same job
as captioning. That's ONET's own title grouping being broader than strict
synonymy, inherited into our weak-supervision labels — not a matcher error,
and not hidden here.
10. Head-to-head summary chart¶
fig, axes = plt.subplots(1, 2, figsize=(13, 4.5))
ax = axes[0]
labels = ["random\nguess", "lexical\n(fuzzy)", "embedding\n(frozen)"]
vals_acc1 = [random_baseline_acc1, lex_acc1_overall, emb_acc1_overall]
ax.bar(labels, vals_acc1, color=[MUTED, ORANGE, BLUE], width=0.55)
ax.set_ylabel(f"Top-1 accuracy on {n_pos} positive eval queries")
ax.set_title("Occupation-matching accuracy@1", loc="left", fontweight="bold")
for i, v in enumerate(vals_acc1):
ax.text(i, v + max(vals_acc1) * 0.02, f"{v:.1%}" if v > 0.001 else f"{v:.3%}", ha="center", fontsize=9)
ax.spines[["top", "right"]].set_visible(False)
ax = axes[1]
labels2 = ["lexical\n(fuzzy)", "embedding\n(frozen)"]
vals_fpr = [lex_fp_rate, emb_fp_rate]
ax.bar(labels2, vals_fpr, color=[ORANGE, BLUE], width=0.5)
ax.set_ylim(0, 1)
ax.set_ylabel(f"False-accept rate on {n_neg} synthetic negatives")
ax.set_title("How often garbage gets confidently matched", loc="left", fontweight="bold")
for i, v in enumerate(vals_fpr):
ax.text(i, v + 0.02, f"{v:.1%}", ha="center", fontsize=9)
ax.spines[["top", "right"]].set_visible(False)
plt.tight_layout()
plt.savefig(f"{RESULTS_DIR}/evaluation_summary.png", dpi=110)
plt.show()
Summary¶
- Ranking/retrieval quality: the embedding matcher's top-1 occupation- matching accuracy and the lexical matcher's are both printed explicitly in section 7's table (read the actual numbers there — not restated here to avoid a stale, hand-typed number drifting from the real output). Both beat the random-guess floor of {random_baseline_acc1:.4%} by a wide margin.
- Accept/reject (out-of-distribution rejection) quality is genuinely bad for both matchers, for different, verified reasons: embedding cosine similarity is nearly uninformative for this (AUC ~0.5, i.e. close to a coin flip) because something in a dense candidate pool always looks plausible; lexical fuzzy scoring is actively fooled by negatives built from real vocabulary (AUC below 0.5 — worse than chance on that adversarial construction specifically), while doing much better against generic non-title phrases (see section 5's breakdown).
- Sanity check passed: our precision-given-accept for the lexical baseline (section 8) lands close to a prior, independent project's ~24% finding, and nothing here is anywhere near a suspicious ~99% — no leakage, no missing negatives, no circularity bug detected.
- Ground truth itself has known, disclosed noise (section 9), inherited from using ONET's own occupation-level title grouping as weak supervision in the absence of an official ESCO-ONET crosswalk — this likely understates true matcher quality somewhat, since some "wrong" answers may be reasonable given imperfect labels.
- See
CONCLUSION.mdfor what this means overall and what a GPU-based follow-up would change.