← Writing

29 October 2026, 12 min read

How Team Minutes finished 38th of 32,000+ at the Amazon ML Challenge 2026

The engineering behind a 0.990 macro F0.5 entity-resolution solution: validation design, retrieval, a LightGBM and cross-encoder stack, exact expected-F0.5 decisions and label-free rules for France.

  • Entity resolution
  • Bi-encoder retrieval
  • Cross-encoder reranking
  • LightGBM stacking
  • Out-of-fold validation
  • LoRA fine-tuning (Qwen2.5-7B)
  • Expected-metric decoding
  • Paired bootstrap tests
  • Label-free rule mining

In September 2026 my team, Minutes, finished 38th out of 32,000+ teams at the Amazon ML Challenge. We climbed from 0.921 to 0.990 macro F0.5 on the public leaderboard, and Amazon invited us to its Applied Scientist Intern interview. I led the four of us, all from GCET, and this is the engineering story: the problem, what we built, the experiments that moved the score, and what I’m taking into the next project.

If you only read one section, make it the one on validation. It decided the result more than any model did.

The problem

Three sources of business records, each with a name, an address and a country, and no shared identifiers. Source 1 is a clean reference list. Sources 2 and 3 hold noisy copies of those businesses, mixed with decoys. For every Source 1 business, list its copies. The score is F0.5 averaged per business, so a wrong match costs about three times as much as a missed one, and a business with no copies is worth getting right too.

The training set has 2,206,821 businesses, 10,320,219 Source 2 and 3 records and 7,638,365 true pairs, all from the US and India. The test set has 1,732,544 businesses and adds France, with no labels at all: 259,452 of those businesses are French.

The noise is rich. Names change case and accents, gain brackets, swap legal forms (Inc, Pvt Ltd, SARL), reorder words, collect typos, turn into initials or web handles, or arrive in Devanagari, Bengali, Telugu, Tamil or Kannada. Addresses lose PIN codes, abbreviate “Road” and “Rue”, and mistype house numbers. The decoys are built to look right: “Uptown Diner” next to “Uptown Diner Southside”, or in France a sibling at the business’s own address where the distinctive word is swapped for another real one (“Lille Club SARL” becomes “Lille Amicale SARL”).

Each Source 1 business has true copies and look-alike decoys in Sources 2 and 3 Source 1 (reference)Sources 2 and 3 (noisy) Uptown Diner Uptown Dinermatch: a true copy Uptown Diner Southsidedecoy: not a copy Lille Club SARL Lille Amicale SARLsibling decoy
Decoys are built to look right, so the model has to tell a true copy from a near-identical sibling.

Start from the record’s side

Every Source 2 or 3 record belongs to at most one business, and that holds across all 7,638,365 training pairs. So instead of asking “what are this business’s copies?”, we ask each record “which business are you, if any?” and group the answers. That reframing turns the task into candidate search plus classification, and it let us run country by country.

The first version, one TF-IDF search and one LightGBM, scored 0.921. A second version with three searches and a pruning cascade reached 0.951. Then the interesting part started.

Validation is where the score came from

The third version added collective features: how strongly other records already claim the same business. On our validation set it gained +0.006. On the leaderboard it dropped, from 0.951 to 0.948.

I took that as a measurement problem, not a modeling one. The test set had more unmatched records than training: 5.5 to 5.8 records per business against 4.7. A model that is rewarded for merging records into a business will look good on training-like data and hurt you on test data that holds more decoys.

Training Test Training, 19% hidden
Records per business 4.7 5.5 to 5.8 5.77
Records matching nothing 26% about 40% 40%

So we reshaped validation to look like test, in four moves.

  1. Split by business, never by pair. A business joins validation when hash(entity_id, seed 42) % 10 == 0, and all its records go with it. A second hash splits validation into half A, which makes every choice, and half B, which is only ever used to report.
  2. Match the test density. We hid 19% of Source 1 businesses from the candidate pool. Their records stay in the data but can no longer be matched, which reproduces the test set’s share of unmatched records.
  3. Match the test difficulty. The unmatched records in test look more like decoys, so we chose the decision rule on a stricter metric that counts every false match from an unmatched record three times.
  4. Compare with a paired bootstrap. Every change was tested against its predecessor on the same 89,000 half-B businesses, which gives a confidence interval on the difference instead of one number.
Validation is split by business, so one business never sits on both sides, and only half B reports Split by pair trainingvalidation One business lands on both sides. Split by business All records of a business move together. Validation: hash(entity_id, seed 42) % 10 == 0 Half Amakes every choice Half Bonly ever reports
Grouped by business, and reported on a half that never made a choice, validation predicted the test score of 0.9913 exactly.

Later we noticed something sharper. Empty-address records, which are almost always genuine copies, occur at the same rate per business in training and test (US 0.168 and 0.1665, India 0.139 and 0.138). So the extra test records are decoys with addresses, not copies of businesses missing from Source 1. We built a “test-like” validation without the records that exist only because of the hiding, and used it for the final calibration.

The payoff came near the end. A diagnostic submission scored our US and India predictions at 0.9913 on the test set, exactly the value validation half B had predicted. From then on, offline numbers were something I could act on.

Finding candidates cheaply

Amazon’s email listed what the ranking rewarded: private-leaderboard performance first, then blocking strategy and ML novelty, then candidate efficiency, where fewer candidates per record scored higher. That matched how we already worked. Everything downstream is expensive, so we spent effort making the candidate list short and complete.

For each record, inside its country, we pooled three TF-IDF searches (name plus address, address only, name only, widened when the address is empty) with a fine-tuned multilingual-e5-small bi-encoder over the raw “name | address” text, which reads Indian scripts natively. Normalisation transliterates Indian scripts and builds consonant skeletons, so “laaiph investtmentt” and “Life Investment” both become “lf inbstmnt”.

One TF-IDF search kept 95.5% of true pairs. All searches plus the bi-encoder keep 98.9%. A small LightGBM then prunes weak candidates, and the cutoff is a trade I could measure on held-out businesses:

Prune below Candidates per business True pairs kept
0.001 6.27 98.93%
0.01 (used) 4.82 98.75%
0.02 4.42 98.59%
0.05 4.00 98.21%

On the test set that left 9,365,329 pairs, 5.4 per business, out of about 26 per record before pruning.

Ranking: features, a cascade and seven cross-encoders

Each surviving pair gets 57 features: RapidFuzz ratios and Jaro-Winkler on names and consonant skeletons, TF-IDF and bi-encoder cosines, house-number and postcode agreement, retrieval ranks, and the legal-form relation. A useful family describes competition: how much probability other records already place on the same business, and how close the runner-up is.

Those group features need out-of-fold probabilities to avoid leaking, so the model is a three-stage LightGBM cascade. Stage 1 uses two out-of-fold models. Stage 2 adds group features computed from stage-1 probabilities, and stage 3 recomputes them from stage 2. The final settings are a learning rate of 0.02, 383 leaves and early stopping on half A.

For the uncertain zone we fine-tuned seven cross-encoders: multilingual-e5 in small, base (two runs) and large, two bge-reranker-v2-m3 models and Qwen3-Reranker-0.6B. Each trained on 0.7 to 1.2 million pairs from records that never touch a validation business, so the stacker later learns from honest scores. The best single model reached a validation log-loss of 0.144. A LightGBM stacker, cross-fitted on half A with two folds by business, merges every cross-encoder logit with the cascade output and the raw pair similarities.

The BizMatch pipeline from raw records to the matching file, in ten stepsRaw records~10M noisy records: name, address,countryNormaliseTransliteration, abbreviations,consonant skeletonsCandidate search3 TF-IDF searches + fine-tuned e598.75% recallPair features and pruning57 pair featuresstage-A LightGBM drops pairs below 0.01LightGBM cascade3 out-of-fold stages withcompeting-claim group featuresCross-encoders7 models re-read the uncertain zone(e5, bge, Qwen3)StackerCross-fitted LightGBM merges every scoreDecisionExact expected F0.5 per business(Poisson-binomial DP)Layers on topFrance rules · LoRA-judge recalibration3,744 additionsmatching_results.tsv5,866,818 matches over1,732,544 businesses
The pipeline as a funnel. Each step removes work for the next one, and every stage writes Parquet so it can resume on another machine.

The decision is an optimisation, not a threshold

F0.5 is computed per business, so one global cut-off cannot be right for both a business with one sure copy and one with five likely ones. Instead, each record goes to its most probable business. For one business, with its assigned records sorted by probability, selecting the top k scores

F0.5 = 1.25·TP / (1.25·TP + FP + 0.25·FN)      FP = k - TP

where TP follows a Poisson-binomial distribution over the first k probabilities and FN one over the rest. An empty prediction scores 1 only when the business truly has no copies, which happens with probability ∏(1 - p). A dynamic program computes the exact expected F0.5 for every k, and we keep the best. For independent probabilities the best subset is always a prefix of the sorted list, so this is exact, not a heuristic. Probabilities are sharpened with an exponent of 1.5, chosen on half A, and leaderboard probes on either side confirmed it. We cap each business at 5 Source 2 and 6 Source 3 matches, mirroring the generator.

A wrong match costs about three times a missed copy, so each business keeps the top k records by expected F0.5 Wrong matchcosts about three times Missed copythe lighter cost One business: its records, most probable first keep the top kleave out the rest k is the cut with the best expected F0.5,computed exactly by dynamic programming
A wrong match costs about three times a miss, so each business keeps the prefix of its sorted records that maximises expected F0.5.

France, with no labels

The US and India were at 0.9913. France was where the room was, at 0.953. With no French labels we studied how the generator builds French records, and found two label-free witnesses:

  • A cap test. Copies are capped per business and source, so a class of genuine copies disappears at businesses whose other records already fill the cap, while decoys keep appearing there.
  • A formatting marker. Copy noise puts the changed word in brackets for 4.2% of French copies and only 0.9% of decoys, and the model’s probabilities don’t depend on brackets, so the marker is an independent witness.

Using both, we removed same-address one-word swaps to another French vocabulary word (bracket share 0.8%, the sibling decoys) and kept swaps to a misspelling or a generic word like Fils, Cie or Associés (bracket share 6.0%, the signature of copies). The rules lifted our score by 0.0039, in line with what the markers predicted, and moved France from 0.953 to about 0.979.

The bracket marker is far more common on French copies than on decoys, which separates them without labels Share of records with the changed word in brackets French copies4.2% French decoys0.9% Swaps to a misspelling or generic word (kept)6.0% Swaps to another French word (removed)0.8%
Copies bracket the changed word far more often than decoys do, so the rules kept the swaps that look like copies and removed the sibling decoys.

A recalibrator built like the test set

With the test-like validation in hand, we trained a residual LightGBM on the uncertain zone. Its inputs were the production probability, generator-operation features (legal-form relation, accents, brackets, word swaps, typo similarity), label-free “orphan” features, and the mean logit of two Qwen2.5-7B judges fine-tuned with LoRA on 400,000 training pairs each. On test-like half B it added +0.00037 (95% CI [+0.00025, +0.00050]) and stayed positive when we doubled the decoys. That run scored 0.990162, which is the best public-leaderboard score we reached.

Results

Public leaderboard macro F0.5 at eight stages, from 0.921 to 0.990162One TF-IDF search, one LightGBM0.921Three searches, pruning cascade0.951Collective features (validation said +0.006)0.948Test-like validation, bi-encoder0.965Cross-encoders and a stacker0.984Seven cross-encoders0.985628Generator-aware France rules0.989496Test-like recalibration, LoRA judges0.990162Bars start at 0.9, so length shows the gain, not a ratio.
Public-leaderboard macro F0.5 at each stage. The third bar is the lesson: a change that gained on validation lost on the leaderboard, so the next step was to rebuild validation.

The final file adds 3,744 high-precision matches to that run. Each one had to be a record the calibrated model had not already scored, so every addition brought new information instead of overriding a judgement. Some came from structural rules measured on training truth: one-token names at an address that belongs to a single business (98% to 100% correct), initials and web handles on the same street (95% to 100%), and Indic-script names matched through a transliteration dictionary learned from training pairs (99%). The rest came from learned tie-breakers at 88% to 98% precision in the bands we used.

The submission holds 5,866,818 matches over 1,732,544 businesses, 3.39 per business against 3.46 true copies in training, with 99,324 businesses (5.7%) left empty against 5.6% in training. For every class of name change, accepted pairs per business matched true copies per business in training at ratios between 0.99 and 1.02.

Built on free compute

None of us had sponsored compute, so we assembled it: AWS EC2 on promotional credits for the pipeline and the stack, Kaggle’s 2×T4 and a Modal H100 for cross-encoders, Lightning AI for the LoRA judges and a laptop RTX 4060 for the bi-encoder. Every stage writes Parquet artifacts that another machine can pick up, long CPU jobs ran on spot instances under a watchdog that resumes from checkpoints, and each GPU job is a small script that uploads inputs and returns only scores. When AWS blocked our account on 26 September, a day before we submitted, that structure kept us moving. Every model is MIT or Apache-2.0 and at most 8B parameters, and we used only the provided data.

What I am taking with me

  • Build the validation set first. Reshape it to match test before trusting any improvement.
  • Study how the data was made. After the core model worked, the biggest gains came from questions about the generator: how many unmatched records test holds, what they look like, and how a French copy gets written.
  • Optimise the metric at the level it is scored. Expected F0.5 per business beat every threshold I could tune.
  • Give rules to the model, not the last word. The French variants that fed the model beat the ones that overrode it.
  • Make every stage resumable. It turned a blocked account from a crisis into a detour.

The code, the reproduction steps and a methodology document are in the BizMatch repository. For a shorter summary of the project, see the case study.

HomeProjectsExperienceOpen sourceResearchAchievementsWritingCertifications
GitHubLinkedInLeetCodeCodeChef
↑↓ move↵ open or askesc close