vazish/query_norm
0
1"""2Query Normalization Benchmark3==============================4Benchmarks multiple normalization approaches on the generated dataset.5 6Normalizers:7 1. Identity - baseline, no change8 2. PySpellChecker - token-by-token spell correction (current approach)9 3. SymSpell - faster, supports compound word correction10 4. Rules - regex + entity canonicalization (flight IDs, stock tickers, product spacing)11 5. RapidFuzz - fuzzy brand name matching12 6. Combined - Rules → SymSpell → RapidFuzz pipeline13 --- ML ---14 7. ContextualSpellCheck - spaCy pipeline with BERT contextual embeddings15 8. T5SpellCorrector - HuggingFace T5 fine-tuned for spelling correction16 9. CombinedML - Rules → T5 pipeline (entity rules first, T5 for the rest)17 18Metrics (per normalizer, per category):19 exact_match - % where output == canonical (case-insensitive)20 cer - character error rate: edit_dist / max(len_pred, len_gold)21 wer - word error rate: token-level edit distance / n_gold_tokens22 no_change_precision - on no_change rows: % correctly left unchanged23 over_correction - on no_change rows: % wrongly changed24 latency_mean_ms - mean per-query latency25 latency_p50_ms - p50 latency26 latency_p95_ms - p95 latency27 latency_p99_ms - p99 latency28 29Usage:30 pip install -r requirements.txt31 python3 benchmark.py [--dataset dataset.csv]32"""33 34import re35import sys36import time37import argparse38import warnings39import numpy as np40import pandas as pd41from pathlib import Path42from abc import ABC, abstractmethod43from typing import Optional44 45warnings.filterwarnings("ignore")46 47# ── Optional imports ───────────────────────────────────────────────────────────48 49try:50 from Levenshtein import distance as _lev51 def edit_distance(a: str, b: str) -> int: return _lev(a, b)52except ImportError:53 # Pure-python fallback54 def edit_distance(a: str, b: str) -> int:55 m, n = len(a), len(b)56 dp = list(range(n + 1))57 for i in range(1, m + 1):58 prev = dp[:]59 dp[0] = i60 for j in range(1, n + 1):61 dp[j] = prev[j - 1] if a[i-1] == b[j-1] else 1 + min(prev[j], dp[j-1], prev[j-1])62 return dp[n]63 64try:65 from spellchecker import SpellChecker as _SC66 HAS_PYSPELL = True67except ImportError:68 HAS_PYSPELL = False69 print("Warning: pyspellchecker not installed — skipping PySpell normalizer")70 71try:72 from symspellpy import SymSpell as _SS, Verbosity as _V73 import pkg_resources74 HAS_SYMSPELL = True75except ImportError:76 HAS_SYMSPELL = False77 print("Warning: symspellpy not installed — skipping SymSpell normalizer")78 79try:80 from rapidfuzz import process as _rf_process, fuzz as _rf_fuzz81 HAS_RAPIDFUZZ = True82except ImportError:83 HAS_RAPIDFUZZ = False84 print("Warning: rapidfuzz not installed — skipping RapidFuzz normalizer")85 86try:87 import spacy as _spacy88 import contextualSpellCheck as _csc89 _csc_nlp = _spacy.load("en_core_web_sm")90 _csc.add_to_pipe(_csc_nlp)91 HAS_CONTEXTUAL = True92except Exception:93 HAS_CONTEXTUAL = False94 print("Warning: contextualSpellCheck/spacy not available — skipping ContextualSpellCheck normalizer")95 print(" Install: pip install contextualSpellCheck && python -m spacy download en_core_web_sm")96 97try:98 from transformers import pipeline as _hf_pipeline99 HAS_TRANSFORMERS = True100except ImportError:101 HAS_TRANSFORMERS = False102 print("Warning: transformers not installed — skipping T5 normalizer")103 print(" Install: pip install transformers torch")104 105# ── Brand list for fuzzy matching ──────────────────────────────────────────────106 107BRANDS = [108 "amazon", "google", "facebook", "twitter", "instagram", "youtube",109 "linkedin", "reddit", "netflix", "spotify", "microsoft", "adobe",110 "dropbox", "github", "slack", "zoom", "paypal", "ebay", "walmart",111 "target", "best buy", "new york times", "bbc", "cnn", "espn",112 "gmail", "outlook", "yahoo", "apple", "samsung", "dell", "hp",113 "lenovo", "asus", "acer", "toshiba", "sony", "lg", "panasonic",114 "booking.com", "expedia", "airbnb", "tripadvisor", "yelp",115 "doordash", "ubereats", "grubhub", "lyft", "uber",116 "twitch", "discord", "telegram", "whatsapp", "snapchat", "tiktok",117]118 119# ── Entity lists for rules normalizer ──────────────────────────────────────────120 121# Common IATA codes (2-3 letter airline codes)122IATA_CODES = {123 "AA", "BA", "DL", "UA", "LH", "AF", "EK", "QR", "SQ", "CX",124 "VS", "KL", "IB", "TK", "AC", "QF", "NH", "JL", "MH", "TG",125 "AI", "SA", "ET", "KE", "OZ", "CI", "BR", "LA", "AV", "AM",126 "WN", "B6", "AS", "F9", "NK", "G4", "VX", "HA",127}128 129# Common stock tickers → company name aliases130STOCK_ALIASES: dict[str, list[str]] = {131 "AAPL": ["apple", "aapl"],132 "TSLA": ["tesla", "tsla"],133 "MSFT": ["microsoft", "msft"],134 "GOOGL": ["google", "alphabet", "googl"],135 "AMZN": ["amazon", "amzn"],136 "META": ["meta", "facebook", "fb"],137 "NVDA": ["nvidia", "nvda"],138 "NFLX": ["netflix", "nflx"],139 "PYPL": ["paypal", "pypl"],140 "SNAP": ["snapchat", "snap"],141 "AMD": ["amd"],142 "INTC": ["intel", "intc"],143 "UBER": ["uber"],144 "LYFT": ["lyft"],145 "ABNB": ["airbnb", "abnb"],146 "COIN": ["coinbase", "coin"],147 "HOOD": ["robinhood", "hood"],148}149 150# Reverse map: alias → ticker151_ALIAS_TO_TICKER: dict[str, str] = {}152for ticker, aliases in STOCK_ALIASES.items():153 for alias in aliases:154 _ALIAS_TO_TICKER[alias.lower()] = ticker155 156# Product model patterns: brand → canonical prefix157PRODUCT_BRANDS = ["iphone", "samsung", "macbook", "ipad", "pixel", "surface"]158 159# ── Base normalizer ────────────────────────────────────────────────────────────160 161class Normalizer(ABC):162 name: str163 164 def warmup(self) -> None:165 """Called once before benchmarking to initialize any lazy state."""166 pass167 168 @abstractmethod169 def normalize(self, query: str) -> str:170 ...171 172 def normalize_batch(self, queries: list[str]) -> list[str]:173 return [self.normalize(q) for q in queries]174 175 176# ── 1. Identity (baseline) ────────────────────────────────────────────────────177 178class IdentityNormalizer(Normalizer):179 name = "Identity (baseline)"180 181 def normalize(self, query: str) -> str:182 return query183 184 185# ── 2. PySpellChecker ────────────────────────────────────────────────────────186 187class PySpellNormalizer(Normalizer):188 name = "PySpellChecker"189 190 def __init__(self):191 if not HAS_PYSPELL:192 raise RuntimeError("pyspellchecker not installed")193 self._sc = _SC()194 195 def normalize(self, query: str) -> str:196 words = query.lower().split()197 return " ".join(self._sc.correction(w) or w for w in words)198 199 200# ── 3. SymSpell ───────────────────────────────────────────────────────────────201 202_ORCAS_VOCAB = Path(__file__).parent / "orcas_vocab.txt"203 204 205class SymSpellNormalizer(Normalizer):206 name = "SymSpell"207 208 def __init__(self, max_edit_distance: int = 2):209 if not HAS_SYMSPELL:210 raise RuntimeError("symspellpy not installed")211 self._sym = _SS(max_dictionary_edit_distance=max_edit_distance)212 # Try importlib.resources first (works in newer Python/packaging setups),213 # fall back to pkg_resources for older environments.214 _dict_loaded = False215 # Try candidate dictionary filenames (name changed across symspellpy versions)216 _DICT_CANDIDATES = ["frequency_dictionary_en_82_765.txt", "en-80k.txt"]217 try:218 import importlib.resources as _ir219 for _fname in _DICT_CANDIDATES:220 try:221 _ref = _ir.files("symspellpy").joinpath(_fname)222 with _ir.as_file(_ref) as _dp:223 _dict_loaded = self._sym.load_dictionary(str(_dp), term_index=0, count_index=1)224 if _dict_loaded:225 break226 except Exception:227 pass228 except Exception:229 pass230 if not _dict_loaded:231 for _fname in _DICT_CANDIDATES:232 _dp = pkg_resources.resource_filename("symspellpy", _fname)233 _dict_loaded = self._sym.load_dictionary(_dp, term_index=0, count_index=1)234 if _dict_loaded:235 break236 if _ORCAS_VOCAB.exists():237 self._sym.load_dictionary(str(_ORCAS_VOCAB), term_index=0, count_index=1)238 self.name = "SymSpell+ORCAS"239 self._max_ed = max_edit_distance240 241 def normalize(self, query: str) -> str:242 # Use lookup_compound for multi-token correction243 suggestions = self._sym.lookup_compound(244 query.lower(), max_edit_distance=self._max_ed245 )246 if suggestions:247 return suggestions[0].term248 return query.lower()249 250 251# ── 4. Rules (entity + regex) ────────────────────────────────────────────────252 253class RulesNormalizer(Normalizer):254 name = "Rules (entity + regex)"255 256 # Flight: digits + IATA or IATA + digits → IATA + digits (no space)257 _FLIGHT_LOOSE = re.compile(258 r'\b(?:flight\s+)?(\d{2,4})\s*([A-Z]{2,3})\b' # 163 SQ259 r'|'260 r'\b(?:flight\s+)?([A-Z]{2,3})\s+(\d{2,4})\b', # SQ 163 (space)261 re.IGNORECASE262 )263 264 # Product spacing: brand directly followed by digits/variant ("iphone15")265 _PRODUCT_SPACING = re.compile(266 r'\b(iphone|macbook|ipad|pixel|galaxy|surface|airpods)'267 r'(\d+|pro|air|mini|max|ultra|plus)\b',268 re.IGNORECASE269 )270 271 # Stock: remove surrounding noise, keep just the ticker272 _STOCK_NOISE = re.compile(273 r'\b(stock|share|price|shares|equity|ticker|market|trading|invest(?:ment)?)\b',274 re.IGNORECASE275 )276 277 # Common compound words that users type without a space.278 # Applied per-token so works in multi-token queries too279 # e.g. "restarants nearme" → "restarants near me" (then GuardedPySpell fixes "restarants")280 _COMPOUND_SPLITS: dict[str, str] = {281 "nearme": "near me",282 "nearbyme": "near by me",283 "newyork": "new york",284 "losangeles": "los angeles",285 "sanfrancisco": "san francisco",286 "lasvegас": "las vegas",287 "bestbuy": "best buy",288 "homedepot": "home depot",289 "wholefoods": "whole foods",290 "starbucks": "starbucks", # already one word, no-op291 "doordash": "doordash",292 "ubereats": "uber eats",293 "grubhub": "grubhub",294 "openai": "openai",295 "chatgpt": "chatgpt",296 "youtube": "youtube",297 "facebook": "facebook",298 "instagram": "instagram",299 "whatsapp": "whatsapp",300 "linkedin": "linkedin",301 "tiktok": "tiktok",302 }303 304 def _normalize_flight(self, query: str) -> str:305 q_upper = query.upper()306 def _repl(m):307 if m.group(1): # digits IATA308 num, code = m.group(1), m.group(2).upper()309 else: # IATA digits310 code, num = m.group(3).upper(), m.group(4)311 if code in IATA_CODES:312 return f"{code}{num}"313 return m.group(0)314 result = self._FLIGHT_LOOSE.sub(_repl, query)315 return result316 317 def _normalize_stock(self, query: str) -> Optional[str]:318 ql = query.lower().strip()319 tokens = ql.split()320 # Check if any token is a known ticker or alias321 found_ticker = None322 for tok in tokens:323 # Direct ticker match (uppercase)324 if tok.upper() in STOCK_ALIASES:325 found_ticker = tok.upper()326 break327 # Alias match328 if tok in _ALIAS_TO_TICKER:329 found_ticker = _ALIAS_TO_TICKER[tok]330 if found_ticker:331 # Case 1: stock noise words present (e.g. "AAPL stock price")332 remaining = self._STOCK_NOISE.sub("", ql).strip()333 if remaining != ql.strip():334 return found_ticker335 # Case 2: explicit ticker token present alongside alias336 # (e.g. "apple aapl", "google GOOGL") — but NOT "google pixel 8"337 if found_ticker.lower() in tokens:338 return found_ticker339 return None340 341 def _normalize_product_spacing(self, query: str) -> str:342 return self._PRODUCT_SPACING.sub(lambda m: f"{m.group(1)} {m.group(2)}", query)343 344 def _normalize_compounds(self, query: str) -> str:345 """Split known compound tokens anywhere in the query.346 Works per-token so handles mixed queries like 'restarants nearme'."""347 tokens = query.lower().split()348 return " ".join(self._COMPOUND_SPLITS.get(tok, tok) for tok in tokens)349 350 def _normalize_word_order(self, query: str) -> str:351 """Reorder product queries so the brand/product-line token comes first.352 353 Handles patterns like:354 's24 samsung' → 'samsung s24'355 'pro 14 macbook' → 'macbook pro 14'356 'ultra s23 samsung'→ 'samsung ultra s23'357 """358 tokens = query.lower().split()359 if len(tokens) < 2:360 return query361 # Find a PRODUCT_BRANDS token that is not already at position 0362 for i, tok in enumerate(tokens):363 if i > 0 and tok in PRODUCT_BRANDS:364 # Move brand to front, preserve relative order of the rest365 return " ".join([tok] + tokens[:i] + tokens[i + 1:])366 return query367 368 def normalize(self, query: str) -> str:369 q = query.strip()370 371 # 1. Stock canonicalization372 stock = self._normalize_stock(q)373 if stock:374 return stock375 376 # 2. Flight ID normalization377 q = self._normalize_flight(q)378 379 # 3. Compound splitting (nearme → near me, newyork → new york)380 q = self._normalize_compounds(q)381 382 # 4. Product spacing383 q = self._normalize_product_spacing(q)384 385 # 5. Product word order386 q = self._normalize_word_order(q)387 388 # 6. Clean up extra whitespace389 q = re.sub(r'\s+', ' ', q).strip()390 391 return q392 393 394# ── 5. RapidFuzz (brand matching) ────────────────────────────────────────────395 396class RapidFuzzNormalizer(Normalizer):397 name = "RapidFuzz (brand match)"398 399 def __init__(self, score_cutoff: int = 82):400 if not HAS_RAPIDFUZZ:401 raise RuntimeError("rapidfuzz not installed")402 self._cutoff = score_cutoff403 404 def normalize(self, query: str) -> str:405 ql = query.lower().strip()406 407 # Only attempt brand correction on short queries (≤ 3 tokens)408 tokens = ql.split()409 if len(tokens) > 3:410 return query411 412 # Skip very short queries — too ambiguous to fuzzy-match safely413 # (e.g. 'appl', 'npm', 'gcc' should not be matched to brand names)414 if len(ql) <= 5:415 return query416 417 # Try matching each n-gram of the query against the brand list418 # First try the full query, then try progressively smaller windows419 result = _rf_process.extractOne(420 ql, BRANDS,421 scorer=_rf_fuzz.token_sort_ratio,422 score_cutoff=self._cutoff,423 )424 if result:425 best_match, score, _ = result426 return best_match427 428 return query429 430 431# ── 6. Combined ───────────────────────────────────────────────────────────────432 433class CombinedNormalizer(Normalizer):434 name = "Combined (Rules + SymSpell + RapidFuzz)"435 436 def __init__(self):437 self._rules = RulesNormalizer()438 self._symspell = SymSpellNormalizer() if HAS_SYMSPELL else None439 self._rfuzz = RapidFuzzNormalizer() if HAS_RAPIDFUZZ else None440 441 def normalize(self, query: str) -> str:442 q = query.strip()443 444 # Step 1: Apply entity/structural rules first (highest precision)445 q_rules = self._rules.normalize(q)446 if q_rules.lower() != q.lower():447 return q_rules # Rules made a change — trust it448 449 # Step 2: SymSpell for general typo correction450 if self._symspell:451 q_sym = self._symspell.normalize(q)452 if q_sym.lower() != q.lower():453 return q_sym454 455 # Step 3: RapidFuzz for brand name typos (catches what SymSpell misses456 # on compound brand names like "bestbuyt" → "best buy")457 if self._rfuzz:458 q_rf = self._rfuzz.normalize(q)459 if q_rf.lower() != q.lower():460 return q_rf461 462 return q463 464 465# ── 7. GuardedPySpell ────────────────────────────────────────────────────────466 467class GuardedPySpellNormalizer(Normalizer):468 """PySpellChecker with guards to prevent over-correction.469 470 PySpellChecker gets 88% on single_typo and 71% on multi_typo, but has471 40% over-correction on no-change queries (e.g. 'appl' → 'apple').472 473 Guards:474 - Skip tokens ≤ 4 chars (appl, npm, gcc, css, java, rust, echo, go)475 - Skip all-uppercase tokens (AAPL, NYC, SQ — abbreviations/tickers)476 - Skip tokens in the brand allowlist (airbnb, spotify, linkedin, …)477 478 Most legitimate short abbreviations are ≤ 4 chars or all-caps.479 Typos worth correcting are almost always ≥ 5 chars ('wheather', 'suhsi').480 """481 name = "PySpell (guarded)"482 483 # Known brand/product tokens that PySpell would corrupt.484 # Stored lowercase; comparison is done after lowercasing the token.485 _BRAND_ALLOWLIST: frozenset = frozenset({486 # Social / streaming487 "airbnb", "spotify", "linkedin", "tiktok", "whatsapp",488 "snapchat", "pinterest", "twitch", "reddit", "tumblr",489 "discord", "telegram", "signal",490 # Tech / SaaS491 "github", "gitlab", "dropbox", "notion", "figma",492 "asana", "trello", "jira", "confluence", "zendesk",493 "hubspot", "salesforce", "shopify", "stripe", "twilio",494 "vercel", "netlify", "supabase", "kubernetes", "terraform",495 "ansible", "grafana", "splunk", "datadog", "snowflake",496 "databricks", "pytorch", "tensorflow", "sklearn",497 # Devices / brands498 "iphone", "ipad", "macbook", "airpods", "homepod",499 "samsung", "pixel", "oneplus", "lenovo", "thinkpad",500 "playstation", "nintendo", "xbox",501 # Services502 "doordash", "grubhub", "instacart", "postmates",503 "lyft", "ubereats",504 # Media505 "netflix", "hulu", "disney", "peacock", "paramount",506 "youtube", "twitch",507 # Finance508 "venmo", "paypal", "cashapp", "robinhood", "coinbase",509 # Misc tech terms PySpell corrupts510 "nginx", "kafka", "numpy", "pandas",511 })512 513 def __init__(self):514 if not HAS_PYSPELL:515 raise RuntimeError("pyspellchecker not installed")516 self._sc = _SC()517 518 def _skip(self, token: str) -> bool:519 return len(token) <= 4 or token.isupper() or token in self._BRAND_ALLOWLIST520 521 def normalize(self, query: str) -> str:522 words = query.lower().split()523 return " ".join(524 w if self._skip(w) else (self._sc.correction(w) or w)525 for w in words526 )527 528 529# ── 8. CombinedV2 (Rules + GuardedPySpell + RapidFuzz) ───────────────────────530 531class CombinedV2Normalizer(Normalizer):532 """Improved pipeline: Rules → RapidFuzz (single-token) → SymSpell split → GuardedPySpell → RapidFuzz (multi-token).533 534 Rules handles structured entities (flight IDs, stock tickers, product535 spacing/order) with perfect precision. RapidFuzz runs first on single-token536 queries to catch brand typos (bestbuyt→best buy) before SymSpell can corrupt537 them (bestbuyt→best but). SymSpell compound splitting then handles concatenated538 words (nearme→near me). GuardedPySpell handles general typos while protecting539 short tokens. RapidFuzz runs again at the end for multi-token brand typos.540 """541 name = "CombinedV2 (Rules + GuardedPySpell + RapidFuzz)"542 543 def __init__(self):544 self._rules = RulesNormalizer()545 self._symspell = SymSpellNormalizer() if HAS_SYMSPELL else None546 self._pyspell = GuardedPySpellNormalizer() if HAS_PYSPELL else None547 self._rfuzz = RapidFuzzNormalizer() if HAS_RAPIDFUZZ else None548 549 def normalize(self, query: str) -> str:550 q = query.strip()551 552 # Step 1: Rules — flight IDs, stock tickers, product spacing/order553 q_rules = self._rules.normalize(q)554 if q_rules.lower() != q.lower():555 return q_rules556 557 # Step 2: RapidFuzz — brand name typos for single-token queries.558 # Must run before SymSpell compound splitting: SymSpell splits 'bestbuyt'559 # into 'best but' (wrong) whereas RapidFuzz correctly maps it to 'best buy'.560 if self._rfuzz and ' ' not in q:561 q_rf = self._rfuzz.normalize(q)562 if q_rf.lower() != q.lower():563 return q_rf564 565 # Step 3: SymSpell compound splitting for single-token queries only.566 # Only accept if SymSpell introduces a space (compound split).567 # Known compounds (nearme, newyork etc.) are handled by Rules above,568 # so this catches any remaining edge cases for single-token inputs.569 if self._symspell and ' ' not in q:570 q_sym = self._symspell.normalize(q)571 if ' ' in q_sym:572 return q_sym573 574 # Step 4: GuardedPySpell — general typos (skips short/uppercase tokens)575 if self._pyspell:576 q_spell = self._pyspell.normalize(q)577 if q_spell.lower() != q.lower():578 return q_spell579 580 # Step 5: RapidFuzz — brand name typos for multi-token queries581 # (e.g. 'gooogle maps' → 'google maps', 'spotifiy premium' → 'spotify premium')582 if self._rfuzz:583 q_rf = self._rfuzz.normalize(q)584 if q_rf.lower() != q.lower():585 return q_rf586 587 return q588 589 590# ── 9. ContextualSpellCheck (spaCy + BERT) ───────────────────────────────────591 592class ContextualSpellCheckNormalizer(Normalizer):593 """Uses BERT contextual embeddings to decide whether and how to correct594 each token. Unlike SymSpell, it sees the full query context before595 making a correction — so 'appl' in an ambiguous context stays as-is,596 while 'wheather nyc' correctly becomes 'weather nyc'.597 598 Requires:599 pip install contextualSpellCheck600 python -m spacy download en_core_web_sm601 """602 name = "ContextualSpellCheck (BERT)"603 604 def __init__(self):605 if not HAS_CONTEXTUAL:606 raise RuntimeError("contextualSpellCheck not available")607 self._nlp = _csc_nlp608 609 def normalize(self, query: str) -> str:610 doc = self._nlp(query)611 # doc._.outcome_spellCheck is the full corrected string612 result = doc._.outcome_spellCheck613 return result if result else query614 615 616# ── 8. T5 Spell Corrector (HuggingFace) ──────────────────────────────────────617 618class T5SpellCorrector(Normalizer):619 """Fine-tuned T5 model for spelling correction.620 Model: oliverguhr/spelling-correction-english-base621 622 This is a seq2seq model trained on noisy→clean sentence pairs.623 It handles multi-token typos, word order, and spacing better than624 dictionary-based approaches, but at significantly higher latency.625 626 Expected latency: ~100–500ms on CPU, ~20–80ms on GPU.627 628 Requires:629 pip install transformers torch (or transformers sentencepiece)630 """631 name = "T5 (oliverguhr/spelling-correction)"632 633 _MODEL_ID = "oliverguhr/spelling-correction-english-base"634 635 def __init__(self):636 if not HAS_TRANSFORMERS:637 raise RuntimeError("transformers not installed")638 self._pipe = None # lazy load in warmup()639 640 def warmup(self) -> None:641 print(f" Loading {self._MODEL_ID}...", end=" ", flush=True)642 self._pipe = _hf_pipeline(643 "text2text-generation",644 model=self._MODEL_ID,645 tokenizer=self._MODEL_ID,646 )647 # Prime the model with a dummy query648 self._pipe("warmup query", max_length=64)649 print("ready")650 651 def normalize(self, query: str) -> str:652 if self._pipe is None:653 self.warmup()654 result = self._pipe(query, max_length=128, num_beams=4)655 return result[0]["generated_text"].strip()656 657 658# ── 9. CombinedML (Rules → T5) ───────────────────────────────────────────────659 660class CombinedMLNormalizer(Normalizer):661 """Best-of-both-worlds pipeline:662 1. Rules handle structured entity normalization (flight IDs, stock tickers,663 product model reordering) with zero latency and perfect precision.664 2. T5 handles everything else — general typos, multi-token corrections,665 brand names — using full-query context.666 667 This avoids running T5 on queries that rules already handle perfectly,668 saving latency on the most common structured patterns.669 """670 name = "CombinedML (Rules → T5)"671 672 def __init__(self):673 self._rules = RulesNormalizer()674 self._t5 = T5SpellCorrector() if HAS_TRANSFORMERS else None675 676 def warmup(self) -> None:677 if self._t5:678 self._t5.warmup()679 680 def normalize(self, query: str) -> str:681 # Step 1: Rules first — highest precision for structured entities682 q_rules = self._rules.normalize(query)683 if q_rules.lower() != query.lower():684 return q_rules685 686 # Step 2: T5 for everything else687 if self._t5:688 return self._t5.normalize(query)689 690 return query691 692 693# ── Metrics ───────────────────────────────────────────────────────────────────694 695def char_error_rate(pred: str, gold: str) -> float:696 """CER = edit_distance / max(len(pred), len(gold))."""697 if not pred and not gold:698 return 0.0699 return edit_distance(pred.lower(), gold.lower()) / max(len(pred), len(gold))700 701 702def word_error_rate(pred: str, gold: str) -> float:703 """WER = token-level edit distance / number of gold tokens."""704 pred_toks = pred.lower().split()705 gold_toks = gold.lower().split()706 if not gold_toks:707 return 0.0708 m, n = len(pred_toks), len(gold_toks)709 dp = list(range(n + 1))710 for i in range(1, m + 1):711 prev = dp[:]712 dp[0] = i713 for j in range(1, n + 1):714 dp[j] = prev[j-1] if pred_toks[i-1] == gold_toks[j-1] \715 else 1 + min(prev[j], dp[j-1], prev[j-1])716 return dp[n] / n717 718 719def run_benchmark(normalizer: Normalizer, df: pd.DataFrame, n_timing_reps: int = 5) -> dict:720 """Run a normalizer on the dataset and return metrics."""721 queries = df["noisy"].tolist()722 723 # ── Timing ───────────────────────────────────────────────────────────────724 latencies_ms = []725 for q in queries:726 t0 = time.perf_counter()727 for _ in range(n_timing_reps):728 normalizer.normalize(q)729 t1 = time.perf_counter()730 latencies_ms.append((t1 - t0) / n_timing_reps * 1000)731 732 # ── Predictions ──────────────────────────────────────────────────────────733 preds = [normalizer.normalize(q) for q in queries]734 df = df.copy()735 df["pred"] = preds736 737 def em(row): return row["pred"].lower().strip() == row["canonical"].lower().strip()738 def cer(row): return char_error_rate(row["pred"], row["canonical"])739 def wer(row): return word_error_rate(row["pred"], row["canonical"])740 741 df["em"] = df.apply(em, axis=1)742 df["cer"] = df.apply(cer, axis=1)743 df["wer"] = df.apply(wer, axis=1)744 745 # No-change precision and over-correction rate746 nc = df[~df["should_change"]]747 no_change_precision = (nc["pred"].str.lower().str.strip() == nc["noisy"].str.lower().str.strip()).mean() if len(nc) else float("nan")748 over_correction = 1.0 - no_change_precision if not np.isnan(no_change_precision) else float("nan")749 750 # ── Per-category exact match ──────────────────────────────────────────────751 cat_em = df.groupby("category")["em"].mean().to_dict()752 753 return {754 "name": normalizer.name,755 "exact_match": df["em"].mean(),756 "cer_mean": df["cer"].mean(),757 "wer_mean": df["wer"].mean(),758 "no_change_precision": no_change_precision,759 "over_correction": over_correction,760 "latency_mean_ms": np.mean(latencies_ms),761 "latency_p50_ms": np.percentile(latencies_ms, 50),762 "latency_p95_ms": np.percentile(latencies_ms, 95),763 "latency_p99_ms": np.percentile(latencies_ms, 99),764 "per_category": cat_em,765 "_df": df, # store for detailed output766 "_latencies": latencies_ms,767 }768 769 770# ── Main ──────────────────────────────────────────────────────────────────────771 772def main():773 parser = argparse.ArgumentParser()774 parser.add_argument("--dataset", default=str(Path(__file__).parent / "dataset.csv"))775 parser.add_argument("--reps", type=int, default=5, help="Timing repetitions per query")776 args = parser.parse_args()777 778 df = pd.read_csv(args.dataset)779 print(f"Loaded {len(df)} rows from {args.dataset}")780 print(f"Categories: {df['category'].value_counts().to_dict()}\n")781 782 # ── Build normalizer list ─────────────────────────────────────────────────783 normalizers: list[Normalizer] = [IdentityNormalizer(), RulesNormalizer()]784 if HAS_PYSPELL:785 normalizers.append(PySpellNormalizer())786 if HAS_SYMSPELL:787 normalizers.append(SymSpellNormalizer())788 if HAS_RAPIDFUZZ:789 normalizers.append(RapidFuzzNormalizer())790 if HAS_SYMSPELL and HAS_RAPIDFUZZ:791 normalizers.append(CombinedNormalizer())792 if HAS_PYSPELL:793 normalizers.append(GuardedPySpellNormalizer())794 if HAS_PYSPELL and HAS_RAPIDFUZZ:795 normalizers.append(CombinedV2Normalizer())796 # ML normalizers (disabled — too slow and underperform rules-based)797 # if HAS_CONTEXTUAL:798 # normalizers.append(ContextualSpellCheckNormalizer())799 # if HAS_TRANSFORMERS:800 # normalizers.append(T5SpellCorrector())801 # normalizers.append(CombinedMLNormalizer())802 803 # Warmup804 for norm in normalizers:805 norm.warmup()806 807 # ── Run benchmarks ────────────────────────────────────────────────────────808 results = []809 for norm in normalizers:810 print(f"Benchmarking: {norm.name}...", end=" ", flush=True)811 r = run_benchmark(norm, df, n_timing_reps=args.reps)812 results.append(r)813 print(f"EM={r['exact_match']:.1%} CER={r['cer_mean']:.3f} lat_p50={r['latency_p50_ms']:.2f}ms")814 815 # ── Summary table ─────────────────────────────────────────────────────────816 print("\n" + "="*90)817 print("SUMMARY — Overall Metrics")818 print("="*90)819 820 summary_rows = []821 for r in results:822 summary_rows.append({823 "Normalizer": r["name"],824 "Exact Match": f"{r['exact_match']:.1%}",825 "CER": f"{r['cer_mean']:.3f}",826 "WER": f"{r['wer_mean']:.3f}",827 "No-change Prec.": f"{r['no_change_precision']:.1%}" if not np.isnan(r['no_change_precision']) else "N/A",828 "Over-correction": f"{r['over_correction']:.1%}" if not np.isnan(r['over_correction']) else "N/A",829 "Lat mean (ms)": f"{r['latency_mean_ms']:.2f}",830 "Lat p50 (ms)": f"{r['latency_p50_ms']:.2f}",831 "Lat p95 (ms)": f"{r['latency_p95_ms']:.2f}",832 "Lat p99 (ms)": f"{r['latency_p99_ms']:.2f}",833 })834 835 try:836 from tabulate import tabulate837 print(tabulate(summary_rows, headers="keys", tablefmt="rounded_outline"))838 except ImportError:839 pd.DataFrame(summary_rows).to_string(index=False)840 print(pd.DataFrame(summary_rows).to_string(index=False))841 842 # ── Per-category table ────────────────────────────────────────────────────843 categories = sorted(df["category"].unique())844 print("\n" + "="*90)845 print("PER-CATEGORY Exact Match")846 print("="*90)847 848 cat_rows = []849 for r in results:850 row = {"Normalizer": r["name"][:30]}851 for cat in categories:852 row[cat] = f"{r['per_category'].get(cat, float('nan')):.0%}"853 cat_rows.append(row)854 855 try:856 from tabulate import tabulate857 print(tabulate(cat_rows, headers="keys", tablefmt="rounded_outline"))858 except ImportError:859 print(pd.DataFrame(cat_rows).to_string(index=False))860 861 # ── Sample predictions ────────────────────────────────────────────────────862 print("\n" + "="*90)863 print("SAMPLE PREDICTIONS — Combined vs Identity (first 5 per category)")864 print("="*90)865 866 combined_r = next((r for r in results if "CombinedV2" in r["name"]),867 next((r for r in results if "Combined" in r["name"]), results[-1]))868 identity_r = results[0]869 870 for cat in categories:871 sub = combined_r["_df"][combined_r["_df"]["category"] == cat].head(5)872 id_sub = identity_r["_df"][identity_r["_df"]["category"] == cat].head(5)873 print(f"\n {cat.upper()}")874 print(f" {'Noisy':<30} {'Canonical':<25} {'Combined pred':<25} {'EM':>4}")875 print(f" {'-'*30} {'-'*25} {'-'*25} {'-'*4}")876 for (_, row), (_, id_row) in zip(sub.iterrows(), id_sub.iterrows()):877 em_mark = "✓" if row["em"] else "✗"878 print(f" {row['noisy']:<30} {row['canonical']:<25} {row['pred']:<25} {em_mark:>4}")879 880 # ── Save full results ─────────────────────────────────────────────────────881 out_path = Path(args.dataset).parent / "results.csv"882 combined_r["_df"].to_csv(out_path, index=False)883 print(f"\nFull predictions saved to {out_path}")884 885 886if __name__ == "__main__":887 main()888 