214 lines
9.9 KiB
Python
214 lines
9.9 KiB
Python
"""
|
|
IOL-AI 2026 submission script. Ships inside the model repo alongside the Qwen2.5-14B-Instruct-AWQ
|
|
weights. Reads /tmp/data/test.csv, writes submission.csv (id, pred, explanation) to the working
|
|
directory. No internet at runtime -- everything must load from local files (".").
|
|
"""
|
|
import os
|
|
|
|
os.environ["HF_HUB_OFFLINE"] = "1"
|
|
os.environ["TRANSFORMERS_OFFLINE"] = "1"
|
|
|
|
import json
|
|
import re
|
|
import time
|
|
|
|
START_TIME = time.time()
|
|
TIME_LIMIT_SECONDS = 30 * 60
|
|
SAFETY_MARGIN_SECONDS = 90 # reserve for CSV write + any per-row overrun
|
|
DEADLINE = START_TIME + TIME_LIMIT_SECONDS - SAFETY_MARGIN_SECONDS
|
|
|
|
import pandas as pd
|
|
import torch
|
|
from transformers import AutoModelForCausalLM, AutoTokenizer
|
|
|
|
SYSTEM = (
|
|
"You solve International Linguistics Olympiad problems. You will be given data "
|
|
"from a language you have never seen before, plus hints, and asked to answer "
|
|
"numbered items about it. The ONLY source of truth is the data given to you in "
|
|
"this problem -- do not rely on anything you think you know about the language "
|
|
"if it conflicts with the examples given here.\n\n"
|
|
"Work method:\n"
|
|
"1. Go through every single example given, in order. For each word in the "
|
|
"unfamiliar language, first split it into its likely component morphemes "
|
|
"(stem plus any prefixes/suffixes) even if you are not fully sure of the "
|
|
"boundaries -- treat it as a sequence of parts, not one opaque unit. Then, "
|
|
"for each one, write down "
|
|
"every distinct morpheme, word, particle, or structural pattern (word order, "
|
|
"marking, alternation) it contains and what it appears to mean or mark. Do not "
|
|
"skip any example, and do not stop early once you have a plausible-looking "
|
|
"pattern -- an alternation that looks like it marks one thing (e.g. tense) may "
|
|
"actually mark something else (e.g. person, number, or agreement with a "
|
|
"different argument), and only the examples you skipped may reveal which.\n"
|
|
"2. Write a section titled RULE TABLE: that lists, as a table or bullet list, "
|
|
"every distinct piece you identified and its meaning/function -- this must "
|
|
"cover every example from step 1, not just the ones similar to the query.\n"
|
|
"3. Using ONLY entries from your RULE TABLE, work out the answer to each query "
|
|
"item. If the table has no entry for something the query needs, say so and give "
|
|
"your best-supported guess rather than leaving it blank.\n"
|
|
"4. Before writing your final answers, re-read your RULE TABLE and re-derive "
|
|
"each answer from it one more time, checking: did you actually apply every "
|
|
"rule you stated (e.g. a plural marker, a tense marker) to every relevant "
|
|
"answer, not just some of them? If two answers could plausibly be swapped "
|
|
"(e.g. two options assigned to the wrong item), re-check the evidence that "
|
|
"distinguishes them specifically.\n\n"
|
|
"Output format by task type -- give exactly this, nothing more:\n"
|
|
"- translation: the translated form only, in the language the task asks for.\n"
|
|
"- fill_blanks: only the missing form for each blank.\n"
|
|
"- match_letters: only the option letter (e.g. A, B, C).\n"
|
|
"- text_to_num: the number in digits.\n"
|
|
"- num_to_text: the number written out in words, in the language asked.\n"
|
|
"- any other task type: give exactly what the instruction asks for, nothing else.\n\n"
|
|
"Each final answer must be the bare form only -- no surrounding quotes, no "
|
|
"trailing period or punctuation that isn't part of the answer itself, no "
|
|
"parenthetical notes, no alternate options separated by '/' or 'or'. Pick "
|
|
"one single best answer per item.\n\n"
|
|
"Follow the work method above, showing your RULE TABLE. Then write a line that "
|
|
"says exactly FINAL ANSWERS: and, below it, one answer per line in the order "
|
|
"the items are asked -- the bare answer only, no numbering, no quotes, no "
|
|
"extra commentary."
|
|
)
|
|
|
|
|
|
def count_expected_items(query):
|
|
"""Count numbered items (e.g. '17.', '18)') in the query -- the target answer count."""
|
|
return len(re.findall(r"(?m)^\s*\d+[.)]", query))
|
|
|
|
|
|
MAX_EXPLANATION_CHARS = 2000
|
|
|
|
|
|
def parse_answers(text, expected_count=None):
|
|
"""Keep only the lines after the last 'FINAL ANSWERS:' marker, one answer per line.
|
|
|
|
If expected_count is given, pad with "" or truncate so the row never silently
|
|
drops points from a length mismatch against the scorer's positional alignment.
|
|
"""
|
|
marker = list(re.finditer(r"(?im)^[#*\s]*final answers?[:#*\s]*", text))
|
|
if marker:
|
|
text = text[marker[-1].end():]
|
|
answers = []
|
|
for line in text.splitlines():
|
|
line = re.sub(r"^\s*\d+[.)]\s*", "", line).strip()
|
|
line = line.strip("\"'")
|
|
if line:
|
|
answers.append(line)
|
|
|
|
if expected_count:
|
|
if len(answers) < expected_count:
|
|
answers = answers + [""] * (expected_count - len(answers))
|
|
elif len(answers) > expected_count:
|
|
answers = answers[:expected_count]
|
|
return answers
|
|
|
|
|
|
def extract_explanation(text):
|
|
"""The reasoning/RULE TABLE portion before the final-answers marker, for the
|
|
Human Evaluation track -- truncated so one long row can't bloat the CSV."""
|
|
marker = re.search(r"(?im)^[#*\s]*final answers?[:#*\s]*$", text)
|
|
explanation = text[:marker.start()] if marker else text
|
|
explanation = explanation.strip()
|
|
if len(explanation) > MAX_EXPLANATION_CHARS:
|
|
explanation = explanation[:MAX_EXPLANATION_CHARS].rsplit(" ", 1)[0] + " ..."
|
|
return explanation
|
|
|
|
|
|
MODEL_ID = "."
|
|
MAX_NEW_TOKENS_FULL = 2048 # normal pass -- room to show a full RULE TABLE
|
|
MAX_NEW_TOKENS_FAST = 768 # fallback pass when time is short -- less room to reason
|
|
|
|
print(f"[{time.time() - START_TIME:.0f}s] loading model...", flush=True)
|
|
tok = AutoTokenizer.from_pretrained(MODEL_ID)
|
|
model = AutoModelForCausalLM.from_pretrained(
|
|
MODEL_ID, torch_dtype=torch.float16, device_map="auto",
|
|
).eval()
|
|
PAD_TOKEN_ID = tok.pad_token_id if tok.pad_token_id is not None else tok.eos_token_id
|
|
|
|
MAX_INPUT_TOKENS = 6000 # conservative guard against the model's context window -- leaves
|
|
# headroom for the system prompt, chat template overhead, and
|
|
# MAX_NEW_TOKENS_FULL generation on unusually long real IOL problems
|
|
|
|
|
|
def truncate_context(context, query):
|
|
"""Guard against exceeding the model's context window -- truncates `context` (never
|
|
`query`, which holds the actual questions) from the end, keeping as much of the given
|
|
data as fits within MAX_INPUT_TOKENS."""
|
|
query_len = len(tok(query, add_special_tokens=False)["input_ids"])
|
|
budget = MAX_INPUT_TOKENS - query_len
|
|
if budget <= 0:
|
|
return context
|
|
context_ids = tok(context, add_special_tokens=False)["input_ids"]
|
|
if len(context_ids) <= budget:
|
|
return context
|
|
return tok.decode(context_ids[:budget], skip_special_tokens=True)
|
|
print(f"[{time.time() - START_TIME:.0f}s] model loaded", flush=True)
|
|
|
|
|
|
def generate(system, user, max_new_tokens):
|
|
messages = [
|
|
{"role": "system", "content": system},
|
|
{"role": "user", "content": user},
|
|
]
|
|
# tokenize=False + separate tok(...) call, rather than apply_chat_template(..., return_dict=True),
|
|
# for compatibility with the eval sandbox's pinned transformers==4.44.1 (return_dict support on
|
|
# apply_chat_template was added later).
|
|
prompt = tok.apply_chat_template(messages, add_generation_prompt=True, tokenize=False)
|
|
enc = tok(prompt, return_tensors="pt").to(model.device)
|
|
with torch.no_grad():
|
|
out = model.generate(
|
|
**enc, max_new_tokens=max_new_tokens, do_sample=True, temperature=0.3, top_p=0.95,
|
|
pad_token_id=PAD_TOKEN_ID, repetition_penalty=1.15,
|
|
)
|
|
return tok.decode(out[0][enc["input_ids"].shape[-1]:], skip_special_tokens=True).strip()
|
|
|
|
|
|
def solve_row(problem, expected, avg_row_time):
|
|
"""One greedy pass, with a shorter token budget if the time budget is running low."""
|
|
full_pass_estimate = avg_row_time if avg_row_time else 45.0 # seconds -- rough guess for row 1
|
|
remaining = DEADLINE - time.time()
|
|
if remaining > full_pass_estimate:
|
|
text = generate(SYSTEM, problem, MAX_NEW_TOKENS_FULL)
|
|
else:
|
|
# Running low on time: fewer tokens, same prompt.
|
|
text = generate(SYSTEM, problem, MAX_NEW_TOKENS_FAST)
|
|
return parse_answers(text, expected_count=expected), extract_explanation(text)
|
|
|
|
|
|
df = pd.read_csv("/tmp/data/test.csv", dtype=str).fillna("")
|
|
print(f"[{time.time() - START_TIME:.0f}s] {len(df)} rows to solve", flush=True)
|
|
|
|
rows_out = []
|
|
row_times = []
|
|
for i, r in df.iterrows():
|
|
row_start = time.time()
|
|
problem = f"{truncate_context(r['context'].strip(), r['query'].strip())}\n\n{r['query'].strip()}"
|
|
expected = count_expected_items(r["query"])
|
|
avg_row_time = sum(row_times) / len(row_times) if row_times else None
|
|
|
|
if time.time() > DEADLINE:
|
|
# Out of time: best-effort placeholder rather than risking the whole process
|
|
# getting killed by the 30-minute hard limit with no submission.csv at all.
|
|
answers = [""] * max(expected, 1)
|
|
explanation = ""
|
|
else:
|
|
try:
|
|
answers, explanation = solve_row(problem, expected, avg_row_time)
|
|
except Exception as e:
|
|
print(f"[{time.time() - START_TIME:.0f}s] row {r['id']} failed: {e}", flush=True)
|
|
answers = [""] * max(expected, 1)
|
|
explanation = ""
|
|
|
|
rows_out.append({
|
|
"id": r["id"],
|
|
"pred": json.dumps(answers, ensure_ascii=False),
|
|
"explanation": explanation,
|
|
})
|
|
row_times.append(time.time() - row_start)
|
|
print(
|
|
f"[{time.time() - START_TIME:.0f}s] row {i + 1}/{len(df)} done in {row_times[-1]:.0f}s, "
|
|
f"{len(answers)} answers, {DEADLINE - time.time():.0f}s budget left",
|
|
flush=True,
|
|
)
|
|
|
|
pd.DataFrame(rows_out).to_csv("submission.csv", index=False)
|
|
print(f"[{time.time() - START_TIME:.0f}s] wrote submission.csv ({len(rows_out)} rows)", flush=True)
|