Discord

Checklist NOAI China 2024 Round 2 (China Stage) · Task 3

SOTA-AI Editorial

News Text Classification Task

Train a PyTorch text classifier that assigns news articles to categories within a 10-minute CPU budget.

Written
8 October 2026
Result
0.9724 duplicate-aware OOF macro-F1 on a community mirror; official hidden-test score is unmeasured.
Points
Not measured; see result for the limitation.
Olympiad
National Olympiad in Artificial Intelligence, China (NOAI; 国际人工智能奥林匹克中国区学术活动), 2024 · Round 2 (China Stage)

Hints

Hint 1

Check the category counts and text lengths before selecting a model.

Hint 2

Fit vectorizers separately inside each validation fold.

Hint 3

Combine word and character n-grams, then train a small multiclass PyTorch head.

Hint 4

Group exact duplicate articles before measuring cross-validation macro-F1.

The solution in one line

A PyTorch sparse TF-IDF classifier scores 0.9724 macro-F1 in duplicate-aware local cross-validation; organizer hidden-test score is unmeasured.

The editorial

News Text Classification Task — NOAI China 2024 Round 2

Problem domain: Multiclass text classification
Evaluation metric: Macro-F1, the unweighted mean of category-level F1 scores. Training and testing must finish within 10 minutes on CPU; if the per-category F1 cannot be computed or the time limit is exceeded, score 0.

Abridged statement

Train a PyTorch text classifier using text to predict category. The task provides 1,000 labeled training articles and 200 unlabeled test articles. CPU training plus testing must finish within 10 minutes, and the score is macro-F1 averaged over categories; failing to calculate per-category F1 or exceeding the time limit gives zero. The official statement recommends word embeddings with an LSTM, but the classifier architecture is otherwise open. Submit submission.ipynb containing the training process and a submission.csv whose category labels follow the naming and storage convention of train_news.csv.

Dataset analysis and EDA

The community mirror contains 1,000 rows in four categories: business 290, sport 284, tech 221, and entertainment 205. Article lengths range from 742 to 19,138 characters (mean 2,159.8). There are 26 exact duplicate texts; the local validation therefore groups identical text by SHA-256 so copies cannot fall on opposite sides of a fold. The hidden 200-row test file was not present.

Experiments and selected method

news_text_classification.py builds word TF-IDF features (unigrams/bigrams) and character-boundary n-grams (3–5 characters), capped at 30,000 features per vectorizer. It trains a sparse multiclass linear classifier with PyTorch sparse matrix multiplication and CrossEntropyLoss. No pretrained weights or external text are used. Submit mode reads the unlabeled CSV, predicts category, preserves the test rows and their order, and writes submission.csv with the same label column name as training. This authored script is a reproducible code path, not the official notebook file; the example organizer run is still needed to assemble the complete required submission.ipynb and CSV artifacts.

Result Macro-F1 Provenance
Five-fold duplicate-aware OOF 0.9724 Locally measured on the 1,000-row community mirror using StratifiedGroupKFold and exact-text groups; fold values 0.9666–0.9796.
Participant LSTM, leaderboard A/B 0.8667 / 0.8286 Per-category F1 and aggregate scores reported in a public community notebook; not reproduced here.

The folds are stratified by category. Each fold fits its vectorizers on the training portion only, then trains the PyTorch head for 160 full-batch epochs. The OOF score is a validation estimate on the mirror, not a hidden-test score.

Result, compute, and limits

The complete five-fold diagnostic finished in 82 seconds on CPU. A single fit uses one fold's vectorization and 160 PyTorch epochs, but the official hidden test path and exact grader runtime were not measured. The task's 10-minute CPU limit is therefore not claimed as organizer-verified. Official leaderboard performance for this implementation is unmeasured.

The SOTA page lists the organizer source license as unstated. The public community repository also has no declared license (license: null). The mirrored article text was used temporarily from /tmp; no source articles, labels, or notebooks are reproduced in this repository.

Alternatives considered

  • The community LSTM follows the statement's recommendation but reported lower public leaderboard macro-F1.
  • Word-only TF-IDF is simpler; character features can preserve useful spelling, suffix, and name patterns.
  • A pretrained language model would add download, runtime, and licensing constraints without being needed for this small dataset.

Sources and reuse

This report paraphrases the official task and links to sources. It does not republish the BBC-derived article text, dataset, or notebook; their license is not stated by the cited pages.

Code

Solution code solution.py · 172 lines
"""CPU-friendly PyTorch text classifier for NOAI China 2024 task 3.

Word and character TF-IDF are used as sparse input features; a trainable
multiclass linear model is optimized in PyTorch. Run ``--mode validate`` for
duplicate-aware stratified cross-validation, or ``--mode submit`` with both
official CSVs available to write predictions in the requested format.
"""

from __future__ import annotations

import argparse
import hashlib
import random
from pathlib import Path

import numpy as np
import pandas as pd
import scipy.sparse as sp
import torch
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics import f1_score
from sklearn.model_selection import StratifiedGroupKFold
from sklearn.preprocessing import LabelEncoder
from torch import nn


class SparseLinearClassifier(nn.Module):
    """A multiclass linear model whose sparse input multiplication uses PyTorch."""

    def __init__(self, input_dim: int, num_classes: int) -> None:
        super().__init__()
        self.weight = nn.Parameter(torch.zeros(input_dim, num_classes))
        self.bias = nn.Parameter(torch.zeros(num_classes))
        nn.init.normal_(self.weight, mean=0.0, std=0.01)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        return torch.sparse.mm(x, self.weight) + self.bias


def seed_everything(seed: int) -> None:
    random.seed(seed)
    np.random.seed(seed)
    torch.manual_seed(seed)


def load_training(path: Path) -> tuple[list[str], np.ndarray]:
    frame = pd.read_csv(path)
    required = {"text", "category"}
    missing = required.difference(frame.columns)
    if missing:
        raise ValueError(f"Missing columns in {path}: {sorted(missing)}")
    texts = frame["text"].fillna("").astype(str).tolist()
    labels = frame["category"].astype(str).to_numpy()
    return texts, labels


def make_features(
    train_texts: list[str], other_texts: list[str] | None = None
) -> tuple[sp.csr_matrix, sp.csr_matrix | None]:
    """Fit compact word/character TF-IDF on training text only."""
    word = TfidfVectorizer(
        lowercase=True,
        strip_accents="unicode",
        stop_words="english",
        ngram_range=(1, 2),
        min_df=2,
        max_features=30000,
        sublinear_tf=True,
        dtype=np.float32,
    )
    char = TfidfVectorizer(
        analyzer="char_wb",
        lowercase=True,
        ngram_range=(3, 5),
        min_df=2,
        max_features=30000,
        sublinear_tf=True,
        dtype=np.float32,
    )
    x_train = sp.hstack(
        [word.fit_transform(train_texts), char.fit_transform(train_texts)],
        format="csr",
        dtype=np.float32,
    )
    if other_texts is None:
        return x_train, None
    x_other = sp.hstack(
        [word.transform(other_texts), char.transform(other_texts)],
        format="csr",
        dtype=np.float32,
    )
    return x_train, x_other


def to_torch_sparse(matrix: sp.csr_matrix) -> torch.Tensor:
    coo = matrix.tocoo()
    indices = torch.from_numpy(np.vstack((coo.row, coo.col)).astype(np.int64))
    values = torch.from_numpy(coo.data.astype(np.float32, copy=False))
    return torch.sparse_coo_tensor(indices, values, coo.shape).coalesce()


def fit_predict(
    x_train: sp.csr_matrix,
    y_train: np.ndarray,
    x_other: sp.csr_matrix,
    *,
    seed: int,
    epochs: int,
    learning_rate: float = 0.08,
    weight_decay: float = 0.02,
) -> tuple[np.ndarray, SparseLinearClassifier]:
    seed_everything(seed)
    model = SparseLinearClassifier(x_train.shape[1], int(y_train.max()) + 1)
    optimizer = torch.optim.AdamW(model.parameters(), lr=learning_rate, weight_decay=weight_decay)
    criterion = nn.CrossEntropyLoss()
    x_tensor = to_torch_sparse(x_train)
    x_other_tensor = to_torch_sparse(x_other)
    y_tensor = torch.as_tensor(y_train, dtype=torch.long)
    model.train()
    for _ in range(epochs):
        optimizer.zero_grad(set_to_none=True)
        loss = criterion(model(x_tensor), y_tensor)
        loss.backward()
        optimizer.step()
    model.eval()
    with torch.inference_mode():
        predictions = model(x_other_tensor).argmax(dim=1).cpu().numpy()
    return predictions, model


def validate(path: Path, folds: int, seed: int, epochs: int) -> None:
    texts, labels = load_training(path)
    encoder = LabelEncoder().fit(labels)
    y = encoder.transform(labels)
    groups = np.asarray([hashlib.sha256(text.encode("utf-8")).hexdigest() for text in texts])
    splitter = StratifiedGroupKFold(n_splits=folds, shuffle=True, random_state=seed)
    oof = np.empty_like(y)
    for fold, (train_idx, valid_idx) in enumerate(splitter.split(texts, y, groups), start=1):
        x_train, x_valid = make_features(
            [texts[i] for i in train_idx], [texts[i] for i in valid_idx]
        )
        assert x_valid is not None
        # Encode fold labels against the global sorted class list.
        prediction, _ = fit_predict(
            x_train,
            y[train_idx],
            x_valid,
            seed=seed + fold,
            epochs=epochs,
        )
        oof[valid_idx] = prediction
        print(
            f"fold={fold} macro_f1="
            f"{f1_score(y[valid_idx], prediction, average='macro', zero_division=0):.4f} "
            f"features={x_train.shape[1]} nnz={x_train.nnz}"
        )
    score = f1_score(y, oof, average="macro", zero_division=0)
    print(f"oof_macro_f1={score:.4f} rows={len(y)} classes={list(encoder.classes_)}")


def submit(train_path: Path, test_path: Path, output: Path, seed: int, epochs: int) -> None:
    texts, labels = load_training(train_path)
    test_frame = pd.read_csv(test_path)
    if "text" not in test_frame:
        raise ValueError(f"Missing text column in {test_path}")
    test_texts = test_frame["text"].fillna("").astype(str).tolist()
    encoder = LabelEncoder().fit(labels)
    y = encoder.transform(labels)
    x_train, x_test = make_features(texts, test_texts)
    assert x_test is not None
    predictions, _ = fit_predict(x_train, y, x_test, seed=seed, epochs=epochs)
    test_frame["category"] = encoder.inverse_transform(predictions)
    output.parent.mkdir(parents=True, exist_ok=True)
    test_frame.to_csv(output, index=False)
    print(f"saved {output} rows={len(test_frame)}")


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--mode", choices=["validate", "submit"], default="validate")
    parser.add_argument("--train", type=Path, required=True, help="CSV with text and category columns")
    parser.add_argument("--test", type=Path, help="Unlabeled CSV with a text column")
    parser.add_argument("--output", type=Path, default=Path("submission.csv"))
    parser.add_argument("--folds", type=int, default=5)
    parser.add_argument("--seed", type=int, default=42)
    parser.add_argument("--epochs", type=int, default=160)
    args = parser.parse_args()
    torch.set_num_threads(2)
    if args.mode == "validate":
        validate(args.train, args.folds, args.seed, args.epochs)
    else:
        if args.test is None:
            parser.error("--test is required in submit mode")
        submit(args.train, args.test, args.output, args.seed, args.epochs)


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.