PoPTard5 / model-5 /src /package.py
Jyo-K's picture
Upload folder using huggingface_hub
7c5d3cc verified
Raw History Blame Contribute Delete
14.7 kB
"""Final submission package (problem statement, "Final Submission Package"):
<team>_submission.zip
output/matching_results.tsv, output/candidate_pairs.tsv
code/business_entity_resolution/{src/, scripts/, utils/, README.md, requirements.txt}
Documentation_template.md (filled in from this run's reports)
"""
import json
import os
import shutil
import zipfile
from pathlib import Path
import pandas as pd
from common import get_logger, load_json
from config import CFG, M5, OUTPUT, WORK
log = get_logger("package")
TEAM = os.environ.get("M5_TEAM", "PopTards")
R = WORK / "reports"
def _j(name, default=None):
p = R / name
return load_json(p) if p.exists() else default
def _fmt(x, nd=5):
return f"{x:.{nd}f}" if isinstance(x, (int, float)) else str(x)
def documentation():
bl = _j("blocking_recall.json", {})
k = _j("k_choice.json", {})
s2 = _j("s2set_train.json", {})
b = load_json(WORK / "models" / "stage2_bundle.json")
tr = _j("test_report.json", {})
f1 = _j("f1_select.json", {})
loco = _j("loco.json", {})
ce = {x: _j(f"ce_train_{x}.json", {}) for x in ("m", "b")}
lce = _j("lce_train.json", {})
man = _j("manifest.json", {})
fi = pd.read_csv(R / "feature_importance.tsv", sep="\t").head(15) if (R / "feature_importance.tsv").exists() else None
sub_rows = "\n".join(f"| {s} | {v:.4f} |" for s, v in sorted(bl.get("subsets", {}).get(
"+".join(k.get("retr", [])), {}).items(), key=lambda x: int(x[0]))) if bl else ""
test_rows = "\n".join(f"| {c} | {v['n_s1']:,} | {v['empty_rate']:.2%} | {v['mean_pred']:.2f} | {v.get('pool_per_s1', '')} |"
for c, v in tr.get("test_pred_stats", {}).items())
var_rows = "\n".join(f"| {v} | {x['stack']:.5f} | {x['val_claims']:.5f} | {x['val_plain']:.5f} |"
for v, x in b["ladder"].items())
gates = _j("stage2_gates.json", {})
six = gates.get("six_features_gate", {})
st = gates.get("structure_gate", {})
en = _j("ename_train.json", {})
dec = _j("val_loss_decomposition.json", {})
subs = b.get("count_subsample_test") or {}
fi_rows = "\n".join(f"| {r.feature} | {r.gain:,.0f} |" for r in fi.itertuples()) if fi is not None else ""
loco_rows = "\n".join(f"| {name} | " + " | ".join(f"{c}: {v:.4f}" for c, v in res.items()) + " |"
for name, res in loco.items() if isinstance(res, dict))
return f"""# ML Challenge 2026: Business Entity Resolution Solution
**Team Name:** {TEAM}
**Team Members:** [fill in]
**Submission Date:** {pd.Timestamp.now():%Y-%m-%d}
---
## 1. Executive Summary
A blocking-then-ranking pipeline. Candidates come from exact dense search over three pre-computed multilingual
embeddings (e5-large, bge-m3, e5-small), an IDF-weighted lexical index and a sibling-hop channel, fused with
reciprocal-rank fusion. A LightGBM stage-1 model over ~100 country-agnostic pair features narrows each Source-1
entity to at most {CFG['s2_topn']} candidates; two fine-tuned pairwise cross-encoders (mDeBERTa-v3, bge-reranker-v2-m3)
and a **listwise cross-encoder** that reads the entity and all its candidates in one transformer pass score that
set; a stage-2 model is calibrated and each entity's match set is chosen by **exact expected-F0.5 maximisation**
followed by one-owner arbitration. Every choice (retrieval channels, cap K, feature set, model variant, decision
rule) is made on a held-out *stack* split; validation is reported only.
**Validation macro F0.5 (US + India, {sum(v['n_s1'] for v in b['val_pred_stats'].values()):,} held-out Source-1 entities): {b['val_f05']:.5f}**
with competitor claims ({b['val_f05_plain']:.5f} without), by country
{', '.join(f'{c}: {v:.5f}' for c, v in b['val_f05_by_country'].items())}. Stack out-of-fold F0.5 (selection split): {b['stack_f05']:.5f}.
---
## 2. Methodology
### 2.1 Problem Analysis
- Train: US + India; test adds France (15% of test S1), which has no labels. Country is treated as an open set:
it only partitions retrieval by string equality and is never a model feature.
- Noise: abbreviations and legal-form variants (`Pvt Ltd` / `Private Limited`, `SARL`, `SAS`), transliteration and
accent corruption, typos, word-order swaps, DBA/trade names, partial or reordered addresses, house-number decoys.
- Ground truth: {man.get('ground_truth', {}).get('n_matched', 'n/a'):,} true pairs; no Source-2/3 record belongs to two
Source-1 entities ({man.get('ground_truth', {}).get('multi_owner', 'n/a')} multi-owner records), which makes
one-owner arbitration valid. Training singleton rate {tr.get('train_singleton_rate', float('nan')):.2%}, mean
{tr.get('train_mean_true', float('nan')):.2f} true matches per entity.
### 2.2 Solution Strategy
**Approach Type:** Hybrid — multi-channel blocking + gradient-boosted ranking + pairwise and listwise transformer
cross-encoders + expected-F0.5 set selection.
**Core Innovation:** a listwise cross-encoder (every candidate attends to the query and to every other candidate,
so decoys and sibling copies are resolved jointly), exact expected-F0.5 set selection with a missed-match term,
and label-free, country-invariant features chosen by leave-one-country-out validation for the unseen country.
Splits (seed {CFG['seed']}, disjoint Source-1 groups): ce {CFG['n_ce']:,} (pairwise cross-encoders), lce {CFG['n_lce']:,}
(listwise model), stack {CFG['n_stack']:,} (all model and rule selection), val {CFG['n_val']:,} (reporting only).
---
## 3. Candidate Generation (Blocking)
- **Blocking keys used:** exact cosine top-{CFG['dense_k']} per country over e5-large (1024-d), bge-m3 (1024-d) and
e5-small (384-d) pre-computed embeddings (no embeddings were computed by this pipeline); an IDF-weighted sparse
lexical index (name tokens, 5-char name prefix, street tokens, locality tokens, house-number+street composites,
postcodes, alphanumeric unit tokens) top-{CFG['lex_k']}; a sibling hop (each entity's top-{CFG['hop_seed']} e5 neighbours'
{CFG['hop_nn']} nearest pool records). Retrieval is exact (GPU matrix products), never approximate.
- **Fusion:** reciprocal-rank fusion (k0={CFG['rrf_k0']}) decides survival; each retriever's score, rank and gap stay
separate features. Optional channels are kept only if they add >= 0.1 pt recall on stack entities; kept:
**{', '.join(k.get('retr', []))}**; cap **K = {k.get('K')}** (smallest K reaching {CFG['k_recall_frac']:.1%} of recall@{CFG['k_max']}).
- **Recall of the fused list on stack entities (true pairs retrieved / all true pairs):**
| K | recall |
|---|---|
{sub_rows}
- **`ename` channel:** character 3-gram TF-IDF (`char_wb`, sublinear tf) name-only search over *empty-address* pool
records per country, top 5, added **after** the K cut as a guaranteed union and always passed to stage 2 (empty
addresses are almost never distractors, but name+address retrievers miss their noisy-name copies). Precision of
its candidates on stack/val: {en.get('recall', {}).get('precision', float('nan')):.3f}; true copies found only by it:
{en.get('recall', {}).get('true_not_in_fused_topK', 'n/a')}.
- **Stage-2 candidate set** (= `candidate_pairs.tsv`, the exact set the final model scores): stage-1 top {CFG['s2_topn']}
with p >= {CFG['s2_min_prob']}, plus every `ename` candidate; it keeps {s2.get('s2_recall_vs_union', float('nan')):.2%} of blocking's true pairs
({s2.get('s2_recall_vs_total', float('nan')):.2%} of all true pairs). Test: {tr.get('n_candidate_pairs', 0):,} candidate pairs.
- **How true matches were not lost:** three dense models with different failure modes plus a lexical channel that
works on empty/short addresses, the hop channel for scrambled-name entities whose sibling records are close, and a
stage-1 cap chosen from measured recall.
---
## 4. Matching Model
**Features used** (all computed from the provided text only; no dictionaries or external data):
- Name: ratio, Jaro-Winkler, Levenshtein, token-sort/set, partial, full-name ratio, no-space ratio/partial,
abbreviation- and typo-aware token coverage both ways, token Jaccard, unmatched-token counts, containment (DBA),
acronym match, first-token match, legal-form state (legal forms discovered per country **without labels**).
- Address: full-address ratio/token-set/partial/Jaccard, street coverage both ways, locality coverage, house-number
state (equal / suffix / range / conflict) and distance, numeric-set Jaccard, postcode and unit-token states.
- Retrieval/context: per-retriever score/rank/gap, RRF score/rank, support count, exact e5/bge/e5-small cosines,
within-query rank/gap/z-score, **per-country standardised cosines** (label-free statistics of random same-country
pairs), S2<->S3 triangle corroboration, candidate count.
- F1 selection (leave-one-country-out US<->India): chosen feature set **{f1.get('choice', 'n/a')}**; scores {json.dumps(f1.get('scores', {}))}.
**Model type:**
1. Stage 1: LightGBM ({CFG['n_folds']} grouped folds on stack), test scoring on the GPU with an exact tree evaluator.
2. Pairwise cross-encoders fine-tuned on the ce split: `microsoft/mdeberta-v3-base` (MIT, 278M; dev AUC
{_fmt(ce['m'].get('dev_auc'))}) and `BAAI/bge-reranker-v2-m3` (Apache-2.0, 568M; dev AUC {_fmt(ce['b'].get('dev_auc'))}).
3. Listwise cross-encoder: the fine-tuned bge-reranker backbone reads `[CLS] entity [SEP] [CAND] c1 [SEP] ...`
for up to {CFG['lce_max_cand']} candidates; per-candidate match head + "no listed match" head; source and rank
biases; candidate order shuffled in training, two orders averaged at inference ({lce.get('lists', 'n/a')} training lists).
4. Stage 2: LightGBM over the stage-2 set with the six **name-sharing count features** (how many Source-1 entities in
the country share this entity's / this candidate's normalised name, exact-name flags, empty-address flag; counted
over the split's whole S1 table, label-free), **structure/competitor features** (reverse search over *all* S1:
rank of this S1 among the record's nearest S1, gap to the best other S1; name-is-another-S1's, sibling agreement),
both cross-encoders, and the listwise scores. A gated ladder chosen on stack (with every stack+val entity
competing in arbitration, as on test):
| configuration | stack F0.5 (selection) | val F0.5 with claims | val F0.5 plain |
|---|---|---|---|
{var_rows}
Gates: six features {six.get('stack_gain', float('nan')):+.5f} (required +{six.get('required', 0.002)}); structure
{st.get('stack_gain', float('nan')):+.5f} (required +{st.get('required', 0.0005)}); listwise model kept only if +0.0015 over its base.
**Chosen:** `{b['config']}`, decision `{b['family']}` (param {b['thr']}).
**Threshold selection method:** none fixed — stage-2 scores are isotonic-calibrated (cross-fitted on stack), then for
each entity the subset size maximising the **exact expected F0.5** (Poisson-binomial dynamic programme; optional
Poisson term for true matches outside the candidate set, rate {b.get('miss_rate', float('nan')):.4f}) is chosen and
compared with a threshold grid on stack. One-owner arbitration then iterates to a fixpoint (the higher-probability
claim keeps a contested record, the loser re-decides without it).
Top stage-2 features by gain:
| feature | gain |
|---|---|
{fi_rows}
---
## 5. Results & Error Analysis
- **F_0.5 Score (macro), validation:** {b['val_f05']:.5f} ({', '.join(f'{c}: {v:.5f}' for c, v in b['val_f05_by_country'].items())})
- **Test predictions per country** (label-free checks, README F3):
| country | S1 | predicted empty | mean matches | pool records per S1 |
|---|---|---|---|---|
{test_rows}
- Shift flags: {tr.get('country_shift_flags') or 'none'}
- Model's own expected F0.5 on test by country (calibrated probabilities, label-free): {tr.get('expected_f05_test')};
on val the same estimate vs the actual score: {b.get('val_expected_vs_actual_by_country')}
- Val loss decomposition (F0.5 recovered if that bucket were fixed): {dec.get('f05_lost_by_bucket')}
(`fp_owner_not_scored` is handled by test-time arbitration)
- Count features on a France-sized (~260k) S1 table: val change {subs.get('delta', float('nan')):+.5f}
- **Leave-one-country-out**, leak-free (every component trained on one country; F1 selection): {json.dumps(f1.get('detail', {}))}
- Leave-one-country-out with the stage-2 model (**upper bounds**: stage-1 scores and cross-encoders saw both
countries), train on one country's stack, F0.5 on each country's val:
| model | result |
|---|---|
{loco_rows}
---
## 6. Other information
- Models: all MIT / Apache-2.0 and <= 568M parameters (limit 8B). No external data, APIs, geocoding or dictionaries.
- Compute: 2 x RTX PRO 6000 (shared). Every stage checkpoints (sharded atomic outputs, resumable training, claimed
shard queues across GPUs), so the run survives crashes, OOMs from other users and SSH drops.
- Reproduction: see `code/business_entity_resolution/README.md`.
"""
def run_package():
stage = WORK / "package"
if stage.exists():
shutil.rmtree(stage)
code = stage / "code" / "business_entity_resolution"
(stage / "output").mkdir(parents=True)
code.mkdir(parents=True)
for f in ("matching_results.tsv", "candidate_pairs.tsv"):
shutil.copy2(OUTPUT / f, stage / "output" / f)
shutil.copytree(M5 / "src", code / "src", ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
shutil.copytree(M5 / "scripts", code / "scripts")
shutil.copytree(M5 / "utils", code / "utils")
shutil.copy2(M5 / "docs" / "PACKAGE_README.md", code / "README.md")
shutil.copy2(M5 / "requirements.txt", code / "requirements.txt")
doc = documentation()
(stage / "Documentation_template.md").write_text(doc)
(R / "Documentation.md").write_text(doc)
zp = M5 / f"{TEAM}_submission.zip"
tmp = zp.with_suffix(".zip.tmp")
with zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as z:
for p in sorted(stage.rglob("*")):
if p.is_file():
z.write(p, p.relative_to(stage))
os.replace(tmp, zp)
with zipfile.ZipFile(zp) as z:
bad = z.testzip()
names = z.namelist()
assert bad is None, f"corrupt zip member {bad}"
for need in ("output/matching_results.tsv", "output/candidate_pairs.tsv", "Documentation_template.md",
"code/business_entity_resolution/README.md", "code/business_entity_resolution/requirements.txt",
"code/business_entity_resolution/src/run_pipeline.py"):
assert need in names, f"{need} missing from the package"
shutil.rmtree(stage)
log.info(f"package written: {zp} ({zp.stat().st_size / 1e6:.0f} MB, {len(names)} files)")