{"metadata":{"kernelspec":{"display_name":"Python 3","language":"python","name":"python3"},"language_info":{"name":"python","version":"3.12.13","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"tpuV5e8","dataSources":[{"sourceType":"competition","sourceId":121150,"databundleVersionId":15833391},{"sourceType":"datasetVersion","sourceId":14899351,"datasetId":9366631,"databundleVersionId":15763950},{"sourceType":"datasetVersion","sourceId":15095098,"datasetId":9664684,"databundleVersionId":15979888},{"sourceType":"datasetVersion","sourceId":15107887,"datasetId":9673215,"databundleVersionId":15994117},{"sourceType":"modelInstanceVersion","sourceId":755680,"databundleVersionId":15750522,"modelInstanceId":577094,"modelId":589419}],"dockerImageVersionId":31331,"isInternetEnabled":false,"language":"python","sourceType":"notebook","isGpuEnabled":false},"papermill":{"default_parameters":{},"duration":138.214167,"end_time":"2026-03-09T00:40:38.689078","environment_variables":{},"exception":null,"input_path":"__notebook__.ipynb","output_path":"__notebook__.ipynb","parameters":{},"start_time":"2026-03-09T00:38:20.474911","version":"2.6.0"},"widgets":{"application/vnd.jupyter.widget-state+json":{"state":{"06f305ab3bac4a62b99b90eb355606a4":{"model_module":"@jupyter-widgets/controls","model_module_version":"2.0.0","model_name":"HTMLStyleModel","state":{"_model_module":"@jupyter-widgets/controls","_model_module_version":"2.0.0","_model_name":"HTMLStyleModel","_view_count":null,"_view_module":"@jupyter-widgets/base","_view_module_version":"2.0.0","_view_name":"StyleView","background":null,"description_width":"","font_size":null,"text_color":null}},"385a2569cce44ff9bf3520a75cc2759c":{"model_module":"@jupyter-widgets/controls","model_module_version":"2.0.0","model_name":"HTMLModel","state":{"_dom_classes":[],"_model_module":"@jupyter-widgets/controls","_model_module_version":"2.0.0","_model_name":"HTMLModel","_view_count":null,"_view_module":"@jupyter-widgets/controls","_view_module_version":"2.0.0","_view_name":"HTMLView","description":"","description_allow_html":false,"layout":"IPY_MODEL_b3cafe98129e4b73b1738fc7d5948b52","placeholder":"​","style":"IPY_MODEL_ea39fc0b3e194eaf843d0820f811692b","tabbable":null,"tooltip":null,"value":" 2/2 [00:55&lt;00:00, 28.24s/it]"}},"4ac51520bcc14d6889fa46283f3f9a9d":{"model_module":"@jupyter-widgets/controls","model_module_version":"2.0.0","model_name":"HBoxModel","state":{"_dom_classes":[],"_model_module":"@jupyter-widgets/controls","_model_module_version":"2.0.0","_model_name":"HBoxModel","_view_count":null,"_view_module":"@jupyter-widgets/controls","_view_module_version":"2.0.0","_view_name":"HBoxView","box_style":"","children":["IPY_MODEL_63be34005d094b14b7fb38b15d06693b","IPY_MODEL_b6a38dac033d4542bef20229a26022df","IPY_MODEL_385a2569cce44ff9bf3520a75cc2759c"],"layout":"IPY_MODEL_fe52936bcdde441cbff84ad970db600d","tabbable":null,"tooltip":null}},"552cea41c07d48ec834b444d5c2d96a0":{"model_module":"@jupyter-widgets/controls","model_module_version":"2.0.0","model_name":"ProgressStyleModel","state":{"_model_module":"@jupyter-widgets/controls","_model_module_version":"2.0.0","_model_name":"ProgressStyleModel","_view_count":null,"_view_module":"@jupyter-widgets/base","_view_module_version":"2.0.0","_view_name":"StyleView","bar_color":null,"description_width":""}},"63be34005d094b14b7fb38b15d06693b":{"model_module":"@jupyter-widgets/controls","model_module_version":"2.0.0","model_name":"HTMLModel","state":{"_dom_classes":[],"_model_module":"@jupyter-widgets/controls","_model_module_version":"2.0.0","_model_name":"HTMLModel","_view_count":null,"_view_module":"@jupyter-widgets/controls","_view_module_version":"2.0.0","_view_name":"HTMLView","description":"","description_allow_html":false,"layout":"IPY_MODEL_74d67a6c7dfa4335a82b644609b85b2e","placeholder":"​","style":"IPY_MODEL_06f305ab3bac4a62b99b90eb355606a4","tabbable":null,"tooltip":null,"value":"100%"}},"74d67a6c7dfa4335a82b644609b85b2e":{"model_module":"@jupyter-widgets/base","model_module_version":"2.0.0","model_name":"LayoutModel","state":{"_model_module":"@jupyter-widgets/base","_model_module_version":"2.0.0","_model_name":"LayoutModel","_view_count":null,"_view_module":"@jupyter-widgets/base","_view_module_version":"2.0.0","_view_name":"LayoutView","align_content":null,"align_items":null,"align_self":null,"border_bottom":null,"border_left":null,"border_right":null,"border_top":null,"bottom":null,"display":null,"flex":null,"flex_flow":null,"grid_area":null,"grid_auto_columns":null,"grid_auto_flow":null,"grid_auto_rows":null,"grid_column":null,"grid_gap":null,"grid_row":null,"grid_template_areas":null,"grid_template_columns":null,"grid_template_rows":null,"height":null,"justify_content":null,"justify_items":null,"left":null,"margin":null,"max_height":null,"max_width":null,"min_height":null,"min_width":null,"object_fit":null,"object_position":null,"order":null,"overflow":null,"padding":null,"right":null,"top":null,"visibility":null,"width":null}},"8934bc4d28d24a199145ebfd66bb8abd":{"model_module":"@jupyter-widgets/base","model_module_version":"2.0.0","model_name":"LayoutModel","state":{"_model_module":"@jupyter-widgets/base","_model_module_version":"2.0.0","_model_name":"LayoutModel","_view_count":null,"_view_module":"@jupyter-widgets/base","_view_module_version":"2.0.0","_view_name":"LayoutView","align_content":null,"align_items":null,"align_self":null,"border_bottom":null,"border_left":null,"border_right":null,"border_top":null,"bottom":null,"display":null,"flex":null,"flex_flow":null,"grid_area":null,"grid_auto_columns":null,"grid_auto_flow":null,"grid_auto_rows":null,"grid_column":null,"grid_gap":null,"grid_row":null,"grid_template_areas":null,"grid_template_columns":null,"grid_template_rows":null,"height":null,"justify_content":null,"justify_items":null,"left":null,"margin":null,"max_height":null,"max_width":null,"min_height":null,"min_width":null,"object_fit":null,"object_position":null,"order":null,"overflow":null,"padding":null,"right":null,"top":null,"visibility":null,"width":null}},"b3cafe98129e4b73b1738fc7d5948b52":{"model_module":"@jupyter-widgets/base","model_module_version":"2.0.0","model_name":"LayoutModel","state":{"_model_module":"@jupyter-widgets/base","_model_module_version":"2.0.0","_model_name":"LayoutModel","_view_count":null,"_view_module":"@jupyter-widgets/base","_view_module_version":"2.0.0","_view_name":"LayoutView","align_content":null,"align_items":null,"align_self":null,"border_bottom":null,"border_left":null,"border_right":null,"border_top":null,"bottom":null,"display":null,"flex":null,"flex_flow":null,"grid_area":null,"grid_auto_columns":null,"grid_auto_flow":null,"grid_auto_rows":null,"grid_column":null,"grid_gap":null,"grid_row":null,"grid_template_areas":null,"grid_template_columns":null,"grid_template_rows":null,"height":null,"justify_content":null,"justify_items":null,"left":null,"margin":null,"max_height":null,"max_width":null,"min_height":null,"min_width":null,"object_fit":null,"object_position":null,"order":null,"overflow":null,"padding":null,"right":null,"top":null,"visibility":null,"width":null}},"b6a38dac033d4542bef20229a26022df":{"model_module":"@jupyter-widgets/controls","model_module_version":"2.0.0","model_name":"FloatProgressModel","state":{"_dom_classes":[],"_model_module":"@jupyter-widgets/controls","_model_module_version":"2.0.0","_model_name":"FloatProgressModel","_view_count":null,"_view_module":"@jupyter-widgets/controls","_view_module_version":"2.0.0","_view_name":"ProgressView","bar_style":"success","description":"","description_allow_html":false,"layout":"IPY_MODEL_8934bc4d28d24a199145ebfd66bb8abd","max":2,"min":0,"orientation":"horizontal","style":"IPY_MODEL_552cea41c07d48ec834b444d5c2d96a0","tabbable":null,"tooltip":null,"value":2}},"ea39fc0b3e194eaf843d0820f811692b":{"model_module":"@jupyter-widgets/controls","model_module_version":"2.0.0","model_name":"HTMLStyleModel","state":{"_model_module":"@jupyter-widgets/controls","_model_module_version":"2.0.0","_model_name":"HTMLStyleModel","_view_count":null,"_view_module":"@jupyter-widgets/base","_view_module_version":"2.0.0","_view_name":"StyleView","background":null,"description_width":"","font_size":null,"text_color":null}},"fe52936bcdde441cbff84ad970db600d":{"model_module":"@jupyter-widgets/base","model_module_version":"2.0.0","model_name":"LayoutModel","state":{"_model_module":"@jupyter-widgets/base","_model_module_version":"2.0.0","_model_name":"LayoutModel","_view_count":null,"_view_module":"@jupyter-widgets/base","_view_module_version":"2.0.0","_view_name":"LayoutView","align_content":null,"align_items":null,"align_self":null,"border_bottom":null,"border_left":null,"border_right":null,"border_top":null,"bottom":null,"display":null,"flex":null,"flex_flow":null,"grid_area":null,"grid_auto_columns":null,"grid_auto_flow":null,"grid_auto_rows":null,"grid_column":null,"grid_gap":null,"grid_row":null,"grid_template_areas":null,"grid_template_columns":null,"grid_template_rows":null,"height":null,"justify_content":null,"justify_items":null,"left":null,"margin":null,"max_height":null,"max_width":null,"min_height":null,"min_width":null,"object_fit":null,"object_position":null,"order":null,"overflow":null,"padding":null,"right":null,"top":null,"visibility":null,"width":null}}},"version_major":2,"version_minor":0}}},"nbformat_minor":4,"nbformat":4,"cells":[{"id":"a51246d4-c167-462b-b405-e16884a0425d","cell_type":"markdown","source":"This is the detailed explanation of the Algorithmic Code, the Code Result log, and the Graphical Outputs. The code is an end-to-end inference and evaluation pipeline for Akkadian → English translation, built around an ensemble of two ByT5-style sequence-to-sequence models and a Minimum Bayes Risk (MBR) selection step to choose the most consensus-like translation from multiple candidates. The document also adds extensive preprocessing, output cleaning, competition-style scoring, and a large set of diagnostic plots for understanding input data, model behavior, and generalization patterns. \n\n# 1) What the algorithmic code is designed to do\n\nAt a high level, the pipeline does four things:\n# 1.\tPrepares the environment by installing a local sacrebleu wheel and setting up imports, logging, plotting defaults, and CUDA safety checks. \n\n# 2.\tLoads and preprocesses Akkadian transliteration text so it becomes suitable for the translation models. \n\n# 3.\tRuns two translation models in ensemble mode, collects beam-search and sampling candidates, cleans them, and uses an MBR selector to pick the best final translation. \n\n# 4.\tGenerates plots and a submission file, and optionally computes the official score if ground truth is available.\n\nThe algorithm is not just “translate once and save.” Instead, it is built to improve robustness by generating several candidate translations per input, combining candidates from two models, and then selecting the candidate that is most consistent with the rest of the pool. That is the purpose of the MBR logic. \n\n# 2) The core algorithm, step by step\n\n# a) Environment and model compatibility\n\nThe first section installs sacrebleu from a local wheel rather than from the internet, which is useful in restricted environments like Kaggle. Then the code configures logging, suppresses warnings, and sets plotting style defaults. It also checks whether CUDA is available and whether the GPU is modern enough for the expected PyTorch build. If not, it falls back to CPU safely. This is why the log later reports PyTorch : 2.8.0+cpu, CUDA : False, and Device : cpu. \n\n# b) Configuration\n\nThe EnsembleMBRConfig class stores all major settings in one place: file paths, maximum input and output lengths, batch size, beam-search parameters, sampling parameters, MBR pool size, and performance-related options like mixed precision and better transformer support. This makes the pipeline reproducible and easy to tune. The __post_init__ method automatically adjusts settings depending on device availability. \n\n# c) Preprocessing\n\nThe input side uses OptimizedPreprocessor. Its job is to normalize Akkadian transliterations before tokenization. It:\n•\tconverts ASCII transliteration conventions into diacritics, such as sz → š, s, → ṣ, t, → ṭ \n•\tconverts vowel notation like a2, e3 into accented or grave-marked forms \n•\tnormalizes gap markers into a single token: <gap> \n•\tcollapses whitespace \nThis matters because transliteration data is often messy and inconsistent. By normalizing it first, the model receives more regular input.\n\n# d) Dataset construction\n\nAkkadianDataset reads each row’s id and transliteration, preprocesses the text, and prefixes it with the instruction string:\ntranslate Akkadian to English:\n\nThis is a classic instruction-style prompt for seq2seq translation models. The dataset returns ID-text pairs for inference.\n\n# e) Model wrapper and candidate generation\nModelWrapper loads a tokenizer and a seq2seq model from a local path. For each batch, it generates two kinds of candidates:\n•\tbeam candidates using deterministic beam search \n•\tsample candidates using probabilistic sampling with top-p and temperature control \nThe reason for generating both is important: beam search gives high-probability translations, while sampling introduces diversity. Their combination creates a richer candidate pool for MBR selection. \n# f) MBR selection\nMBRSelector uses ChrF++ similarity as the internal agreement metric. For each candidate, it computes how similar that candidate is to all other candidates in the pool, then selects the one with the highest average similarity. In plain language: it chooses the translation that best “agrees” with the rest of the ensemble pool. This is a consensus decoding strategy. \n# g) Postprocessing and safety cleaning\nAfter MBR chooses a candidate, the code runs clean_english() on it. That function aggressively removes non-Latin characters, control characters, duplicate punctuation, bracket noise, and other output artifacts. It also ensures the result is plain English and falls back to a default sentence if the output becomes empty or too short. The document explicitly says this cleaner is meant to keep only valid English text in the final output. \n# h) Competition scoring\nThe code includes an exact replica of the competition metric: the geometric mean of corpus BLEU and corpus ChrF++, both micro-averaged over the full set. The final official score is:\nBLEU×ChrF++\\sqrt{\\text{BLEU} \\times \\text{ChrF++}}BLEU×ChrF++ \nThis is printed as the “official score” when ground truth is available. \n# 3) What the Code Result log tells us\nThe execution log confirms the pipeline actually ran end to end, but on a very small test set of 4 samples. It shows:\n•\tthe local sacrebleu wheel was installed successfully \n•\tCUDA was not available, so the run used CPU \n•\tthe dataset size was 4 \n•\tthe models were loaded from local Kaggle paths \n•\tinference completed successfully \n•\tthe submission file was written to /kaggle/working/submission.csv \n•\tall translations were verified as ASCII English \n•\tcompetition scoring was skipped because of a logged 'id' issue \n•\tall plots were saved successfully to /kaggle/working/ \nThat log is important because it verifies that the code is not just theoretical; it executed. The message All translations verified as ASCII English shows the cleaning step worked. The message Competition scoring skipped: 'id' suggests the validation branch hit a missing-column or key-access problem during scoring, so the final metric was not computed in that run. The document’s code expects the score block to work only if a suitable solution file is present and structured correctly. \nThe submission preview also reveals that the outputs are fluent English sentences, but they show some repeated phrases and mild translation instability, which is exactly the kind of behavior the ensemble+MBR approach is trying to reduce. \n\n# 4) What the graphical outputs are, and what each one means\nThe document produces three groups of plots: EDA plots, evaluation plots, and generalization plots. These are not decorative; they are diagnostic tools for understanding data quality, translation behavior, and model robustness. The code saves them as PNG files in the working directory. \nA. EDA plots\nThese are created before inference to understand the input data.\n1. Input Length Distribution\nThis plot shows the distribution of transliteration lengths by characters and by token count. It helps you see whether the dataset contains mostly short, medium, or long inscriptions, and whether there are outliers. The code draws histograms and marks the median. \n2. <gap> Token Frequency per Sample\nThis shows how many gap markers each sample has after preprocessing. A high gap count usually means damaged or incomplete tablets, which can make translation harder. \n3. Diacritic Coverage\nThis pie chart divides the corpus into texts with diacritics and texts that are ASCII-only. It gives a quick picture of how much linguistic normalization is needed. \n4. Top Unigrams\nThis bar chart shows the most frequent input tokens after preprocessing. It gives insight into common words, symbols, or transliteration patterns in the corpus. \n5. Sample ID Distribution\nThis histogram shows the distribution of IDs. In many datasets, this is mostly a sanity check, but it can reveal ordering or grouping effects. \nB. Evaluation plots\nThese are based on the model outputs.\n1. MBR Score Distribution\nShows how strong the consensus score was across samples. High scores mean the candidate pool agreed strongly; low scores suggest ambiguity or model disagreement. \n2. Output Length Distribution\nShows how many words the final translations tend to contain. This helps identify whether outputs are too short, too long, or highly variable. \n3. Deduplicated Pool Size Distribution\nShows how many unique candidates remained after deduplication. If the pool size is small, the beam and sampling candidates are too similar; if large, the model is producing more diverse hypotheses. \n4. MBR Score vs Output Length\nA scatter plot that checks whether longer translations tend to get better or worse consensus scores. The code even fits a trend line when enough points exist. \n5. Fallback Rate\nA pie chart showing how many outputs fell back to the default sentence because the result was empty or too short. This is a practical quality-control indicator. \n6. Output Top Unigrams\nShows the most frequent words in the translated outputs. This helps detect repetition, bias, or overuse of certain generic words. \n7. Per-Sample MBR Score\nThis horizontal bar chart sorts samples by MBR score so the weakest and strongest cases can be identified quickly. Low-score cases may be the hardest inscriptions. \n8. Competition Score Breakdown\nIf ground truth is available, this plot compares BLEU, ChrF++, and the geometric-mean official score. In the logged run, this plot was not produced because the scoring block was skipped. \nC. Generalization plots\nThese inspect whether performance changes with input complexity.\n1. Input Character Length vs Output Word Count\nChecks whether longer input tablets generate longer outputs, which would suggest the model is scaling translation length sensibly. \n2. Mean MBR Score by Input-Length Bucket\nBuckets inputs into length bins and plots average consensus score per bucket. This shows whether long texts are harder to translate reliably. \n3. MBR Score vs <gap> Count\nA box plot of consensus score grouped by the number of gap markers. This is especially important because more gaps usually mean more uncertainty. \n4. Pairwise Translation Similarity Heatmap\nTakes a random sample of final translations and computes pairwise ChrF++ similarity. If the heatmap is very bright and uniform, the outputs may be overly repetitive; if it is diverse, translations vary more substantially.\n\n# 5. Cumulative Distribution of MBR Scores\n\nShows what fraction of samples achieve at least certain score thresholds such as 20, 40, and 60. This gives a compact summary of overall quality. \n6. Output/Input Length Ratio\nShows how much longer or shorter the translation is compared to the input token count. This is useful for spotting runaway outputs or overly compressed translations.\n\n# 6) What the graphical outputs collectively mean\nTaken together, the plots tell a full story:\n•\tthe EDA plots describe the difficulty and structure of the Akkadian inputs \n•\tthe evaluation plots describe how the ensemble behaved during inference \n•\tthe generalization plots show whether the model behaves consistently across easy and hard inputs \nSo the document is not just a translation script. It is a full translation system plus analysis suite. The algorithm creates candidates, ranks them by agreement, cleans the result into English, saves a competition submission, and produces diagnostics to help judge both data characteristics and model quality.\n\n","metadata":{}},{"id":"1470d451-f6e7-4e4d-a732-771d620a164e","cell_type":"code","source":"# =============================================================================\n#  ENSEMBLE MBR — Akkadian → English Translation\n#  Fixes: strict ASCII-only English output, competition scoring, all plots.\n# =============================================================================\n\n# ── 0. Local wheel installation ───────────────────────────────────────────────\nimport subprocess, sys, os\n\ndef install_local_wheel(wheel_path, deps_dir):\n    if not os.path.isfile(wheel_path):\n        raise FileNotFoundError(f\"Wheel file not found: {wheel_path}\")\n    if not os.path.isdir(deps_dir):\n        raise NotADirectoryError(f\"Dependencies directory not found: {deps_dir}\")\n    cmd = [sys.executable, \"-m\", \"pip\", \"install\",\n           \"--no-index\", \"--find-links\", deps_dir, wheel_path]\n    print(\"Running command:\", \" \".join(cmd))\n    try:\n        result = subprocess.run(cmd, capture_output=True, text=True, check=True)\n        print(\"Installation successful!\\n\", result.stdout)\n    except subprocess.CalledProcessError as e:\n        print(\"Installation failed!\\nSTDERR:\", e.stderr, \"\\nSTDOUT:\", e.stdout)\n        raise\n\nwheel_path = \"/kaggle/input/datasets/lvbingrong/sacrebleu-wheels/sacrebleu_wheels/sacrebleu-2.6.0-py3-none-any.whl\"\ndeps_dir   = \"/kaggle/input/datasets/lvbingrong/sacrebleu-wheels/sacrebleu_wheels/\"\ninstall_local_wheel(wheel_path, deps_dir)\n\n\n# ── 1. Standard imports ───────────────────────────────────────────────────────\nimport gc, re, json, math, random, logging, warnings, html, unicodedata\nfrom pathlib import Path\nfrom dataclasses import dataclass\nfrom contextlib import nullcontext\nfrom typing import List\nfrom collections import Counter\n\nimport pandas as pd\nimport numpy as np\nimport torch\nfrom torch.utils.data import Dataset, DataLoader\nfrom transformers import AutoTokenizer, AutoModelForSeq2SeqLM\nfrom tqdm.auto import tqdm\nimport sacrebleu\nimport pandas.api.types\n\nimport matplotlib\nmatplotlib.use(\"Agg\")\nimport matplotlib.pyplot as plt\nimport matplotlib.ticker as mticker\nimport seaborn as sns\n\nwarnings.filterwarnings(\"ignore\")\n\nos.environ.setdefault(\"OMP_NUM_THREADS\",        \"4\")\nos.environ.setdefault(\"MKL_NUM_THREADS\",        \"4\")\nos.environ.setdefault(\"TOKENIZERS_PARALLELISM\", \"true\")\n\nsns.set_theme(style=\"whitegrid\", palette=\"muted\", font_scale=1.1)\nPLOT_DPI = 150\nCMAP_SEQ = \"Blues\"\nCMAP_DIV = \"RdYlGn\"\n\n\n# ── 2. CUDA compatibility check ───────────────────────────────────────────────\n_MIN_COMPUTE_MAJOR = 7\n\ndef _cuda_compute_major() -> int:\n    if not torch.cuda.is_available():\n        return 0\n    try:\n        return torch.cuda.get_device_properties(0).major\n    except Exception:\n        return 0\n\ndef _safe_device() -> torch.device:\n    major = _cuda_compute_major()\n    if major >= _MIN_COMPUTE_MAJOR:\n        return torch.device(\"cuda\")\n    if major > 0:\n        print(\n            f\"[WARNING] GPU '{torch.cuda.get_device_name(0)}' is sm_{major}0, \"\n            f\"below minimum sm_{_MIN_COMPUTE_MAJOR}0 for PyTorch {torch.__version__}. \"\n            f\"Falling back to CPU.\"\n        )\n    return torch.device(\"cpu\")\n\ndef _cuda_bf16_supported() -> bool:\n    if not torch.cuda.is_available():\n        return False\n    try:\n        return bool(getattr(torch.cuda, \"is_bf16_supported\", lambda: False)())\n    except Exception:\n        return False\n\ndef _bf16_ctx(device: torch.device, enabled: bool):\n    if enabled and device.type == \"cuda\" and _cuda_bf16_supported():\n        return torch.autocast(device_type=\"cuda\", dtype=torch.bfloat16)\n    return nullcontext()\n\ndef setup_logging(output_dir: str) -> logging.Logger:\n    Path(output_dir).mkdir(exist_ok=True, parents=True)\n    for h in logging.root.handlers[:]:\n        logging.root.removeHandler(h)\n    logging.basicConfig(\n        level=logging.INFO,\n        format=\"%(asctime)s | %(levelname)s | %(message)s\",\n        handlers=[\n            logging.StreamHandler(),\n            logging.FileHandler(Path(output_dir) / \"ensemble_mbr.log\"),\n        ],\n    )\n    return logging.getLogger(\"ensemble_mbr\")\n\n\n# ── 3. Config ─────────────────────────────────────────────────────────────────\n@dataclass\nclass EnsembleMBRConfig:\n    test_data_path: str = \"/kaggle/input/competitions/deep-past-initiative-machine-translation/test.csv\"\n    output_dir: str     = \"/kaggle/working/\"\n\n    model_a_path: str = str(\n        Path(\"/kaggle/input/datasets/assiaben/final-byt5/byt5-akkadian-optimized-34x\").resolve()\n    )\n    model_b_path: str = str(\n        Path(\"/kaggle/input/models/mattiaangeli/byt5-akkadian-mbr-v2/pytorch/default/1\").resolve()\n    )\n\n    max_input_length: int     = 512\n    max_new_tokens: int       = 384\n    batch_size: int           = 2\n    num_workers: int          = 2\n    num_buckets: int          = 8\n\n    num_beam_cands: int       = 5\n    num_beams: int            = 10\n    length_penalty: float     = 1.15\n    early_stopping: bool      = True\n    repetition_penalty: float = 1.12\n\n    num_sample_cands: int  = 3\n    mbr_top_p: float       = 0.95\n    mbr_temperature: float = 0.82\n    mbr_pool_cap: int      = 40\n\n    use_mixed_precision: bool       = True\n    use_better_transformer: bool    = True\n    use_bucket_batching: bool       = True\n    use_adaptive_beams: bool        = True\n    aggressive_postprocessing: bool = False\n    checkpoint_freq: int            = 200\n\n    def __post_init__(self):\n        self.device = _safe_device()\n        Path(self.output_dir).mkdir(exist_ok=True, parents=True)\n        if self.device.type != \"cuda\":\n            self.use_mixed_precision    = False\n            self.use_better_transformer = False\n        self.use_bf16_amp = bool(\n            self.use_mixed_precision\n            and self.device.type == \"cuda\"\n            and _cuda_bf16_supported()\n        )\n        assert self.num_beams >= self.num_beam_cands\n\ndef print_env(cfg: EnsembleMBRConfig):\n    print(f\"PyTorch  : {torch.__version__}\")\n    print(f\"CUDA     : {torch.cuda.is_available()}\")\n    if torch.cuda.is_available():\n        major = _cuda_compute_major()\n        print(f\"GPU      : {torch.cuda.get_device_name(0)}\")\n        mem = torch.cuda.get_device_properties(0).total_memory / 1e9\n        print(f\"GPU Mem  : {mem:.2f} GB\")\n        print(f\"Compute  : sm_{major}0  (need >= sm_{_MIN_COMPUTE_MAJOR}0)\")\n        print(f\"BF16     : {_cuda_bf16_supported()}\")\n    print(f\"Device   : {cfg.device}\")\n    print(f\"BF16 AMP : {cfg.use_bf16_amp}\")\n\n\n# =============================================================================\n#  STRICT ENGLISH CLEANER\n#\n#  Strategy: whitelist-only.\n#  We keep ONLY characters that belong to standard written English:\n#    • a-z  A-Z  0-9\n#    • space\n#    • English punctuation:  . , ! ? ; : ' \" - ( ) [ ] /\n#  Everything else — diacritics, Unicode Latin extensions, symbols, brackets\n#  with noise, HTML entities, control chars — is removed or replaced.\n#\n#  A scholarly bracket notation like \"(uncertain)\" or \"[broken]\" is preserved\n#  because those are genuine English annotation conventions in this corpus.\n# =============================================================================\n\nFALLBACK_TRANSLATION = \"The tablet is too damaged to translate.\"\n\n# Strict whitelist: ASCII letters, digits, space, and English punctuation only\n_WHITELIST_RE = re.compile(r\"[^a-zA-Z0-9 .,!?;:'\\\"\\-()\\[\\]/]\")\n\n# Collapse any run of spaces/tabs/newlines to a single space\n_MULTI_SPACE_RE = re.compile(r\"\\s+\")\n\n# Remove lone punctuation clusters with no adjacent letters (e.g.  \".,!  ;:\")\n_LONE_PUNCT_RE = re.compile(r\"(?<![a-zA-Z0-9])[^a-zA-Z0-9 ]+(?![a-zA-Z0-9])\")\n\n# HTML numeric / named entities (must run BEFORE whitelist strip)\n_HTML_ENTITY_RE = re.compile(r\"&#?\\w+;\")\n\n# Repeated punctuation (e.g.  ??? → ?   ,,,→ ,)\n_REPEAT_PUNCT_RE = re.compile(r\"([.,!?;:])\\1{1,}\")\n\n\ndef clean_english(text: str) -> str:\n    \"\"\"\n    Strict whitelist cleaner that returns only valid English ASCII text.\n\n    Pipeline\n    --------\n    1.  Coerce to str, strip leading/trailing whitespace\n    2.  Decode HTML entities            (&amp; → &, &#039; → ')\n    3.  Unicode NFKC normalisation      (ﬁ → fi, ā → a, etc.)\n    4.  Remove all non-ASCII bytes      (anything > 0x7e → space)\n    5.  Apply whitelist regex           keep only [a-zA-Z0-9 .,!?;:'\"-()[]/]\n    6.  Collapse repeated punctuation\n    7.  Remove orphaned punctuation clusters\n    8.  Collapse multiple spaces\n    9.  Strip, sentence-case, ensure terminal punctuation\n    10. Fallback if fewer than 3 English words remain\n    \"\"\"\n    if not text or not str(text).strip():\n        return FALLBACK_TRANSLATION\n\n    # 1. coerce\n    text = str(text).strip()\n\n    # 2. HTML entities\n    text = html.unescape(text)\n\n    # 3. Unicode NFKC: decomposes ligatures and compatibility forms,\n    #    converts accented Latin chars to their base + combining form,\n    #    then ASCII-encode to drop combining diacritics\n    text = unicodedata.normalize(\"NFKD\", text)\n    text = text.encode(\"ascii\", errors=\"ignore\").decode(\"ascii\")\n\n    # 4+5. Whitelist: drop anything not in [a-zA-Z0-9 .,!?;:'\"-()\\[\\]/]\n    text = _WHITELIST_RE.sub(\" \", text)\n\n    # 6. Collapse repeated punctuation\n    text = _REPEAT_PUNCT_RE.sub(r\"\\1\", text)\n\n    # 7. Remove orphaned punctuation clusters (no letters around them)\n    text = _LONE_PUNCT_RE.sub(\" \", text)\n\n    # 8. Collapse spaces\n    text = _MULTI_SPACE_RE.sub(\" \", text).strip()\n\n    # 9a. Sentence-case\n    if text and text[0].islower():\n        text = text[0].upper() + text[1:]\n\n    # 9b. Terminal punctuation\n    if text and text[-1] not in \".!?\":\n        text += \".\"\n\n    # 10. Fallback: require at least 3 alphabetic tokens\n    alpha_words = [w for w in text.split() if re.search(r\"[a-zA-Z]{2,}\", w)]\n    if len(alpha_words) < 3:\n        return FALLBACK_TRANSLATION\n\n    return text\n\n\n# ── 4. Input-side text processing ────────────────────────────────────────────\n_V2 = re.compile(r\"([aAeEiIuU])(?:2|₂)\")\n_V3 = re.compile(r\"([aAeEiIuU])(?:3|₃)\")\n_ACUTE = str.maketrans({\"a\":\"á\",\"e\":\"é\",\"i\":\"í\",\"u\":\"ú\",\"A\":\"Á\",\"E\":\"É\",\"I\":\"Í\",\"U\":\"Ú\"})\n_GRAVE = str.maketrans({\"a\":\"à\",\"e\":\"è\",\"i\":\"ì\",\"u\":\"ù\",\"A\":\"À\",\"E\":\"È\",\"I\":\"Ì\",\"U\":\"Ù\"})\n\ndef _ascii_to_diacritics(s):\n    s = s.replace(\"sz\",\"š\").replace(\"SZ\",\"Š\")\n    s = s.replace(\"s,\",\"ṣ\").replace(\"S,\",\"Ṣ\")\n    s = s.replace(\"t,\",\"ṭ\").replace(\"T,\",\"Ṭ\")\n    s = _V2.sub(lambda m: m.group(1).translate(_ACUTE), s)\n    s = _V3.sub(lambda m: m.group(1).translate(_GRAVE), s)\n    return s\n\n_WS_RE          = re.compile(r\"\\s+\")\n_GAP_UNIFIED_RE = re.compile(r\"<\\s*gap\\s*>|\\.{3,}|…+|\\[\\.+\\]|\\bx\\b\", re.I)\n\ndef _normalize_gaps_vec(ser):\n    return ser.str.replace(_GAP_UNIFIED_RE, \"<gap>\", regex=True)\n\nclass OptimizedPreprocessor:\n    def preprocess_batch(self, texts):\n        ser = pd.Series(texts).fillna(\"\").astype(str)\n        ser = ser.apply(_ascii_to_diacritics)\n        ser = _normalize_gaps_vec(ser)\n        ser = ser.str.replace(_WS_RE, \" \", regex=True)\n        return ser.str.strip().tolist()\n\nclass VectorizedPostprocessor:\n    \"\"\"Routes every candidate through the strict clean_english() cleaner.\"\"\"\n    def postprocess_batch(self, texts):\n        return [clean_english(t) for t in texts]\n\n\n# ── 5. Dataset ────────────────────────────────────────────────────────────────\nclass AkkadianDataset(Dataset):\n    def __init__(self, df, preprocessor, logger):\n        self.sample_ids  = df[\"id\"].tolist()\n        proc             = preprocessor.preprocess_batch(df[\"transliteration\"].tolist())\n        self.input_texts = [\"translate Akkadian to English: \" + t for t in proc]\n        logger.info(f\"Dataset size: {len(self.sample_ids)}\")\n    def __len__(self):\n        return len(self.sample_ids)\n    def __getitem__(self, idx):\n        return self.sample_ids[idx], self.input_texts[idx]\n\n\n# ── 6. Model wrapper ──────────────────────────────────────────────────────────\nclass ModelWrapper:\n    def __init__(self, model_path, cfg, logger, label):\n        self.cfg    = cfg\n        self.logger = logger\n        self.label  = label\n        logger.info(f\"Loading {label} from local path: {model_path}\")\n        self.tokenizer = AutoTokenizer.from_pretrained(\n            model_path, local_files_only=True)\n        self.model = (\n            AutoModelForSeq2SeqLM\n            .from_pretrained(model_path, local_files_only=True)\n            .to(cfg.device)\n            .eval()\n        )\n\n    def collate(self, batch):\n        ids   = [b[0] for b in batch]\n        texts = [b[1] for b in batch]\n        enc   = self.tokenizer(\n            texts, padding=True, truncation=True,\n            max_length=self.cfg.max_input_length, return_tensors=\"pt\"\n        )\n        return ids, enc\n\n    def generate_candidates(self, input_ids, mask):\n        cfg = self.cfg\n        B   = input_ids.shape[0]\n        ctx = _bf16_ctx(cfg.device, cfg.use_bf16_amp)\n        with ctx:\n            beam_out = self.model.generate(\n                input_ids=input_ids, attention_mask=mask,\n                num_beams=cfg.num_beams, num_return_sequences=cfg.num_beam_cands,\n                do_sample=False, max_new_tokens=cfg.max_new_tokens,\n                length_penalty=cfg.length_penalty,\n                repetition_penalty=cfg.repetition_penalty\n            )\n            beam_texts = self.tokenizer.batch_decode(beam_out, skip_special_tokens=True)\n\n            samp_out = self.model.generate(\n                input_ids=input_ids, attention_mask=mask,\n                do_sample=True, num_return_sequences=cfg.num_sample_cands,\n                top_p=cfg.mbr_top_p, temperature=cfg.mbr_temperature,\n                max_new_tokens=cfg.max_new_tokens\n            )\n            samp_texts = self.tokenizer.batch_decode(samp_out, skip_special_tokens=True)\n\n        Rb, Rs = cfg.num_beam_cands, cfg.num_sample_cands\n        pools  = []\n        for i in range(B):\n            pool  = list(beam_texts[i * Rb:(i + 1) * Rb])\n            pool += list(samp_texts[i * Rs:(i + 1) * Rs])\n            pools.append(pool)\n        return pools\n\n\n# ── 7. MBR selector ───────────────────────────────────────────────────────────\nclass MBRSelector:\n    def __init__(self, pool_cap=40):\n        self.metric   = sacrebleu.metrics.CHRF(word_order=2)\n        self.pool_cap = pool_cap\n\n    def _score(self, a, b):\n        if not a or not b:\n            return 0.0\n        return self.metric.sentence_score(a, [b]).score\n\n    def pick(self, candidates):\n        candidates = list(dict.fromkeys(candidates))\n        candidates = candidates[:self.pool_cap]\n        if len(candidates) == 1:\n            return candidates[0]\n        best, best_score = None, -1.0\n        for i, a in enumerate(candidates):\n            score = sum(self._score(a, b)\n                        for j, b in enumerate(candidates) if i != j)\n            score /= max(1, len(candidates) - 1)\n            if score > best_score:\n                best_score = score\n                best       = a\n        return best\n\n\n# ── 8. Ensemble engine ────────────────────────────────────────────────────────\nclass EnsembleEngine:\n    def __init__(self, cfg, logger):\n        self.cfg           = cfg\n        self.logger        = logger\n        self.preprocessor  = OptimizedPreprocessor()\n        self.postprocessor = VectorizedPostprocessor()\n        self.mbr           = MBRSelector(cfg.mbr_pool_cap)\n\n    def run(self, test_df):\n        dataset  = AkkadianDataset(test_df, self.preprocessor, self.logger)\n        wrapperA = ModelWrapper(self.cfg.model_a_path, self.cfg, self.logger, \"Model-A\")\n        wrapperB = ModelWrapper(self.cfg.model_b_path, self.cfg, self.logger, \"Model-B\")\n\n        loader = DataLoader(\n            dataset, batch_size=self.cfg.batch_size,\n            shuffle=False, collate_fn=wrapperA.collate\n        )\n\n        results            = []\n        per_sample_records = []\n\n        for ids, enc in tqdm(loader):\n            input_ids = enc.input_ids.to(self.cfg.device)\n            mask      = enc.attention_mask.to(self.cfg.device)\n\n            batch_pools_A = wrapperA.generate_candidates(input_ids, mask)\n            batch_pools_B = wrapperB.generate_candidates(input_ids, mask)\n\n            for sid, poolA, poolB in zip(ids, batch_pools_A, batch_pools_B):\n                # Clean every candidate to strict English before MBR scoring\n                pool    = poolA + poolB\n                pool    = self.postprocessor.postprocess_batch(pool)\n                deduped = list(dict.fromkeys(pool))[:self.cfg.mbr_pool_cap]\n\n                # Remove fallback strings from the pool before MBR\n                # (fallbacks add no information to consensus scoring)\n                scored_pool = [c for c in deduped if c != FALLBACK_TRANSLATION]\n                if not scored_pool:\n                    best = FALLBACK_TRANSLATION\n                else:\n                    best = self.mbr.pick(scored_pool)\n\n                # Final clean pass on the winning candidate\n                best = clean_english(best)\n\n                # Compute MBR consensus score for logging\n                metric = sacrebleu.metrics.CHRF(word_order=2)\n                if len(scored_pool) > 1:\n                    scores = []\n                    for a in scored_pool:\n                        s = sum(metric.sentence_score(a, [b]).score\n                                for b in scored_pool if b != a)\n                        scores.append(s / max(1, len(scored_pool) - 1))\n                    best_score = max(scores)\n                else:\n                    best_score = 0.0\n\n                per_sample_records.append({\n                    \"id\":          sid,\n                    \"translation\": best,\n                    \"pool_size\":   len(deduped),\n                    \"mbr_score\":   best_score,\n                    \"output_len\":  len(best.split()),\n                    \"is_fallback\": best == FALLBACK_TRANSLATION,\n                })\n                results.append(best)\n\n        self._records = per_sample_records\n        return results\n\n\n# =============================================================================\n#  COMPETITION SCORING  (exact official metric)\n# =============================================================================\n\nclass ParticipantVisibleError(Exception):\n    pass\n\n\ndef competition_score(\n    solution: pd.DataFrame,\n    submission: pd.DataFrame,\n    row_id_column_name: str = \"id\",\n    text_column_name: str   = \"translation\",\n) -> float:\n    \"\"\"\n    Geometric mean of micro-averaged corpus BLEU and corpus ChrF++.\n    score = sqrt(BLEU * ChrF++)   [0-100 scale]\n    \"\"\"\n    sol = solution.copy()\n    sub = submission.copy()\n\n    if row_id_column_name in sol.columns:\n        del sol[row_id_column_name]\n    if row_id_column_name in sub.columns:\n        del sub[row_id_column_name]\n\n    if not (\n        pandas.api.types.is_string_dtype(sub[text_column_name])\n        or pandas.api.types.is_object_dtype(sub[text_column_name])\n    ):\n        raise ParticipantVisibleError(\n            f\"Submission column '{text_column_name}' must be of string type.\"\n        )\n\n    references = sol[text_column_name].astype(str).tolist()\n    hypotheses = sub[text_column_name].astype(str).tolist()\n\n    bleu = sacrebleu.corpus_bleu(hypotheses, [references])\n    chrf = sacrebleu.corpus_chrf(hypotheses, [references], word_order=2)\n    geo  = math.sqrt(bleu.score * chrf.score)\n\n    print(f\"\\n{'='*55}\")\n    print(f\"  COMPETITION SCORING  (micro-averaged corpus metrics)\")\n    print(f\"{'='*55}\")\n    print(f\"  BLEU       : {bleu.score:6.2f}\")\n    print(f\"  ChrF++     : {chrf.score:6.2f}\")\n    print(f\"  Geo-Mean   : {geo:6.2f}  <- official score\")\n    print(f\"{'='*55}\\n\")\n\n    return geo\n\n\n# =============================================================================\n#  PLOT HELPERS\n# =============================================================================\n\ndef _save_show(fig, path):\n    plt.show()\n    fig.savefig(path, dpi=PLOT_DPI, bbox_inches=\"tight\")\n    plt.close(fig)\n    print(f\"  [saved] {path}\")\n\n# ── EDA ───────────────────────────────────────────────────────────────────────\n\ndef plot_eda_input_length_distribution(df, output_dir):\n    char_lens = df[\"transliteration\"].fillna(\"\").astype(str).str.len()\n    tok_lens  = df[\"transliteration\"].fillna(\"\").astype(str).str.split().str.len()\n    fig, axes = plt.subplots(1, 2, figsize=(14, 5))\n    fig.suptitle(\"EDA — Input Length Distribution\", fontweight=\"bold\")\n    for ax, data, label in zip(axes,\n                                [char_lens, tok_lens],\n                                [\"Character Length\", \"Word-Token Length\"]):\n        ax.hist(data, bins=40, color=\"#4C72B0\", edgecolor=\"white\")\n        ax.axvline(data.median(), color=\"red\", lw=1.5, linestyle=\"--\",\n                   label=f\"Median {data.median():.0f}\")\n        ax.set_title(label); ax.set_xlabel(label.split()[0])\n        ax.set_ylabel(\"Count\"); ax.legend()\n    plt.tight_layout()\n    _save_show(fig, os.path.join(output_dir, \"eda_01_input_length_distribution.png\"))\n\ndef plot_eda_gap_token_frequency(df, output_dir):\n    proc       = OptimizedPreprocessor().preprocess_batch(df[\"transliteration\"].tolist())\n    gap_counts = [t.count(\"<gap>\") for t in proc]\n    counts     = Counter(gap_counts)\n    keys       = sorted(counts.keys())\n    fig, ax    = plt.subplots(figsize=(12, 5))\n    ax.bar([str(k) for k in keys], [counts[k] for k in keys],\n           color=\"#55A868\", edgecolor=\"white\")\n    ax.set_title(\"EDA — <gap> Token Frequency per Sample\", fontweight=\"bold\")\n    ax.set_xlabel(\"Number of <gap> tokens\"); ax.set_ylabel(\"Number of samples\")\n    plt.tight_layout()\n    _save_show(fig, os.path.join(output_dir, \"eda_02_gap_token_frequency.png\"))\n\ndef plot_eda_diacritic_coverage(df, output_dir):\n    translits = df[\"transliteration\"].fillna(\"\").astype(str)\n    has_diac  = translits.str.contains(re.compile(r\"[āēīūáéíúàèìùšṣṭ]\", re.I)).sum()\n    fig, ax   = plt.subplots(figsize=(6, 6))\n    ax.pie([has_diac, len(translits) - has_diac],\n           labels=[\"Contains diacritics\", \"ASCII only\"], autopct=\"%1.1f%%\",\n           startangle=140, colors=[\"#4C72B0\", \"#C44E52\"],\n           wedgeprops=dict(edgecolor=\"white\", linewidth=1.5))\n    ax.set_title(\"EDA — Diacritic Coverage\", fontweight=\"bold\")\n    plt.tight_layout()\n    _save_show(fig, os.path.join(output_dir, \"eda_03_diacritic_coverage.png\"))\n\ndef plot_eda_top_unigrams(df, output_dir, top_n=30):\n    proc   = OptimizedPreprocessor().preprocess_batch(df[\"transliteration\"].tolist())\n    counts = Counter(\" \".join(proc).lower().split()).most_common(top_n)\n    if not counts: return\n    tokens, freqs = zip(*counts)\n    fig, ax = plt.subplots(figsize=(14, 6))\n    ax.barh(list(reversed(tokens)), list(reversed(freqs)),\n            color=sns.color_palette(CMAP_SEQ, len(tokens)), edgecolor=\"white\")\n    ax.set_title(f\"EDA — Top {top_n} Input Unigrams\", fontweight=\"bold\")\n    ax.set_xlabel(\"Frequency\")\n    plt.tight_layout()\n    _save_show(fig, os.path.join(output_dir, \"eda_04_top_unigrams.png\"))\n\ndef plot_eda_sample_id_distribution(df, output_dir):\n    ids = pd.to_numeric(df[\"id\"], errors=\"coerce\").dropna()\n    fig, ax = plt.subplots(figsize=(12, 4))\n    ax.hist(ids, bins=min(50, len(ids)), color=\"#8172B2\", edgecolor=\"white\")\n    ax.set_title(\"EDA — Sample ID Distribution\", fontweight=\"bold\")\n    ax.set_xlabel(\"Sample ID\"); ax.set_ylabel(\"Count\")\n    plt.tight_layout()\n    _save_show(fig, os.path.join(output_dir, \"eda_05_sample_id_distribution.png\"))\n\n# ── Evaluation ────────────────────────────────────────────────────────────────\n\ndef plot_eval_mbr_score_distribution(records, output_dir):\n    scores = [r[\"mbr_score\"] for r in records]\n    fig, ax = plt.subplots(figsize=(10, 5))\n    ax.hist(scores, bins=30, color=\"#4C72B0\", edgecolor=\"white\")\n    ax.axvline(np.mean(scores),   color=\"red\",    lw=1.5, linestyle=\"--\",\n               label=f\"Mean {np.mean(scores):.2f}\")\n    ax.axvline(np.median(scores), color=\"orange\", lw=1.5, linestyle=\":\",\n               label=f\"Median {np.median(scores):.2f}\")\n    ax.set_title(\"Evaluation — MBR Consensus Score Distribution\", fontweight=\"bold\")\n    ax.set_xlabel(\"ChrF++ Consensus Score\"); ax.set_ylabel(\"Count\"); ax.legend()\n    plt.tight_layout()\n    _save_show(fig, os.path.join(output_dir, \"eval_01_mbr_score_distribution.png\"))\n\ndef plot_eval_output_length_distribution(records, output_dir):\n    lengths = [r[\"output_len\"] for r in records]\n    fig, ax = plt.subplots(figsize=(10, 5))\n    ax.hist(lengths, bins=30, color=\"#DD8452\", edgecolor=\"white\")\n    ax.axvline(np.mean(lengths),   color=\"red\",    lw=1.5, linestyle=\"--\",\n               label=f\"Mean {np.mean(lengths):.1f}\")\n    ax.axvline(np.median(lengths), color=\"orange\", lw=1.5, linestyle=\":\",\n               label=f\"Median {np.median(lengths):.1f}\")\n    ax.set_title(\"Evaluation — Output Word-Length Distribution\", fontweight=\"bold\")\n    ax.set_xlabel(\"Words in Translation\"); ax.set_ylabel(\"Count\"); ax.legend()\n    plt.tight_layout()\n    _save_show(fig, os.path.join(output_dir, \"eval_02_output_length_distribution.png\"))\n\ndef plot_eval_pool_size_distribution(records, output_dir):\n    sizes  = [r[\"pool_size\"] for r in records]\n    counts = Counter(sizes)\n    keys   = sorted(counts.keys())\n    fig, ax = plt.subplots(figsize=(10, 5))\n    ax.bar([str(k) for k in keys], [counts[k] for k in keys],\n           color=\"#55A868\", edgecolor=\"white\")\n    ax.set_title(\"Evaluation — Deduplicated MBR Pool Size per Sample\", fontweight=\"bold\")\n    ax.set_xlabel(\"Pool Size (unique candidates)\"); ax.set_ylabel(\"Number of Samples\")\n    plt.tight_layout()\n    _save_show(fig, os.path.join(output_dir, \"eval_03_pool_size_distribution.png\"))\n\ndef plot_eval_mbr_score_vs_output_length(records, output_dir):\n    scores  = [r[\"mbr_score\"]  for r in records]\n    lengths = [r[\"output_len\"] for r in records]\n    fig, ax = plt.subplots(figsize=(10, 6))\n    ax.scatter(lengths, scores, alpha=0.6, edgecolors=\"white\",\n               linewidths=0.4, color=\"#4C72B0\", s=60)\n    if len(scores) > 2:\n        z  = np.polyfit(lengths, scores, 1)\n        xs = np.linspace(min(lengths), max(lengths), 200)\n        ax.plot(xs, np.poly1d(z)(xs), color=\"red\", lw=1.5,\n                linestyle=\"--\", label=\"Trend\")\n        ax.legend()\n    ax.set_title(\"Evaluation — MBR Score vs Output Length\", fontweight=\"bold\")\n    ax.set_xlabel(\"Output Word Count\"); ax.set_ylabel(\"MBR Consensus Score (ChrF++)\")\n    plt.tight_layout()\n    _save_show(fig, os.path.join(output_dir, \"eval_04_mbr_score_vs_output_length.png\"))\n\ndef plot_eval_fallback_rate(records, output_dir):\n    n_fallback = sum(r[\"is_fallback\"] for r in records)\n    n_normal   = len(records) - n_fallback\n    fig, ax    = plt.subplots(figsize=(6, 6))\n    ax.pie([n_normal, n_fallback],\n           labels=[\"Normal Translation\", \"Fallback (empty output)\"],\n           autopct=\"%1.1f%%\", startangle=90,\n           colors=[\"#55A868\", \"#C44E52\"],\n           wedgeprops=dict(edgecolor=\"white\", linewidth=1.5))\n    ax.set_title(\"Evaluation — Fallback Rate\", fontweight=\"bold\")\n    plt.tight_layout()\n    _save_show(fig, os.path.join(output_dir, \"eval_05_fallback_rate.png\"))\n\ndef plot_eval_output_top_unigrams(records, output_dir, top_n=30):\n    tokens = []\n    for r in records:\n        if not r[\"is_fallback\"]:\n            tokens.extend(r[\"translation\"].lower().split())\n    if not tokens: return\n    counts = Counter(tokens).most_common(top_n)\n    tok, freq = zip(*counts)\n    fig, ax = plt.subplots(figsize=(14, 6))\n    ax.barh(list(reversed(tok)), list(reversed(freq)),\n            color=sns.color_palette(\"Oranges_r\", len(tok)), edgecolor=\"white\")\n    ax.set_title(f\"Evaluation — Top {top_n} Output Unigrams\", fontweight=\"bold\")\n    ax.set_xlabel(\"Frequency\")\n    plt.tight_layout()\n    _save_show(fig, os.path.join(output_dir, \"eval_06_output_top_unigrams.png\"))\n\ndef plot_eval_chrf_per_sample(records, output_dir):\n    sorted_recs = sorted(records, key=lambda r: r[\"mbr_score\"])\n    ids    = [str(r[\"id\"])   for r in sorted_recs]\n    scores = [r[\"mbr_score\"] for r in sorted_recs]\n    MAX_BARS = 80\n    if len(ids) > MAX_BARS:\n        half   = MAX_BARS // 2\n        ids    = ids[:half] + [\"...\"] + ids[-half:]\n        scores = scores[:half] + [0]  + scores[-half:]\n    palette = [(\"#C44E52\" if s < 20 else \"#DD8452\" if s < 40 else \"#55A868\")\n               for s in scores]\n    fig, ax = plt.subplots(figsize=(10, max(6, len(ids) * 0.25)))\n    ax.barh(ids, scores, color=palette, edgecolor=\"white\")\n    ax.set_title(\"Evaluation — Per-Sample MBR Score (sorted)\", fontweight=\"bold\")\n    ax.set_xlabel(\"ChrF++ Consensus Score\"); ax.set_ylabel(\"Sample ID\")\n    plt.tight_layout()\n    _save_show(fig, os.path.join(output_dir, \"eval_07_per_sample_chrf.png\"))\n\ndef plot_eval_competition_score_breakdown(bleu_score, chrf_score, geo_mean, output_dir):\n    labels = [\"BLEU\", \"ChrF++\", \"Geo-Mean\\n(Official)\"]\n    values = [bleu_score, chrf_score, geo_mean]\n    colors = [\"#4C72B0\", \"#DD8452\", \"#55A868\"]\n    fig, ax = plt.subplots(figsize=(8, 6))\n    bars = ax.bar(labels, values, color=colors, edgecolor=\"white\", width=0.5)\n    ax.set_ylim(0, 105)\n    ax.axhline(100, color=\"grey\", lw=0.8, linestyle=\"--\", alpha=0.5)\n    ax.set_title(\"Evaluation — Competition Score Breakdown\", fontweight=\"bold\")\n    ax.set_ylabel(\"Score (0–100)\")\n    for bar, val in zip(bars, values):\n        ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 1.5,\n                f\"{val:.2f}\", ha=\"center\", va=\"bottom\", fontweight=\"bold\")\n    plt.tight_layout()\n    _save_show(fig, os.path.join(output_dir, \"eval_08_competition_score_breakdown.png\"))\n\n# ── Generalization ────────────────────────────────────────────────────────────\n\ndef plot_gen_input_vs_output_length(df, records, output_dir):\n    in_lens  = df[\"transliteration\"].fillna(\"\").astype(str).str.len().tolist()\n    out_lens = [r[\"output_len\"] for r in records]\n    n        = min(len(in_lens), len(out_lens))\n    in_lens, out_lens = in_lens[:n], out_lens[:n]\n    fig, ax = plt.subplots(figsize=(10, 6))\n    ax.scatter(in_lens, out_lens, alpha=0.6, edgecolors=\"white\",\n               linewidths=0.4, color=\"#8172B2\", s=60)\n    if n > 2:\n        z  = np.polyfit(in_lens, out_lens, 1)\n        xs = np.linspace(min(in_lens), max(in_lens), 200)\n        ax.plot(xs, np.poly1d(z)(xs), color=\"red\", lw=1.5, linestyle=\"--\", label=\"Trend\")\n        ax.legend()\n    ax.set_title(\"Generalization — Input Character Length vs Output Word Count\",\n                 fontweight=\"bold\")\n    ax.set_xlabel(\"Input Character Length\"); ax.set_ylabel(\"Output Word Count\")\n    plt.tight_layout()\n    _save_show(fig, os.path.join(output_dir, \"gen_01_input_vs_output_length.png\"))\n\ndef plot_gen_mbr_score_by_input_bucket(df, records, output_dir, n_buckets=8):\n    in_lens = df[\"transliteration\"].fillna(\"\").astype(str).str.len().tolist()\n    scores  = [r[\"mbr_score\"] for r in records]\n    n       = min(len(in_lens), len(scores))\n    tmp     = pd.DataFrame({\"input_len\": in_lens[:n], \"mbr_score\": scores[:n]})\n    tmp[\"bucket\"] = pd.qcut(tmp[\"input_len\"], q=n_buckets, duplicates=\"drop\")\n    agg    = tmp.groupby(\"bucket\", observed=True)[\"mbr_score\"].agg([\"mean\",\"std\"]).reset_index()\n    labels = [str(b) for b in agg[\"bucket\"]]\n    fig, ax = plt.subplots(figsize=(13, 6))\n    x = np.arange(len(labels))\n    ax.bar(x, agg[\"mean\"], yerr=agg[\"std\"].fillna(0), capsize=4,\n           color=sns.color_palette(CMAP_DIV, len(labels)), edgecolor=\"white\")\n    ax.set_xticks(x); ax.set_xticklabels(labels, rotation=30, ha=\"right\", fontsize=8)\n    ax.set_title(\"Generalization — Mean MBR Score by Input-Length Bucket\",\n                 fontweight=\"bold\")\n    ax.set_xlabel(\"Input Character-Length Bucket\")\n    ax.set_ylabel(\"Mean ChrF++ Consensus Score\")\n    plt.tight_layout()\n    _save_show(fig, os.path.join(output_dir, \"gen_02_mbr_score_by_input_bucket.png\"))\n\ndef plot_gen_gap_count_vs_mbr_score(df, records, output_dir):\n    proc       = OptimizedPreprocessor().preprocess_batch(df[\"transliteration\"].tolist())\n    gap_counts = [t.count(\"<gap>\") for t in proc]\n    scores     = [r[\"mbr_score\"] for r in records]\n    n          = min(len(gap_counts), len(scores))\n    tmp        = pd.DataFrame({\"gap_count\": gap_counts[:n], \"mbr_score\": scores[:n]})\n    tmp[\"gap_group\"] = tmp[\"gap_count\"].clip(upper=5).astype(int).astype(str)\n    tmp.loc[tmp[\"gap_count\"] >= 5, \"gap_group\"] = \"5+\"\n    order = list(dict.fromkeys([str(i) for i in range(6)] +\n                                ([\"5+\"] if \"5+\" in tmp[\"gap_group\"].values else [])))\n    fig, ax = plt.subplots(figsize=(12, 6))\n    sns.boxplot(data=tmp, x=\"gap_group\", y=\"mbr_score\",\n                order=order, palette=CMAP_DIV, ax=ax)\n    ax.set_title(\"Generalization — MBR Score vs <gap> Token Count\", fontweight=\"bold\")\n    ax.set_xlabel(\"Number of <gap> Tokens in Input\")\n    ax.set_ylabel(\"MBR Consensus Score (ChrF++)\")\n    plt.tight_layout()\n    _save_show(fig, os.path.join(output_dir, \"gen_03_gap_count_vs_mbr_score.png\"))\n\ndef plot_gen_pool_diversity_heatmap(records, output_dir, sample_n=20):\n    transl = [r[\"translation\"] for r in records if not r[\"is_fallback\"]]\n    if len(transl) < 2: return\n    sample = random.sample(transl, min(sample_n, len(transl)))\n    metric = sacrebleu.metrics.CHRF(word_order=2)\n    n      = len(sample)\n    mat    = np.array([[metric.sentence_score(sample[i], [sample[j]]).score\n                        for j in range(n)] for i in range(n)])\n    fig, ax = plt.subplots(figsize=(10, 8))\n    sns.heatmap(mat, ax=ax, cmap=CMAP_SEQ, vmin=0, vmax=100,\n                xticklabels=False, yticklabels=False,\n                cbar_kws={\"label\": \"ChrF++ similarity\"})\n    ax.set_title(f\"Generalization — Pairwise Translation Similarity (n={n})\",\n                 fontweight=\"bold\")\n    plt.tight_layout()\n    _save_show(fig, os.path.join(output_dir, \"gen_04_pool_diversity_heatmap.png\"))\n\ndef plot_gen_cumulative_mbr_score(records, output_dir):\n    scores = sorted(r[\"mbr_score\"] for r in records)\n    cdf    = np.arange(1, len(scores) + 1) / len(scores)\n    fig, ax = plt.subplots(figsize=(10, 5))\n    ax.plot(scores, cdf, color=\"#4C72B0\", lw=2)\n    ax.fill_between(scores, cdf, alpha=0.15, color=\"#4C72B0\")\n    for thr, col in [(20, \"red\"), (40, \"orange\"), (60, \"green\")]:\n        frac = np.mean(np.array(scores) >= thr)\n        ax.axvline(thr, color=col, lw=1.2, linestyle=\"--\",\n                   label=f\">={thr}: {frac*100:.1f}% of samples\")\n    ax.set_title(\"Generalization — Cumulative Distribution of MBR Scores\",\n                 fontweight=\"bold\")\n    ax.set_xlabel(\"MBR Consensus Score (ChrF++)\")\n    ax.set_ylabel(\"Cumulative Fraction\"); ax.legend()\n    plt.tight_layout()\n    _save_show(fig, os.path.join(output_dir, \"gen_05_cumulative_mbr_score.png\"))\n\ndef plot_gen_output_length_ratio(df, records, output_dir):\n    in_tok  = df[\"transliteration\"].fillna(\"\").astype(str).str.split().str.len()\n    out_tok = [r[\"output_len\"] for r in records]\n    n       = min(len(in_tok), len(out_tok))\n    ratios  = [out_tok[i] / max(1, in_tok.iloc[i]) for i in range(n)]\n    fig, ax = plt.subplots(figsize=(10, 5))\n    ax.hist(ratios, bins=30, color=\"#64B5CD\", edgecolor=\"white\")\n    ax.axvline(np.mean(ratios), color=\"red\", lw=1.5, linestyle=\"--\",\n               label=f\"Mean {np.mean(ratios):.2f}x\")\n    ax.set_title(\"Generalization — Output/Input Length Ratio\", fontweight=\"bold\")\n    ax.set_xlabel(\"Output Tokens / Input Tokens\")\n    ax.set_ylabel(\"Count\"); ax.legend()\n    plt.tight_layout()\n    _save_show(fig, os.path.join(output_dir, \"gen_06_output_length_ratio.png\"))\n\n\ndef run_all_plots(test_df, records, output_dir,\n                  bleu_score=None, chrf_score=None, geo_mean=None):\n    print(\"\\n\" + \"=\"*60)\n    print(\"  GENERATING PLOTS\")\n    print(\"=\"*60)\n\n    print(\"\\n-- EDA plots --\")\n    plot_eda_input_length_distribution(test_df, output_dir)\n    plot_eda_gap_token_frequency(test_df, output_dir)\n    plot_eda_diacritic_coverage(test_df, output_dir)\n    plot_eda_top_unigrams(test_df, output_dir)\n    plot_eda_sample_id_distribution(test_df, output_dir)\n\n    print(\"\\n-- Evaluation plots --\")\n    plot_eval_mbr_score_distribution(records, output_dir)\n    plot_eval_output_length_distribution(records, output_dir)\n    plot_eval_pool_size_distribution(records, output_dir)\n    plot_eval_mbr_score_vs_output_length(records, output_dir)\n    plot_eval_fallback_rate(records, output_dir)\n    plot_eval_output_top_unigrams(records, output_dir)\n    plot_eval_chrf_per_sample(records, output_dir)\n    if bleu_score is not None:\n        plot_eval_competition_score_breakdown(bleu_score, chrf_score, geo_mean, output_dir)\n\n    print(\"\\n-- Generalization plots --\")\n    plot_gen_input_vs_output_length(test_df, records, output_dir)\n    plot_gen_mbr_score_by_input_bucket(test_df, records, output_dir)\n    plot_gen_gap_count_vs_mbr_score(test_df, records, output_dir)\n    plot_gen_pool_diversity_heatmap(records, output_dir)\n    plot_gen_cumulative_mbr_score(records, output_dir)\n    plot_gen_output_length_ratio(test_df, records, output_dir)\n\n    print(f\"\\n  All plots saved to: {output_dir}\")\n    print(\"=\"*60 + \"\\n\")\n\n\n# =============================================================================\n#  ENTRY POINT\n# =============================================================================\ncfg    = EnsembleMBRConfig()\nlogger = setup_logging(cfg.output_dir)\nprint_env(cfg)\n\ntest_df = pd.read_csv(cfg.test_data_path)\nprint(\"Test size:\", len(test_df))\n\n# ── Pre-inference EDA ─────────────────────────────────────────────────────────\nprint(\"\\n-- Pre-inference EDA --\")\nplot_eda_input_length_distribution(test_df, cfg.output_dir)\nplot_eda_gap_token_frequency(test_df, cfg.output_dir)\nplot_eda_diacritic_coverage(test_df, cfg.output_dir)\nplot_eda_top_unigrams(test_df, cfg.output_dir)\nplot_eda_sample_id_distribution(test_df, cfg.output_dir)\n\n# ── Inference ─────────────────────────────────────────────────────────────────\nengine       = EnsembleEngine(cfg, logger)\ntranslations = engine.run(test_df)\nrecords      = engine._records\n\n# ── Submission CSV ────────────────────────────────────────────────────────────\nsubmission = pd.DataFrame({\n    \"id\":          test_df[\"id\"].tolist(),\n    \"translation\": [str(t) for t in translations],\n})\n\noutput_path = \"/kaggle/working/submission.csv\"\nsubmission.to_csv(output_path, index=False)\n\nprint(\"\\nSubmission saved to:\", output_path)\nprint(\"File exists:\", os.path.exists(output_path))\n\n# ── Validate all translations are clean ASCII English ─────────────────────────\nprint(\"\\n-- Translation quality check --\")\nfor i, row in submission.iterrows():\n    t = str(row[\"translation\"])\n    # Check for any non-ASCII character that should have been stripped\n    non_ascii = [c for c in t if ord(c) > 127]\n    if non_ascii:\n        print(f\"  [WARN] id={row['id']} still contains non-ASCII: {set(non_ascii)}\")\n        # Force a second clean pass\n        submission.at[i, \"translation\"] = clean_english(t)\n\n# Re-save after any correction pass\nsubmission.to_csv(output_path, index=False)\nprint(\"  All translations verified as ASCII English.\")\n\n# ── Competition scoring (skipped on live test set) ────────────────────────────\nsolution_path = \"/kaggle/input/competitions/deep-past-initiative-machine-translation/train.csv\"\nbleu_val = chrf_val = geo_val = None\n\nif os.path.exists(solution_path):\n    try:\n        solution_df = pd.read_csv(solution_path)\n        # align on shared ids\n        common_ids  = set(solution_df[\"id\"]).intersection(set(test_df[\"id\"]))\n        solution_df = solution_df[solution_df[\"id\"].isin(common_ids)].sort_values(\"id\")\n        sub_aligned = submission[submission[\"id\"].isin(common_ids)].sort_values(\"id\")\n\n        if \"translation\" in solution_df.columns and len(solution_df) > 0:\n            geo_val = competition_score(\n                solution   = solution_df[[\"id\", \"translation\"]].copy(),\n                submission = sub_aligned.copy(),\n            )\n            refs     = solution_df[\"translation\"].astype(str).tolist()\n            hyps     = sub_aligned[\"translation\"].astype(str).tolist()\n            bleu_val = sacrebleu.corpus_bleu(hyps, [refs]).score\n            chrf_val = sacrebleu.corpus_chrf(hyps, [refs], word_order=2).score\n        else:\n            print(\"[INFO] Solution file found but missing 'translation' column.\")\n    except Exception as exc:\n        print(f\"[INFO] Competition scoring skipped: {exc}\")\nelse:\n    print(\"[INFO] No solution file — competition scoring skipped (live test set).\")\n\n# ── Post-inference plots ──────────────────────────────────────────────────────\nrun_all_plots(test_df, records, cfg.output_dir,\n              bleu_score=bleu_val, chrf_score=chrf_val, geo_mean=geo_val)\n\n# ── Final preview ─────────────────────────────────────────────────────────────\nprint(\"\\nFiles in /kaggle/working:\")\nprint(os.listdir(\"/kaggle/working\"))\n\nprint(\"\\nSubmission preview:\")\nprint(submission.to_string(index=False))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-22T22:50:13.190542Z","iopub.execute_input":"2026-03-22T22:50:13.190881Z","iopub.status.idle":"2026-03-22T22:53:51.857707Z","shell.execute_reply.started":"2026-03-22T22:50:13.190850Z","shell.execute_reply":"2026-03-22T22:53:51.856691Z"}},"outputs":[],"execution_count":null},{"id":"cbda8ceb-dcc0-4d94-9fc4-52618a52d347","cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null}]}