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.
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
- SOTA task page and official-material links
- Bohrium task page
- Community LSTM notebook and mirrored training data
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
"""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()
Editorials are written by members of the SOTA community, not by the olympiad. The official statement and scoring win wherever an editorial differs.