script.py: aligned answer blocks, self-consistency voting, task-type format hints
Browse files
script.py
ADDED
|
@@ -0,0 +1,630 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
"""IOL-AI 2026 submission -- International Linguistics Olympiad solver.
|
| 3 |
+
|
| 4 |
+
Design notes (the eval sandbox is unforgiving, so these matter):
|
| 5 |
+
|
| 6 |
+
* HARD 30-MINUTE LIMIT. A killed process means no score at all, so the script
|
| 7 |
+
is structured as a monotonically-improving pipeline: it writes a complete,
|
| 8 |
+
correctly-shaped submission.csv *before* the model is even loaded, then
|
| 9 |
+
overwrites it after every improvement. Any crash or timeout leaves the best
|
| 10 |
+
result reached so far on disk.
|
| 11 |
+
* ALIGNMENT IS EVERYTHING. Each row is a problem block with N numbered items
|
| 12 |
+
and `pred` must be a JSON list of exactly N answers, in order. One missing
|
| 13 |
+
line shifts every later answer and zeroes the whole block on both metrics.
|
| 14 |
+
So N is detected from the query and the model output is force-fitted to it.
|
| 15 |
+
* NEVER EMIT AN EMPTY STRING. The final score is a geometric mean of exact
|
| 16 |
+
match and chrF, so an empty answer scores zero on both. A wrong guess is
|
| 17 |
+
strictly better than a blank.
|
| 18 |
+
* Environment is transformers 4.44.1 / torch 2.4.0 / autoawq on a 16GB T4
|
| 19 |
+
(fp16 only, no bf16, no flash-attn), with no internet.
|
| 20 |
+
"""
|
| 21 |
+
import os
|
| 22 |
+
import re
|
| 23 |
+
import json
|
| 24 |
+
import time
|
| 25 |
+
import unicodedata
|
| 26 |
+
from collections import Counter, defaultdict
|
| 27 |
+
|
| 28 |
+
T0 = time.time()
|
| 29 |
+
|
| 30 |
+
# The platform allows 30 minutes. Reserve a margin for model load overhead we
|
| 31 |
+
# can't predict and for the final write; being 60s early costs a little
|
| 32 |
+
# accuracy, being 1s late costs the entire submission.
|
| 33 |
+
TIME_LIMIT = float(os.environ.get("IOL_TIME_LIMIT", "1800"))
|
| 34 |
+
SAFETY = float(os.environ.get("IOL_SAFETY", "150"))
|
| 35 |
+
DEADLINE = T0 + TIME_LIMIT - SAFETY
|
| 36 |
+
|
| 37 |
+
TEST_CSV = os.environ.get("IOL_TEST_CSV", "/tmp/data/test.csv")
|
| 38 |
+
OUT_CSV = os.environ.get("IOL_OUT_CSV", "submission.csv")
|
| 39 |
+
MODEL_ID = os.environ.get("IOL_MODEL", ".")
|
| 40 |
+
WANT_EXPLANATION = os.environ.get("IOL_EXPLAIN", "1") == "1"
|
| 41 |
+
MAX_NEW = int(os.environ.get("IOL_MAXNEW", "900")) # reasoning budget/item
|
| 42 |
+
MAX_SAMPLES = int(os.environ.get("IOL_MAXSAMPLES", "8")) # self-consistency cap
|
| 43 |
+
|
| 44 |
+
os.environ.setdefault("HF_HUB_OFFLINE", "1")
|
| 45 |
+
os.environ.setdefault("TRANSFORMERS_OFFLINE", "1")
|
| 46 |
+
os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
|
| 47 |
+
# Reduce allocator fragmentation: at batch 4 the T4 has only ~2GB spare.
|
| 48 |
+
os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def log(msg):
|
| 52 |
+
print(f"[{time.time() - T0:7.1f}s] {msg}", flush=True)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def left():
|
| 56 |
+
return DEADLINE - time.time()
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
# ===========================================================================
|
| 60 |
+
# Item-count detection (validated: 98.4% of Linguini items land in
|
| 61 |
+
# correctly-sized blocks)
|
| 62 |
+
# ===========================================================================
|
| 63 |
+
|
| 64 |
+
_LINE_NUM = re.compile(r"^[ \t]*(\d{1,3})[.)\]]", re.M)
|
| 65 |
+
_PAREN_NUM = re.compile(r"\((\d{1,3})\)")
|
| 66 |
+
_RANGE = re.compile(r"\(?(\d{1,3})\s*(?:[-–—]|to)\s*(\d{1,3})\)?")
|
| 67 |
+
_LINE_LETTER = re.compile(r"^[ \t]*([A-Z])[.)\]]\s", re.M)
|
| 68 |
+
_PAREN_LETTER = re.compile(r"\(([A-Z])\)")
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def detect_n_items(query, task_type="", context=""):
|
| 72 |
+
"""How many numbered sub-items this problem asks for. Never < 1."""
|
| 73 |
+
q = query or ""
|
| 74 |
+
line_nums = [int(m) for m in _LINE_NUM.findall(q)]
|
| 75 |
+
paren_nums = [int(m) for m in _PAREN_NUM.findall(q)]
|
| 76 |
+
|
| 77 |
+
range_n = 0
|
| 78 |
+
for a, b in _RANGE.findall(q):
|
| 79 |
+
a, b = int(a), int(b)
|
| 80 |
+
if 0 < b - a < 60:
|
| 81 |
+
range_n = max(range_n, b - a + 1)
|
| 82 |
+
|
| 83 |
+
cand = max(len(set(line_nums)), len(set(paren_nums)))
|
| 84 |
+
if range_n and cand and range_n != cand:
|
| 85 |
+
# A stated range ("items 1-4") can disagree with the markers actually
|
| 86 |
+
# present; the markers are what we have to answer, so they win.
|
| 87 |
+
return cand
|
| 88 |
+
cand = max(cand,
|
| 89 |
+
len(set(_LINE_LETTER.findall(q))),
|
| 90 |
+
len(set(_PAREN_LETTER.findall(q))))
|
| 91 |
+
|
| 92 |
+
n = max(range_n, cand)
|
| 93 |
+
if n > 1:
|
| 94 |
+
return n
|
| 95 |
+
|
| 96 |
+
# Unnumbered "Translate into X:" followed by one item per line.
|
| 97 |
+
lines = [l.strip() for l in q.splitlines() if l.strip()]
|
| 98 |
+
if len(lines) > 1:
|
| 99 |
+
head = lines[0]
|
| 100 |
+
body = lines[1:] if head.endswith((":", ".")) else lines
|
| 101 |
+
if body:
|
| 102 |
+
return len(body)
|
| 103 |
+
|
| 104 |
+
# Bare instruction ("Determine the correct correspondences."): items are in
|
| 105 |
+
# the shared context (this is the match_letters shape).
|
| 106 |
+
if context:
|
| 107 |
+
c_nums = len(set(int(m) for m in _LINE_NUM.findall(context)))
|
| 108 |
+
if c_nums > 1:
|
| 109 |
+
return c_nums
|
| 110 |
+
c_lets = len(set(_LINE_LETTER.findall(context)))
|
| 111 |
+
if c_lets > 1:
|
| 112 |
+
return c_lets
|
| 113 |
+
|
| 114 |
+
return max(n, 1)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
# ===========================================================================
|
| 118 |
+
# Output parsing / repair
|
| 119 |
+
# ===========================================================================
|
| 120 |
+
|
| 121 |
+
_STRIP_PREFIX = re.compile(r"^\s*(?:\(?\d{1,3}\)?[.):\]]\s*|[-*•]\s+)")
|
| 122 |
+
_FENCE = re.compile(r"^```[a-zA-Z]*\s*$")
|
| 123 |
+
_CHATTY = re.compile(
|
| 124 |
+
r"^\s*(?:here (?:are|is)\b|answers?\s*:?\s*$|explanation\b|note\b|okay\b|"
|
| 125 |
+
r"solution\b|reasoning\b|analysis\b|translations?\s*:?\s*$|the answers?\b|"
|
| 126 |
+
r"let me\b|first,|so,|therefore\b|thus\b)",
|
| 127 |
+
re.I,
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def clean_line(s):
|
| 132 |
+
s = s.strip()
|
| 133 |
+
s = _STRIP_PREFIX.sub("", s)
|
| 134 |
+
s = s.strip().strip("`").strip()
|
| 135 |
+
if len(s) >= 2 and s[0] == s[-1] and s[0] in "\"'“”":
|
| 136 |
+
s = s[1:-1].strip()
|
| 137 |
+
# "word | gloss" answer lines: keep the side being asked for is ambiguous,
|
| 138 |
+
# so keep the whole line -- chrF still gives partial credit.
|
| 139 |
+
return s.strip()
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def extract_item_sources(query, n):
|
| 143 |
+
"""The source text of each numbered item, used as a last-resort fallback.
|
| 144 |
+
|
| 145 |
+
A blank scores zero on both metrics; echoing the item's own source string is
|
| 146 |
+
strictly better, and on transcription / fill-the-blank tasks the source and
|
| 147 |
+
the target share a lot of characters, so it collects real chrF credit.
|
| 148 |
+
"""
|
| 149 |
+
q = query or ""
|
| 150 |
+
out = []
|
| 151 |
+
for ln in q.splitlines():
|
| 152 |
+
s = ln.strip()
|
| 153 |
+
if not s:
|
| 154 |
+
continue
|
| 155 |
+
m = re.match(r"^\(?(\d{1,3})\)?[.):\]]\s*(.+)$", s)
|
| 156 |
+
if m:
|
| 157 |
+
out.append(m.group(2).strip())
|
| 158 |
+
if not out:
|
| 159 |
+
lines = [l.strip() for l in q.splitlines() if l.strip()]
|
| 160 |
+
if len(lines) > 1 and lines[0].endswith((":", ".")):
|
| 161 |
+
out = lines[1:]
|
| 162 |
+
# "form | gloss" items: the left side is the thing being asked about.
|
| 163 |
+
out = [o.split("|")[0].strip() if "|" in o else o for o in out]
|
| 164 |
+
out = [o for o in out if o]
|
| 165 |
+
while len(out) < n:
|
| 166 |
+
out.append(out[-1] if out else "?")
|
| 167 |
+
return out[:n]
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def parse_answers(text, n, fallback=None):
|
| 171 |
+
"""Raw model output -> exactly n non-empty answers."""
|
| 172 |
+
if not text:
|
| 173 |
+
return list(fallback[:n]) if fallback else ["?"] * n
|
| 174 |
+
|
| 175 |
+
# Prefer the explicit final block the prompt asks for.
|
| 176 |
+
m = None
|
| 177 |
+
for m2 in re.finditer(r"(?:^|\n)\s*(?:final\s+)?answers?\s*:\s*\n?", text, re.I):
|
| 178 |
+
m = m2
|
| 179 |
+
body = text[m.end():] if m else text
|
| 180 |
+
|
| 181 |
+
numbered, raw = [], []
|
| 182 |
+
for ln in body.splitlines():
|
| 183 |
+
if _FENCE.match(ln):
|
| 184 |
+
continue
|
| 185 |
+
mm = re.match(r"^\s*\(?(\d{1,3})\)?[.):\]]\s*(.+)$", ln.strip())
|
| 186 |
+
if mm:
|
| 187 |
+
val = clean_line(mm.group(2))
|
| 188 |
+
if val and not _CHATTY.match(val):
|
| 189 |
+
numbered.append((int(mm.group(1)), val))
|
| 190 |
+
c = clean_line(ln)
|
| 191 |
+
if c and not _CHATTY.match(c):
|
| 192 |
+
raw.append(c)
|
| 193 |
+
|
| 194 |
+
# If the model numbered its answers, trust those labels for placement.
|
| 195 |
+
if len(numbered) >= n:
|
| 196 |
+
by_label = {}
|
| 197 |
+
for lab, val in numbered:
|
| 198 |
+
by_label[lab] = val # last write wins (models restate)
|
| 199 |
+
labs = sorted(by_label)
|
| 200 |
+
if len(labs) >= n:
|
| 201 |
+
return [by_label[l] for l in labs[:n]]
|
| 202 |
+
|
| 203 |
+
return fit_to_n(raw, n, fallback)
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def fit_to_n(items, n, fallback=None):
|
| 207 |
+
items = [i for i in items if i and i.strip()]
|
| 208 |
+
if len(items) > n:
|
| 209 |
+
# Take the LAST n. The prompt asks for reasoning first and the answers
|
| 210 |
+
# last, so when there is no ANSWERS: marker to slice on, the tail is the
|
| 211 |
+
# answer block and the head is reasoning prose.
|
| 212 |
+
items = items[-n:]
|
| 213 |
+
while len(items) < n:
|
| 214 |
+
if fallback and len(items) < len(fallback):
|
| 215 |
+
items.append(fallback[len(items)])
|
| 216 |
+
else:
|
| 217 |
+
items.append(items[-1] if items else "?")
|
| 218 |
+
return items[:n]
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def norm(s):
|
| 222 |
+
s = unicodedata.normalize("NFC", (s or "").strip().lower())
|
| 223 |
+
s = re.sub(r"\s+", " ", s)
|
| 224 |
+
return s.strip(" .!?;:,")
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
# ===========================================================================
|
| 228 |
+
# chrF (inline, dependency-free) -- used only to pick the most "central"
|
| 229 |
+
# candidate when self-consistency voting has no majority. sacrebleu is not
|
| 230 |
+
# guaranteed to be importable inside the sandbox.
|
| 231 |
+
# ===========================================================================
|
| 232 |
+
|
| 233 |
+
def _ngrams(s, k):
|
| 234 |
+
s = re.sub(r"\s+", "", s)
|
| 235 |
+
return Counter(s[i:i + k] for i in range(len(s) - k + 1)) if len(s) >= k else Counter()
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
def chrf_sim(hyp, ref, order=6, beta=2.0):
|
| 239 |
+
if not hyp or not ref:
|
| 240 |
+
return 0.0
|
| 241 |
+
ps, rs = [], []
|
| 242 |
+
for k in range(1, order + 1):
|
| 243 |
+
h, r = _ngrams(hyp, k), _ngrams(ref, k)
|
| 244 |
+
if not h or not r:
|
| 245 |
+
continue
|
| 246 |
+
overlap = sum((h & r).values())
|
| 247 |
+
ps.append(overlap / max(1, sum(h.values())))
|
| 248 |
+
rs.append(overlap / max(1, sum(r.values())))
|
| 249 |
+
if not ps:
|
| 250 |
+
return 0.0
|
| 251 |
+
p, r = sum(ps) / len(ps), sum(rs) / len(rs)
|
| 252 |
+
if p + r == 0:
|
| 253 |
+
return 0.0
|
| 254 |
+
b2 = beta * beta
|
| 255 |
+
return (1 + b2) * p * r / (b2 * p + r)
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
def vote(cands):
|
| 259 |
+
"""Pick one answer from several samples of the same item.
|
| 260 |
+
|
| 261 |
+
Majority on a normalised form maximises exact match; when there is no
|
| 262 |
+
majority, the medoid by chrF maximises expected partial credit.
|
| 263 |
+
"""
|
| 264 |
+
cands = [c for c in cands if c and c.strip()]
|
| 265 |
+
if not cands:
|
| 266 |
+
return "?"
|
| 267 |
+
if len(cands) == 1:
|
| 268 |
+
return cands[0]
|
| 269 |
+
|
| 270 |
+
groups = defaultdict(list)
|
| 271 |
+
for c in cands:
|
| 272 |
+
groups[norm(c)].append(c)
|
| 273 |
+
best_key, best = None, -1
|
| 274 |
+
for k, v in groups.items():
|
| 275 |
+
if len(v) > best:
|
| 276 |
+
best_key, best = k, len(v)
|
| 277 |
+
if best > len(cands) / 2.0: # strict majority
|
| 278 |
+
return Counter(groups[best_key]).most_common(1)[0][0]
|
| 279 |
+
|
| 280 |
+
scored = []
|
| 281 |
+
for c in cands:
|
| 282 |
+
s = sum(chrf_sim(c, o) for o in cands if o is not c)
|
| 283 |
+
scored.append((s + 0.5 * len(groups[norm(c)]), c))
|
| 284 |
+
scored.sort(key=lambda t: (-t[0], len(t[1])))
|
| 285 |
+
return scored[0][1]
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
def repair_bijection(answers):
|
| 289 |
+
"""match_letters answers are usually a permutation of the option letters.
|
| 290 |
+
|
| 291 |
+
When every answer is a single letter and there are as many items as
|
| 292 |
+
distinct letters available, duplicates are certainly wrong. Reassign the
|
| 293 |
+
duplicated slots to the unused letters. Strictly guarded so it is a no-op
|
| 294 |
+
on anything that isn't this shape.
|
| 295 |
+
"""
|
| 296 |
+
if len(answers) < 3:
|
| 297 |
+
return answers
|
| 298 |
+
if not all(re.fullmatch(r"[A-Z]", a or "") for a in answers):
|
| 299 |
+
return answers
|
| 300 |
+
n = len(answers)
|
| 301 |
+
universe = [chr(ord("A") + i) for i in range(n)]
|
| 302 |
+
if len(set(answers)) == n:
|
| 303 |
+
return answers
|
| 304 |
+
unused = [l for l in universe if l not in set(answers)]
|
| 305 |
+
if not unused:
|
| 306 |
+
return answers
|
| 307 |
+
seen, out = set(), []
|
| 308 |
+
for a in answers:
|
| 309 |
+
if a in seen and unused:
|
| 310 |
+
out.append(unused.pop(0))
|
| 311 |
+
else:
|
| 312 |
+
seen.add(a)
|
| 313 |
+
out.append(a)
|
| 314 |
+
return out
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
# ===========================================================================
|
| 318 |
+
# Prompting
|
| 319 |
+
# ===========================================================================
|
| 320 |
+
|
| 321 |
+
SYSTEM = (
|
| 322 |
+
"You are a gold medallist at the International Linguistics Olympiad.\n"
|
| 323 |
+
"Each problem gives data from a language you have never seen. Everything "
|
| 324 |
+
"you need is in the problem itself; no outside knowledge is required or "
|
| 325 |
+
"allowed.\n"
|
| 326 |
+
"Method: line up the given examples, segment the words, identify the "
|
| 327 |
+
"recurring morphemes and the rules that order them, check your rules "
|
| 328 |
+
"against EVERY example, then apply them to the items asked for.\n"
|
| 329 |
+
"Be concise while reasoning. Then output a final block that begins with a "
|
| 330 |
+
"line containing exactly ANSWERS: followed by one answer per line, in the "
|
| 331 |
+
"order asked, with no numbering, no commentary and no blank lines.\n"
|
| 332 |
+
"Give your best guess for every item. Never leave one blank."
|
| 333 |
+
)
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
# Exact match is half the score, so the answer's *form* matters as much as its
|
| 337 |
+
# content. test.csv states the task type, so say precisely what a well-formed
|
| 338 |
+
# answer looks like. Unknown/absent types simply get no hint.
|
| 339 |
+
TASK_HINTS = {
|
| 340 |
+
"translation": "Each answer is the translation alone -- no source text, no "
|
| 341 |
+
"gloss, no notes, no quotation marks.",
|
| 342 |
+
"match_letters": "Each answer is a single capital letter identifying the "
|
| 343 |
+
"match for that numbered item. Every letter is used "
|
| 344 |
+
"exactly once, so no letter may repeat.",
|
| 345 |
+
"fill_blanks": "Each answer is only the missing form that belongs in that "
|
| 346 |
+
"blank -- not the whole line, not the gloss.",
|
| 347 |
+
"text_to_num": "Each answer is written in digits only (e.g. 111).",
|
| 348 |
+
"num_to_text": "Each answer is the number written out in the problem "
|
| 349 |
+
"language, words only.",
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
def build_prompt(row, n):
|
| 354 |
+
hint = TASK_HINTS.get((row.get("task_type") or "").strip().lower(), "")
|
| 355 |
+
return (
|
| 356 |
+
f"{row['context'].strip()}\n\n{row['query'].strip()}\n\n"
|
| 357 |
+
f"There are exactly {n} item{'s' if n != 1 else ''} to answer."
|
| 358 |
+
+ (f" {hint}" if hint else "") +
|
| 359 |
+
f"\nAfter your reasoning, write ANSWERS: on its own line and then exactly "
|
| 360 |
+
f"{n} line{'s' if n != 1 else ''}, one answer per item, in order."
|
| 361 |
+
)
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
EXPLAIN_SYSTEM = (
|
| 365 |
+
"You explain International Linguistics Olympiad solutions to a human judge. "
|
| 366 |
+
"Given a problem and the answers produced, state the key rules of the "
|
| 367 |
+
"language that justify them: the relevant morphemes, word order and any "
|
| 368 |
+
"sound changes. Be specific and concise (2-4 sentences or a few short "
|
| 369 |
+
"bullets). Do not restate the reasoning as a stream of thought."
|
| 370 |
+
)
|
| 371 |
+
|
| 372 |
+
|
| 373 |
+
def build_explain_prompt(row, answers):
|
| 374 |
+
return (
|
| 375 |
+
f"{row['context'].strip()}\n\n{row['query'].strip()}\n\n"
|
| 376 |
+
f"Answers given:\n" + "\n".join(f"- {a}" for a in answers) +
|
| 377 |
+
"\n\nBriefly explain the linguistic rules behind these answers."
|
| 378 |
+
)
|
| 379 |
+
|
| 380 |
+
|
| 381 |
+
# ===========================================================================
|
| 382 |
+
# Main
|
| 383 |
+
# ===========================================================================
|
| 384 |
+
|
| 385 |
+
def dev_score(preds):
|
| 386 |
+
"""Offline diagnostic: score against a gold file when IOL_GOLD is set.
|
| 387 |
+
|
| 388 |
+
Never runs on the platform (the answers are hidden, so the variable is
|
| 389 |
+
unset there); it exists so one benchmark run reveals the whole learning
|
| 390 |
+
curve -- greedy, then after each self-consistency pass -- instead of a
|
| 391 |
+
single final number.
|
| 392 |
+
"""
|
| 393 |
+
gold_path = os.environ.get("IOL_GOLD")
|
| 394 |
+
if not gold_path or not os.path.exists(gold_path):
|
| 395 |
+
return
|
| 396 |
+
try:
|
| 397 |
+
import ast
|
| 398 |
+
|
| 399 |
+
import pandas as pd
|
| 400 |
+
g = pd.read_csv(gold_path, dtype=str)
|
| 401 |
+
ems, cfs = [], []
|
| 402 |
+
for _, r in g.iterrows():
|
| 403 |
+
gold = ast.literal_eval(r["answer"])
|
| 404 |
+
p = preds.get(str(r["id"]), [])
|
| 405 |
+
p = list(p)[:len(gold)] + [""] * max(0, len(gold) - len(p))
|
| 406 |
+
for gi, pi in zip(gold, p):
|
| 407 |
+
alts = gi if isinstance(gi, (list, tuple)) else [gi]
|
| 408 |
+
alts = [str(a) for a in alts]
|
| 409 |
+
ems.append(1.0 if any(pi.strip() == a.strip() for a in alts) else 0.0)
|
| 410 |
+
cfs.append(max(chrf_sim(pi, a) for a in alts))
|
| 411 |
+
em = sum(ems) / max(1, len(ems))
|
| 412 |
+
cf = sum(cfs) / max(1, len(cfs))
|
| 413 |
+
log(f" [dev] EM={em:.4f} chrF~={cf:.4f} score~={(em * cf) ** 0.5:.4f} "
|
| 414 |
+
f"over {len(ems)} items")
|
| 415 |
+
except Exception as e:
|
| 416 |
+
log(f" [dev] scoring failed: {type(e).__name__}: {e}")
|
| 417 |
+
|
| 418 |
+
|
| 419 |
+
def write_submission(path, ids, preds, explanations=None):
|
| 420 |
+
import pandas as pd
|
| 421 |
+
rows = []
|
| 422 |
+
for i in ids:
|
| 423 |
+
rec = {"id": i, "pred": json.dumps(preds[i], ensure_ascii=False)}
|
| 424 |
+
if explanations is not None:
|
| 425 |
+
rec["explanation"] = explanations.get(i, "")
|
| 426 |
+
rows.append(rec)
|
| 427 |
+
pd.DataFrame(rows).to_csv(path, index=False)
|
| 428 |
+
|
| 429 |
+
|
| 430 |
+
def main():
|
| 431 |
+
import pandas as pd
|
| 432 |
+
|
| 433 |
+
df = pd.read_csv(TEST_CSV, dtype=str).fillna("")
|
| 434 |
+
ids = [str(x) for x in df["id"].tolist()]
|
| 435 |
+
ns = [detect_n_items(r.get("query", ""), r.get("task_type", ""), r.get("context", ""))
|
| 436 |
+
for _, r in df.iterrows()]
|
| 437 |
+
total_items = sum(ns)
|
| 438 |
+
log(f"loaded {len(df)} problems, {total_items} items "
|
| 439 |
+
f"(min={min(ns)} max={max(ns)} mean={total_items / len(ns):.1f})")
|
| 440 |
+
|
| 441 |
+
srcs = {i: extract_item_sources(r.get("query", ""), n)
|
| 442 |
+
for i, (_, r), n in zip(ids, df.iterrows(), ns)}
|
| 443 |
+
|
| 444 |
+
# --- 1. Baseline submission on disk before anything can go wrong --------
|
| 445 |
+
preds = {i: list(srcs[i]) for i in ids}
|
| 446 |
+
explanations = {i: "" for i in ids} if WANT_EXPLANATION else None
|
| 447 |
+
write_submission(OUT_CSV, ids, preds, explanations)
|
| 448 |
+
log(f"wrote placeholder {OUT_CSV} ({len(ids)} rows)")
|
| 449 |
+
|
| 450 |
+
# --- 2. Load model -----------------------------------------------------
|
| 451 |
+
import torch
|
| 452 |
+
from transformers import (AutoTokenizer, AutoModelForCausalLM,
|
| 453 |
+
StoppingCriteria, StoppingCriteriaList)
|
| 454 |
+
|
| 455 |
+
class Deadline(StoppingCriteria):
|
| 456 |
+
"""Abort generation on wall-clock, checked every token.
|
| 457 |
+
|
| 458 |
+
Without this the budget is only checked between batches, so a batch
|
| 459 |
+
started near the limit runs past it and the platform kills the process.
|
| 460 |
+
"""
|
| 461 |
+
|
| 462 |
+
def __init__(self, stop_at):
|
| 463 |
+
self.stop_at = stop_at
|
| 464 |
+
|
| 465 |
+
def __call__(self, input_ids, scores, **kw):
|
| 466 |
+
return time.time() > self.stop_at
|
| 467 |
+
|
| 468 |
+
log("loading tokenizer/model ...")
|
| 469 |
+
tok = AutoTokenizer.from_pretrained(MODEL_ID, trust_remote_code=True)
|
| 470 |
+
if tok.pad_token is None:
|
| 471 |
+
tok.pad_token = tok.eos_token
|
| 472 |
+
tok.padding_side = "left"
|
| 473 |
+
|
| 474 |
+
# Pin every layer to the GPU. device_map="auto" is free to spill layers to
|
| 475 |
+
# CPU when it thinks VRAM is tight, and a couple of offloaded layers make
|
| 476 |
+
# generation ~100x slower without any error -- the worst kind of failure
|
| 477 |
+
# here. Falling back to "auto" only if the explicit placement fails.
|
| 478 |
+
def _load(dev_map):
|
| 479 |
+
# transformers 4.44 (the sandbox) wants torch_dtype=; 5.x renamed it to
|
| 480 |
+
# dtype=. Accept either so the same file runs in both.
|
| 481 |
+
try:
|
| 482 |
+
return AutoModelForCausalLM.from_pretrained(
|
| 483 |
+
MODEL_ID, torch_dtype=torch.float16, device_map=dev_map,
|
| 484 |
+
trust_remote_code=True).eval()
|
| 485 |
+
except TypeError:
|
| 486 |
+
return AutoModelForCausalLM.from_pretrained(
|
| 487 |
+
MODEL_ID, dtype=torch.float16, device_map=dev_map,
|
| 488 |
+
trust_remote_code=True).eval()
|
| 489 |
+
|
| 490 |
+
try:
|
| 491 |
+
model = _load({"": 0} if torch.cuda.is_available() else "auto")
|
| 492 |
+
except Exception as e:
|
| 493 |
+
log(f"pinned load failed ({type(e).__name__}: {e}); falling back to auto")
|
| 494 |
+
model = _load("auto")
|
| 495 |
+
|
| 496 |
+
devs = set(str(p.device) for p in model.parameters())
|
| 497 |
+
log(f"model ready on {sorted(devs)} ({left():.0f}s of budget left)")
|
| 498 |
+
if any(d.startswith("cpu") or d == "meta" for d in devs):
|
| 499 |
+
log("WARNING: part of the model is off-GPU; generation will be very slow")
|
| 500 |
+
if torch.cuda.is_available():
|
| 501 |
+
log(f" VRAM allocated {torch.cuda.memory_allocated()/1e9:.2f} GB / "
|
| 502 |
+
f"{torch.cuda.get_device_properties(0).total_memory/1e9:.1f} GB")
|
| 503 |
+
|
| 504 |
+
prompts = []
|
| 505 |
+
for (_, r), n in zip(df.iterrows(), ns):
|
| 506 |
+
msgs = [{"role": "system", "content": SYSTEM},
|
| 507 |
+
{"role": "user", "content": build_prompt(r, n)}]
|
| 508 |
+
prompts.append(tok.apply_chat_template(msgs, tokenize=False,
|
| 509 |
+
add_generation_prompt=True))
|
| 510 |
+
|
| 511 |
+
batch_size = int(os.environ.get("IOL_BATCH", "4"))
|
| 512 |
+
|
| 513 |
+
def generate(texts, max_new, sample, temp=0.7):
|
| 514 |
+
"""Batched generation with OOM backoff. Returns list of strings."""
|
| 515 |
+
nonlocal batch_size
|
| 516 |
+
out = [""] * len(texts)
|
| 517 |
+
order = sorted(range(len(texts)), key=lambda i: len(texts[i]))
|
| 518 |
+
i = 0
|
| 519 |
+
while i < len(order):
|
| 520 |
+
if left() < 25:
|
| 521 |
+
log(" out of time inside generate(); returning partial")
|
| 522 |
+
break
|
| 523 |
+
idx = order[i:i + batch_size]
|
| 524 |
+
chunk = [texts[j] for j in idx]
|
| 525 |
+
try:
|
| 526 |
+
enc = tok(chunk, return_tensors="pt", padding=True,
|
| 527 |
+
truncation=True, max_length=6144).to(model.device)
|
| 528 |
+
kw = dict(max_new_tokens=max_new, pad_token_id=tok.pad_token_id,
|
| 529 |
+
stopping_criteria=StoppingCriteriaList(
|
| 530 |
+
[Deadline(DEADLINE - 10)]))
|
| 531 |
+
if sample:
|
| 532 |
+
kw.update(do_sample=True, temperature=temp, top_p=0.95)
|
| 533 |
+
else:
|
| 534 |
+
kw.update(do_sample=False)
|
| 535 |
+
with torch.no_grad():
|
| 536 |
+
o = model.generate(**enc, **kw)
|
| 537 |
+
for k, j in enumerate(idx):
|
| 538 |
+
out[j] = tok.decode(o[k][enc["input_ids"].shape[1]:],
|
| 539 |
+
skip_special_tokens=True)
|
| 540 |
+
i += batch_size
|
| 541 |
+
except torch.cuda.OutOfMemoryError:
|
| 542 |
+
torch.cuda.empty_cache()
|
| 543 |
+
if batch_size == 1:
|
| 544 |
+
log(" OOM at batch=1; skipping this item")
|
| 545 |
+
i += 1
|
| 546 |
+
else:
|
| 547 |
+
batch_size = max(1, batch_size // 2)
|
| 548 |
+
log(f" OOM -> batch_size={batch_size}")
|
| 549 |
+
except Exception as e: # never die mid-run
|
| 550 |
+
log(f" generate error: {type(e).__name__}: {e}")
|
| 551 |
+
i += batch_size
|
| 552 |
+
return out
|
| 553 |
+
|
| 554 |
+
# --- 3. Pass 1: greedy, guarantees a full answer set --------------------
|
| 555 |
+
# Size the reasoning budget to the actual problem count. Measured on the
|
| 556 |
+
# eval hardware (T4, 14B AWQ, batch 4) throughput is ~32 tok/s, so the whole
|
| 557 |
+
# 30 minutes buys only ~50k generated tokens. With ~16 problem blocks that
|
| 558 |
+
# affords full-length reasoning; if the platform instead ships one row per
|
| 559 |
+
# sub-question (~90 rows) a fixed 900-token budget would not even finish a
|
| 560 |
+
# single pass. Spend at most ~40% of what's left on pass 1.
|
| 561 |
+
TOK_PER_S = float(os.environ.get("IOL_TOKS", "30"))
|
| 562 |
+
adaptive = int(0.40 * max(1.0, left()) * TOK_PER_S / max(1, len(df)))
|
| 563 |
+
max_new = max(192, min(MAX_NEW, adaptive))
|
| 564 |
+
log(f"reasoning budget: {max_new} new tokens/problem "
|
| 565 |
+
f"(adaptive={adaptive}, cap={MAX_NEW}, {len(df)} problems)")
|
| 566 |
+
|
| 567 |
+
t = time.time()
|
| 568 |
+
texts = generate(prompts, max_new=max_new, sample=False)
|
| 569 |
+
pass1_cost = time.time() - t
|
| 570 |
+
samples = {i: [] for i in ids}
|
| 571 |
+
for i, n, txt in zip(ids, ns, texts):
|
| 572 |
+
a = repair_bijection(parse_answers(txt, n, srcs[i]))
|
| 573 |
+
preds[i] = a
|
| 574 |
+
samples[i].append(a)
|
| 575 |
+
write_submission(OUT_CSV, ids, preds, explanations)
|
| 576 |
+
log(f"pass 1 (greedy) done in {pass1_cost:.0f}s -> submission written")
|
| 577 |
+
dev_score(preds)
|
| 578 |
+
|
| 579 |
+
# --- 4. Self-consistency passes while budget allows ---------------------
|
| 580 |
+
reserve = 0.0
|
| 581 |
+
if WANT_EXPLANATION:
|
| 582 |
+
reserve = min(300.0, 0.25 * pass1_cost + 60) # explanations are short
|
| 583 |
+
n_extra = 0
|
| 584 |
+
while left() - reserve > pass1_cost * 1.25 and n_extra < MAX_SAMPLES:
|
| 585 |
+
n_extra += 1
|
| 586 |
+
log(f"self-consistency pass {n_extra} ({left():.0f}s left)")
|
| 587 |
+
texts = generate(prompts, max_new=max_new, sample=True, temp=0.7)
|
| 588 |
+
for i, n, txt in zip(ids, ns, texts):
|
| 589 |
+
if txt:
|
| 590 |
+
samples[i].append(repair_bijection(parse_answers(txt, n, srcs[i])))
|
| 591 |
+
for i, n in zip(ids, ns):
|
| 592 |
+
if len(samples[i]) > 1:
|
| 593 |
+
preds[i] = repair_bijection(
|
| 594 |
+
[vote([s[k] for s in samples[i]]) for k in range(n)])
|
| 595 |
+
write_submission(OUT_CSV, ids, preds, explanations)
|
| 596 |
+
log(f" voted over {n_extra + 1} samples -> submission written")
|
| 597 |
+
dev_score(preds)
|
| 598 |
+
|
| 599 |
+
# --- 5. Explanations for the jury track ---------------------------------
|
| 600 |
+
if WANT_EXPLANATION and left() > 60:
|
| 601 |
+
log(f"generating explanations ({left():.0f}s left)")
|
| 602 |
+
ex_prompts = []
|
| 603 |
+
for (_, r), i in zip(df.iterrows(), ids):
|
| 604 |
+
msgs = [{"role": "system", "content": EXPLAIN_SYSTEM},
|
| 605 |
+
{"role": "user", "content": build_explain_prompt(r, preds[i])}]
|
| 606 |
+
ex_prompts.append(tok.apply_chat_template(
|
| 607 |
+
msgs, tokenize=False, add_generation_prompt=True))
|
| 608 |
+
ex = generate(ex_prompts, max_new=200, sample=False)
|
| 609 |
+
for i, e in zip(ids, ex):
|
| 610 |
+
e = re.sub(r"\s+", " ", (e or "").strip())
|
| 611 |
+
if e:
|
| 612 |
+
explanations[i] = e[:1200]
|
| 613 |
+
write_submission(OUT_CSV, ids, preds, explanations)
|
| 614 |
+
log("explanations written")
|
| 615 |
+
|
| 616 |
+
# --- 6. Final integrity check ------------------------------------------
|
| 617 |
+
bad = [i for i, n in zip(ids, ns) if len(preds[i]) != n or any(
|
| 618 |
+
not str(x).strip() for x in preds[i])]
|
| 619 |
+
if bad:
|
| 620 |
+
log(f"repairing {len(bad)} malformed rows")
|
| 621 |
+
for i, n in zip(ids, ns):
|
| 622 |
+
preds[i] = fit_to_n([x for x in preds[i] if str(x).strip()], n, srcs[i])
|
| 623 |
+
write_submission(OUT_CSV, ids, preds, explanations)
|
| 624 |
+
|
| 625 |
+
log(f"DONE. {len(ids)} rows, {sum(len(v) for v in preds.values())} answers, "
|
| 626 |
+
f"{time.time() - T0:.0f}s elapsed")
|
| 627 |
+
|
| 628 |
+
|
| 629 |
+
if __name__ == "__main__":
|
| 630 |
+
main()
|