Discord

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.

Written
8 October 2026
Result
0.862135 exact-span F1 on a related IOAI GAITE holdout; NOAI score and data identity remain unverified.
Points
No points score reported; see result for the measured metric.
Olympiad
National Olympiad in Artificial Intelligence, China (NOAI; 国际人工智能奥林匹克中国区学术活动), 2025 · Round 2 (China Stage, national final)

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

Code

Solution code solution.py · 203 lines
"""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()

Download solution.py

Editorials are written by members of the SOTA community, not by the olympiad. The official statement and scoring win wherever an editorial differs.