Checklist NOAI China 2025 Round 2 (China Stage, national final) · Task 1
SOTA-AI Editorial
Compound Word Segmentation
Build a character-level segmenter that splits German compound words into their constituent words.
Hints
Hint 1
Treat the target as character-level boundary labels and decode them into constituent intervals.
Hint 2
Inspect word lengths, boundary frequency, and repeated substrings in the training set.
Hint 3
Use a character model to estimate boundary probabilities, then add training constituents as a soft lexicon.
Hint 4
Tune the decoder with mean per-word exact-interval F1; boundary accuracy alone does not match the task score.
The solution in one line
A character BiLSTM predicts endings, and a lexicon-aware dynamic program turns them into consistent German compound segments; the NOAI score remains unmeasured.
The editorial
NOAI China 2025 — Compound Word Segmentation
Problem domain: German compound word segmentation, character-level sequence labeling
Metric: Mean per-word F1 on exact constituent intervals
Official task page: SOTA checklist: Compound Word Segmentation · Bohrium contest page
Abridged task
Given a concatenated German compound, return one 0/1 label per character. A 1 marks the final character of a constituent; a 0 marks other characters. The released training file has 94,306 labeled words. The 11,788 validation and 11,789 test words have empty target arrays and are read in the competition environment. Each word is scored by F1 over exact boundary/interval matches, then scores are averaged over words. The validation leaderboard is public; the test leaderboard is the final result. See the official statement linked from the task page for the controlling specification.
Data and license
No NOAI data or Bohrium ANSWER_PATH was mounted in this workspace. The SOTA task page lists the source license as not stated. It also links the training data through Bohrium. I did not include any organizer data or notebooks here.
Dataset analysis and EDA
The task summary lists 94,306 labeled training words, 11,788 validation words, and 11,789 test words. The labels encode constituent endings as one binary value per character. The separate IOAI GAITE repository contains a same-sized labeled file, but identity with the NOAI files and split order has not been established. No NOAI character frequencies, word-length distribution, or boundary-rate summary could be computed locally.
The official IOAI-2025 repository contains a similarly named GAITE word-segmentation task and a 94,306-word training file under the repository's CC BY 4.0 license. I used that public file for a local holdout experiment below. Its identity with the NOAI Bohrium files is not verified, so the score is proxy evidence only. The script accepts the NOAI training JSON directly so it can be evaluated without changing the model code.
Method
The candidate in compound_word_segmentation.py combines two signals:
- A two-layer bidirectional character LSTM predicts boundary probabilities with a binary cross-entropy objective.
- A dynamic program uses the training constituents as a soft lexicon. It gives known constituent spans a frequency-weighted bonus while retaining an unconstrained path for unseen pieces. The last character of every word is always marked as a boundary.
The script includes a random 90/10 word holdout mode that reports the official mean per-word F1. The default threshold and lexicon weight are starting values; tune them on a held-out split before selecting a final run. Submit mode trains on all labeled words and writes the required validation and test JSON files into submission.zip.
Experiments and score evidence
| Result | Score | Provenance |
|---|---|---|
| NOAI validation leaderboard | Not measured | No Bohrium login, mounted task data, or active submission window was available. |
| NOAI hidden test leaderboard | Not measured | Hidden labels and final evaluation are inaccessible in this workspace. |
| Local GAITE analogue holdout | 0.862135 mean per-word exact-segment F1 | Measured on a seeded random 10% split: 9,430 held-out words; the model trained on the other 84,876 words for 1 epoch, batch size 512, threshold 0.42 and lexicon weight 0.25. This is not a NOAI leaderboard score. |
The public IOAI GAITE task is not counted as a NOAI result unless the data are shown to be identical. This local holdout is a candidate estimate only, not an organizer score. No score from an organizer reference model is attributed to this implementation.
Implementation, diagnostics, and compute
The 0.862135 proxy run used one training epoch on CPU; it is a metric check on related public data, not evidence of NOAI validation quality. No NOAI runtime or memory measurement is available. The interval-level scorer now compares exact (start, end) constituent spans, matching the task metric described on the checklist. The training script remains configurable to rerun validation on the actual NOAI JSON files.
Alternatives considered
- Character n-gram classifiers can be faster and provide a strong baseline for boundary decisions.
- A CRF can model adjacent boundary consistency explicitly.
- A lexicon-only dynamic program is useful as a diagnostic but may fail on unseen constituents; compare its held-out score with the BiLSTM and blended variants.
Run
python solutions/china/2025/compound_word_segmentation.py \
--mode validate \
--train-json /path/to/IOAI-2025/GAITE-Contest/Word_Segmentation/train.json \
--epochs 1 --batch-size 512 --seed 17
python solutions/china/2025/compound_word_segmentation.py \
--mode submit --train-json /path/to/train.json \
--val-json /path/to/val.json --test-json /path/to/test.json \
--output-dir submission
Requires PyTorch and NumPy. The script does not fetch the dataset or access encrypted files automatically; pass the official file paths explicitly. No dataset, checkpoint, or organizer notebook is stored in this repository.
Limitations and next steps
- Run the built-in holdout evaluation on the exact NOAI training file, then tune the boundary threshold and lexicon bonus without consulting the hidden test labels.
- Confirm the character-label and JSON serialization requirements against the official Chinese statement before uploading; the statement overrides the abridged checklist.
- The BiLSTM may miss rare constituents and spelling/linking variants. Character n-gram models or a CRF head are reasonable extensions if holdout F1 improves.
Sources and reuse
- NOAI task summary and linked official statement/data — license not stated on the source page.
- Official IOAI-2025 task repository — separate GAITE task materials; repository license is CC BY 4.0. It is cited as a related source, not as proof of NOAI data identity.
Code
Solution code
"""Character-level German compound segmentation with a lexicon-aware BiLSTM.
Expected data: the organizer's train.json plus optional unlabeled val.json and
test.json. The script never downloads or writes copies of those assets.
"""
from __future__ import annotations
import argparse
import json
import math
import random
import zipfile
from collections import Counter
from pathlib import Path
from typing import Iterable
import numpy as np
import torch
from torch import nn
from torch.nn.utils.rnn import pad_sequence
from torch.utils.data import DataLoader, Dataset
PAD, UNK = 0, 1
def load_json(path: Path) -> dict[str, list[int]]:
with path.open(encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict):
raise ValueError(f"Expected a JSON object in {path}")
return {str(w): list(map(int, y)) for w, y in data.items()}
def make_vocab(words: Iterable[str]) -> dict[str, int]:
chars = sorted({ch for word in words for ch in word})
return {ch: i + 2 for i, ch in enumerate(chars)}
class WordDataset(Dataset):
def __init__(self, data: dict[str, list[int]], vocab: dict[str, int]):
self.items = [(w, y) for w, y in data.items()]
self.vocab = vocab
def __len__(self) -> int:
return len(self.items)
def __getitem__(self, i: int):
word, labels = self.items[i]
x = torch.tensor([self.vocab.get(ch, UNK) for ch in word], dtype=torch.long)
y = torch.tensor(labels, dtype=torch.float32)
return word, x, y
def collate(batch):
words, xs, ys = zip(*batch)
lengths = torch.tensor([len(x) for x in xs], dtype=torch.long)
return words, pad_sequence(xs, batch_first=True, padding_value=PAD), pad_sequence(ys, batch_first=True, padding_value=-1), lengths
class Segmenter(nn.Module):
def __init__(self, vocab_size: int, emb: int = 48, hidden: int = 96):
super().__init__()
self.embedding = nn.Embedding(vocab_size, emb, padding_idx=PAD)
self.encoder = nn.LSTM(emb, hidden, batch_first=True, bidirectional=True, num_layers=2, dropout=0.15)
self.head = nn.Linear(2 * hidden, 1)
def forward(self, x: torch.Tensor) -> torch.Tensor:
return self.head(self.encoder(self.embedding(x))[0]).squeeze(-1)
def constituents(data: dict[str, list[int]]) -> Counter[str]:
counts: Counter[str] = Counter()
for word, labels in data.items():
start = 0
for i, flag in enumerate(labels):
if flag:
counts[word[start:i + 1].lower()] += 1
start = i + 1
return counts
def decode_word(word: str, prob: np.ndarray, lex: Counter[str], threshold: float, lex_weight: float) -> list[int]:
"""Find a segmentation by dynamic programming over token boundaries."""
n = len(word)
if not n:
return []
p = np.clip(prob, 1e-5, 1 - 1e-5)
# Shift the local log-odds so threshold has its usual meaning.
offset = math.log(threshold / (1.0 - threshold))
log0 = -np.logaddexp(0.0, np.log(p / (1.0 - p)) - offset)
log1 = -np.logaddexp(0.0, -np.log(p / (1.0 - p)) + offset)
prefix0 = np.concatenate(([0.0], np.cumsum(log0)))
best = np.full(n + 1, -1e30, dtype=np.float64)
prev = np.full(n + 1, -1, dtype=np.int32)
best[0] = 0.0
max_len = min(max((len(k) for k in lex), default=0), n)
for end in range(1, n + 1):
lower = max(0, end - max_len)
for start in range(lower, end):
token = word[start:end].lower()
count = lex.get(token, 0)
# Unknown pieces remain legal; known training constituents receive
# a small frequency bonus. Every completed word ends in label 1.
local = prefix0[end - 1] - prefix0[start] + log1[end - 1]
if count:
local += lex_weight * math.log1p(count)
score = best[start] + local
if score > best[end]:
best[end], prev[end] = score, start
result = [0] * n
end = n
while end > 0:
start = int(prev[end])
if start < 0:
start = end - 1
result[end - 1] = 1
end = start
return result
def predict_prob(model: Segmenter, data: dict[str, list[int]], vocab: dict[str, int], device: torch.device, batch_size: int = 512) -> dict[str, np.ndarray]:
ds = WordDataset(data, vocab)
loader = DataLoader(ds, batch_size=batch_size, shuffle=False, collate_fn=collate)
out: dict[str, np.ndarray] = {}
model.eval()
with torch.no_grad():
for words, x, _, lengths in loader:
logits = torch.sigmoid(model(x.to(device))).cpu().numpy()
for i, word in enumerate(words):
out[word] = logits[i, :int(lengths[i])]
return out
def train_model(data: dict[str, list[int]], vocab: dict[str, int], epochs: int, batch_size: int, seed: int, device: torch.device) -> Segmenter:
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
ds = WordDataset(data, vocab)
loader = DataLoader(ds, batch_size=batch_size, shuffle=True, collate_fn=collate)
model = Segmenter(len(vocab) + 2).to(device)
optimizer = torch.optim.AdamW(model.parameters(), lr=0.001, weight_decay=1e-4)
criterion = nn.BCEWithLogitsLoss(reduction="none", pos_weight=torch.tensor(1.6, device=device))
for epoch in range(epochs):
model.train()
total = 0.0
count = 0
for _, x, y, _ in loader:
x, y = x.to(device), y.to(device)
logits = model(x)
mask = y >= 0
loss = (criterion(logits, y.clamp_min(0)) * mask).sum() / mask.sum().clamp_min(1)
optimizer.zero_grad(set_to_none=True)
loss.backward()
nn.utils.clip_grad_norm_(model.parameters(), 1.0)
optimizer.step()
total += float(loss.detach())
count += 1
print(f"epoch={epoch + 1} loss={total / max(count, 1):.5f}")
return model
def word_f1(gold: list[int], pred: list[int]) -> float:
def spans(labels):
result = set()
start = 0
for i, value in enumerate(labels):
if value:
result.add((start, i))
start = i + 1
if start < len(labels):
result.add((start, len(labels) - 1))
return result
g, p = spans(gold), spans(pred)
tp = len(g & p)
precision = tp / len(p) if p else 0.0
recall = tp / len(g) if g else 0.0
return 2 * precision * recall / (precision + recall) if precision + recall else 0.0
def mean_word_f1(gold: dict[str, list[int]], pred: dict[str, list[int]]) -> float:
return float(np.mean([word_f1(y, pred[w]) for w, y in gold.items()]))
def predict_labels(model, data, vocab, lex, threshold, lex_weight, device):
probs = predict_prob(model, data, vocab, device)
return {word: decode_word(word, probs[word], lex, threshold, lex_weight) for word in data}
def write_submission(val_pred: dict[str, list[int]], test_pred: dict[str, list[int]], out: Path) -> None:
out.mkdir(parents=True, exist_ok=True)
val_path, test_path = out / "submissionval.json", out / "submissiontest.json"
for path, data in ((val_path, val_pred), (test_path, test_pred)):
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
with zipfile.ZipFile(out / "submission.zip", "w", compression=zipfile.ZIP_DEFLATED) as zf:
zf.write(val_path, val_path.name)
zf.write(test_path, test_path.name)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--mode", choices=("validate", "submit"), default="validate")
parser.add_argument("--train-json", type=Path, required=True)
parser.add_argument("--val-json", type=Path)
parser.add_argument("--test-json", type=Path)
parser.add_argument("--output-dir", type=Path, default=Path("submission"))
parser.add_argument("--epochs", type=int, default=8)
parser.add_argument("--batch-size", type=int, default=256)
parser.add_argument("--threshold", type=float, default=0.42)
parser.add_argument("--lex-weight", type=float, default=0.25)
parser.add_argument("--seed", type=int, default=17)
args = parser.parse_args()
data = load_json(args.train_json)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
if args.mode == "validate":
items = list(data.items())
rng = np.random.default_rng(args.seed)
order = rng.permutation(len(items))
n_val = max(1, int(0.1 * len(items)))
val_items = [items[i] for i in order[:n_val]]
train = dict(items[i] for i in order[n_val:])
heldout = dict(val_items)
vocab = make_vocab(train)
model = train_model(train, vocab, args.epochs, args.batch_size, args.seed, device)
lex = constituents(train)
pred = predict_labels(model, heldout, vocab, lex, args.threshold, args.lex_weight, device)
print(f"heldout_words={len(heldout)} mean_word_f1={mean_word_f1(heldout, pred):.6f} threshold={args.threshold} lex_weight={args.lex_weight}")
return
if args.val_json is None or args.test_json is None:
raise SystemExit("submit mode requires --val-json and --test-json")
val, test = load_json(args.val_json), load_json(args.test_json)
vocab = make_vocab(data)
model = train_model(data, vocab, args.epochs, args.batch_size, args.seed, device)
lex = constituents(data)
write_submission(predict_labels(model, val, vocab, lex, args.threshold, args.lex_weight, device),
predict_labels(model, test, vocab, lex, args.threshold, args.lex_weight, device), args.output_dir)
print(f"wrote {args.output_dir / 'submission.zip'}")
if __name__ == "__main__":
main()
Editorials are written by members of the SOTA community, not by the olympiad. The official statement and scoring win wherever an editorial differs.