MikCil's picture
Use explicit non-thinking chat-template rendering
c9eb0e0 verified
Raw
History Blame Contribute Delete
8.06 kB
"""Offline IOL-AI 2026 direct-pass submission for Qwen3.5-9B + our LoRA."""
from __future__ import annotations
import csv
import gc
import os
from pathlib import Path
import random
import time
from typing import Any
from iol_contract import (
expected_answer_count,
parse_answer_lines,
serialized_prediction,
validate_id_sequence,
)
from runtime_bootstrap import (
assert_runtime_versions,
bootstrap_local_runtime,
configure_offline_environment,
patch_torch24_for_single_gpu,
)
ROOT = Path(__file__).resolve().parent
INPUT_CSV = Path(os.environ.get("IOL_INPUT_CSV", "/tmp/data/test.csv"))
OUTPUT_CSV = Path(os.environ.get("IOL_OUTPUT_CSV", str(ROOT / "submission.csv")))
ADAPTER_DIR = ROOT / "adapter"
SYSTEM_PROMPT_PATH = ROOT / "system_prompt.txt"
SEED = 3407
MAX_NEW_TOKENS = int(os.environ.get("IOL_MAX_NEW_TOKENS", "512"))
HARD_STOP_SECONDS = float(os.environ.get("IOL_HARD_STOP_SECONDS", str(27.5 * 60)))
def set_reproducible_seed(torch: Any) -> None:
random.seed(SEED)
torch.manual_seed(SEED)
if torch.cuda.is_available():
torch.cuda.manual_seed_all(SEED)
def read_input() -> list[dict[str, str]]:
if INPUT_CSV.resolve().parent != Path("/tmp/data") and "IOL_INPUT_CSV" not in os.environ:
raise RuntimeError("competition input must be /tmp/data/test.csv")
with INPUT_CSV.open("r", encoding="utf-8-sig", newline="") as handle:
reader = csv.DictReader(handle)
required = {"id", "context", "query"}
missing = required.difference(reader.fieldnames or [])
if missing:
raise ValueError(f"test.csv is missing columns: {sorted(missing)}")
rows = [{key: value or "" for key, value in row.items()} for row in reader]
if not rows:
raise ValueError("test.csv is empty")
ids = [row["id"] for row in rows]
validate_id_sequence(ids, ids)
return rows
def build_direct_prompt(row: dict[str, str], expected_n: int) -> str:
# This matches the non-thinking DIRECT interface used during our SFT.
return (
"TASK_MODE=DIRECT\n"
f"Return exactly {expected_n} answer line(s), in order, with no explanation.\n\n"
"CONTEXT\n"
f"{row['context'].strip()}\n\n"
"QUERY\n"
f"{row['query'].strip()}"
)
def typed_text_messages(prompt: str) -> list[dict[str, Any]]:
if not SYSTEM_PROMPT_PATH.is_file():
raise RuntimeError("system_prompt.txt is missing")
system_prompt = SYSTEM_PROMPT_PATH.read_text(encoding="utf-8").strip()
if not system_prompt:
raise RuntimeError("system_prompt.txt is empty")
return [
{
"role": "system",
"content": [{"type": "text", "text": system_prompt}],
},
{
"role": "user",
"content": [{"type": "text", "text": prompt}],
},
]
def load_model_and_processor(torch: Any):
from peft import PeftModel
from transformers import AutoModelForMultimodalLM, AutoProcessor, BitsAndBytesConfig
if not ADAPTER_DIR.joinpath("adapter_config.json").is_file():
raise RuntimeError("adapter/adapter_config.json is missing")
processor = AutoProcessor.from_pretrained(
ROOT,
local_files_only=True,
trust_remote_code=False,
)
quantization = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_use_double_quant=True,
bnb_4bit_compute_dtype=torch.float16,
)
base = AutoModelForMultimodalLM.from_pretrained(
ROOT,
local_files_only=True,
trust_remote_code=False,
dtype=torch.float16,
quantization_config=quantization,
device_map="auto",
low_cpu_mem_usage=True,
)
model = PeftModel.from_pretrained(base, ADAPTER_DIR, is_trainable=False)
model.eval()
return model, processor
def generate_direct(model: Any, processor: Any, row: dict[str, str], expected_n: int, torch: Any) -> str:
prompt = build_direct_prompt(row, expected_n)
rendered_prompt = processor.apply_chat_template(
typed_text_messages(prompt),
tokenize=False,
add_generation_prompt=True,
enable_thinking=False,
)
encoded = processor(
text=rendered_prompt,
return_tensors="pt",
)
device = next(model.parameters()).device
encoded = {name: tensor.to(device) for name, tensor in encoded.items()}
input_length = encoded["input_ids"].shape[-1]
with torch.inference_mode():
output = model.generate(
**encoded,
max_new_tokens=MAX_NEW_TOKENS,
do_sample=False,
use_cache=True,
pad_token_id=getattr(processor.tokenizer, "pad_token_id", None),
eos_token_id=getattr(processor.tokenizer, "eos_token_id", None),
)
generated = output[0, input_length:]
text = processor.decode(generated, skip_special_tokens=True)
del encoded, output, generated
return text
def write_submission(rows: list[dict[str, str]], predictions: dict[str, list[str]]) -> None:
temp = OUTPUT_CSV.with_suffix(OUTPUT_CSV.suffix + ".tmp")
output_ids: list[str] = []
with temp.open("w", encoding="utf-8", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=["id", "pred"])
writer.writeheader()
for row in rows:
row_id = row["id"]
answers = predictions[row_id]
expected_n = expected_answer_count(row)
if len(answers) != expected_n or not all(isinstance(item, str) for item in answers):
raise ValueError(f"invalid prediction shape for id={row_id}")
writer.writerow({"id": row_id, "pred": serialized_prediction(answers)})
output_ids.append(row_id)
handle.flush()
os.fsync(handle.fileno())
validate_id_sequence([row["id"] for row in rows], output_ids)
os.replace(temp, OUTPUT_CSV)
def main() -> None:
configure_offline_environment()
started = time.monotonic()
rows = read_input()
expected = {row["id"]: expected_answer_count(row) for row in rows}
predictions = {row["id"]: [""] * expected[row["id"]] for row in rows}
vendor = bootstrap_local_runtime(ROOT)
versions = assert_runtime_versions(vendor)
import torch
if not torch.cuda.is_available():
raise RuntimeError("a CUDA GPU is required")
patches = patch_torch24_for_single_gpu()
set_reproducible_seed(torch)
print(
f"runtime ready: rows={len(rows)} torch={torch.__version__} "
f"transformers={versions['transformers']} patches={','.join(patches) or 'none'}",
flush=True,
)
model, processor = load_model_and_processor(torch)
print(
f"model loaded: gpu={torch.cuda.get_device_name(0)} "
f"vram_gib={torch.cuda.memory_allocated() / 2**30:.2f}",
flush=True,
)
for index, row in enumerate(rows, 1):
elapsed = time.monotonic() - started
if elapsed >= HARD_STOP_SECONDS:
print(f"hard stop reached after {index - 1}/{len(rows)} rows", flush=True)
break
row_id = row["id"]
row_started = time.monotonic()
try:
raw = generate_direct(model, processor, row, expected[row_id], torch)
predictions[row_id] = parse_answer_lines(raw, expected[row_id])
except torch.cuda.OutOfMemoryError:
gc.collect()
torch.cuda.empty_cache()
print(f"row={row_id} failed: OutOfMemoryError", flush=True)
except Exception as exc:
print(f"row={row_id} failed: {type(exc).__name__}", flush=True)
write_submission(rows, predictions)
print(
f"row={row_id} done {index}/{len(rows)} "
f"answers={len(predictions[row_id])} seconds={time.monotonic() - row_started:.1f}",
flush=True,
)
write_submission(rows, predictions)
print(f"wrote {OUTPUT_CSV.name} in {time.monotonic() - started:.1f}s", flush=True)
if __name__ == "__main__":
main()