Discord

Checklist NOAI China 2025 Round 2 (China Stage, national final) · Task 2

SOTA-AI Editorial

Chemical Reaction Kinetics Simulation

Predict the half-life of the reactant L2M in an organometallic reaction from the initial concentrations of L2M, D and L.

Written
8 October 2026
Result
No candidate score measured because organizer data and grader were unavailable.
Points
Not measured; see result for the limitation.
Olympiad
National Olympiad in Artificial Intelligence, China (NOAI; 国际人工智能奥林匹克中国区学术活动), 2025 · Round 2 (China Stage, national final)

Hints

Hint 1

Identify which initial concentrations correspond to the reactant, donor, and inhibitor before fitting a model.

Hint 2

Inspect concentration ranges and how the target changes with each concentration; the reaction is nonlinear.

Hint 3

Add ratios, products, and limiting-reactant features, then compare tree models with a kinetic-law fit.

Hint 4

Select models using the clipped logarithmic score from the statement, not MAE alone.

The solution in one line

Blend tree regressors on concentration and kinetic-interaction features; the candidate and both NOAI leaderboard scores remain unmeasured until the organizer data are available.

The editorial

NOAI China 2025 — Chemical Reaction Kinetics Simulation

Problem domain: Tabular regression with concentration-time reaction traces
Metric: Mean of max(0, 1 - ln(1 + 0.1 × |prediction − target|) / 5) over experiments
Official task page: SOTA checklist: Chemical Reaction Kinetics Simulation · Bohrium task

Abridged task

Predict the half-life t1/2 of reactant L2M from the initial concentrations of L2M, D and L. The reaction includes reversible and irreversible elementary steps. The released training package contains traces for 1,000 simulated experiments and a summary table; each hidden validation/test experiment provides only the three initial concentrations. The validation set has 100 experiments and the hidden test set has 412. The expected archive contains submission_val.csv and submission_test.csv, each with experiment number and t12 columns. The official statement linked from the checklist controls exact column names and rules.

Data and license

The NOAI training traces and validation/test inputs were not mounted in this workspace. The task page lists the organizer source license as not stated. The linked statement and baseline notebook were not retrievable from this environment, and the Bohrium answer files are protected by the competition runtime. No organizer data or notebook is copied here.

Dataset analysis and EDA

The task summary describes 1,000 simulated training experiments, with three initial concentrations and a reaction trace for each experiment. The hidden validation and test tables contain 100 and 412 experiments, respectively, and expose only the initial concentrations. Since the tables were unavailable locally, I could not inspect concentration ranges, target skew, trace shapes, missing values, or outliers. The column positions below follow the linked baseline's description and still need confirmation against the organizer files.

Method

The candidate in chemical_reaction_kinetics.py uses three tree regressors blended together: Extra Trees, Random Forest, and an absolute-error HistGradientBoosting model. Features include raw and log concentrations, concentration ratios, pairwise products, reactant imbalance, limiting-reactant concentration, and an inhibitor fraction. This captures nonlinear interactions while remaining practical for the 1,000-row training set.

The script reads training_data.dat and selects input columns 1–3 and target column 5, matching the baseline notebook's positional description. A random 80/20 split reports the competition score, MAE and median absolute error. Submit mode predicts the validation and test tables and writes the expected CSVs and ZIP.

Experiments and score evidence

Result Score Provenance
NOAI validation leaderboard Not measured Bohrium credentials and the encrypted validation data were unavailable.
NOAI hidden test leaderboard Not measured Hidden labels and final evaluation were inaccessible.
Local candidate holdout Not measured The official training table was not available locally.

No organizer baseline or scientific-committee score is reported as this candidate's result. The linked Bohrium competition window is listed as ending on 20 June 2026, so this candidate was not submitted to its A/B leaderboard.

Implementation, diagnostics, and compute

The script uses CPU scikit-learn tree models, with parallel tree fitting enabled through n_jobs=-1. The competition tables were not available, so there is no measured runtime, memory footprint, holdout result, or output-format check against the organizer's helper. The positional parser and expected CSV names are candidate assumptions, not verified facts; confirm them from the official statement and sample files before running.

Alternatives considered

  • Fit a mechanistic rate law or reaction ODE directly, which could extrapolate more naturally from concentration inputs.
  • Regress on a log-transformed half-life and compare it with the raw-target blend.
  • Compare repeated cross-validation and simpler gradient-boosted trees using the exact clipped logarithmic score.

Run

python solutions/china/2025/chemical_reaction_kinetics.py \
  --mode validate --train-table /path/to/training_data.dat

python solutions/china/2025/chemical_reaction_kinetics.py \
  --mode submit --train-table /path/to/training_data.dat \
  --val-table /path/to/val_data_question.dat \
  --test-table /path/to/test_data_question.dat \
  --output-dir submission

Requires NumPy, pandas and scikit-learn. The code does not download the organizer data or attempt to read ANSWER_PATH; files are supplied explicitly.

Limitations and next steps

  • Check that the actual summary file uses the stated column positions and delimiter before running; I could not verify the organizer statement or table headers locally.
  • Compare the ensemble with a direct kinetic-law fit and log-target models using repeated splits. With only 1,000 experiments, report split variance as well as the mean.
  • Evaluate the exact requested file formatting and score against the public validation grader if Bohrium access becomes available. Do not select the model on the hidden test.

Sources and reuse

Code

Solution code solution.py · 117 lines
"""Predict organometallic reaction half-life from initial concentrations.

The competition data stay external. Point --train-table at the organizer's
training_data.dat and pass validation/test question tables in submit mode.
"""
from __future__ import annotations

import argparse
import math
import zipfile
from pathlib import Path

import numpy as np
import pandas as pd
from sklearn.ensemble import ExtraTreesRegressor, HistGradientBoostingRegressor, RandomForestRegressor
from sklearn.model_selection import train_test_split


EPS = 1e-12


def read_table(path: Path) -> pd.DataFrame:
    """Read the organizer's comma/tab/space-delimited .dat tables."""
    try:
        raw = pd.read_csv(path, sep=None, engine="python", header=None, comment="#")
    except Exception as exc:
        raise ValueError(f"Could not parse {path}: {exc}") from exc
    if raw.empty:
        raise ValueError(f"Empty data table: {path}")
    first_is_data = pd.to_numeric(raw.iloc[0], errors="coerce").notna().all()
    if first_is_data:
        return raw
    return raw.iloc[1:].reset_index(drop=True)


def select_inputs(frame: pd.DataFrame) -> np.ndarray:
    if frame.shape[1] < 4:
        raise ValueError(f"Expected experiment id and three concentrations, got {frame.shape[1]} columns")
    values = frame.iloc[:, 1:4].apply(pd.to_numeric, errors="coerce").to_numpy(dtype=np.float64)
    if not np.isfinite(values).all():
        raise ValueError("Initial concentrations contain missing or non-numeric values")
    return values


def features(x: np.ndarray) -> pd.DataFrame:
    """Simple kinetic features for reactant, donor, and inhibitor concentrations."""
    x = np.asarray(x, dtype=np.float64)
    a, d, l = x[:, 0], x[:, 1], x[:, 2]
    cols: dict[str, np.ndarray] = {}
    for i, v in enumerate((a, d, l)):
        cols[f"c{i}"] = v
        cols[f"log_c{i}"] = np.log(np.maximum(v, 0) + EPS)
        cols[f"sqrt_c{i}"] = np.sqrt(np.maximum(v, 0))
    for name, v in {
        "a_over_d": a / (d + EPS), "a_over_l": a / (l + EPS),
        "d_over_a": d / (a + EPS), "d_over_l": d / (l + EPS),
        "l_over_a": l / (a + EPS), "l_over_d": l / (d + EPS),
        "a_times_d": a * d, "a_times_l": a * l, "d_times_l": d * l,
        "a_d_over_l": a * d / (l + EPS), "a_l_over_d": a * l / (d + EPS),
        "d_l_over_a": d * l / (a + EPS), "min_reactants": np.minimum(a, d),
        "reactant_sum": a + d, "all_sum": a + d + l,
        "stoich_imbalance": np.abs(a - d) / (a + d + EPS),
        "inhibitor_fraction": l / (a + d + l + EPS),
    }.items():
        cols[name] = v
        if name.endswith("_over_l") or name.endswith("_over_d") or name.endswith("_over_a"):
            cols[f"log_{name}"] = np.log(np.maximum(v, 0) + EPS)
    return pd.DataFrame(cols).replace([np.inf, -np.inf], np.nan).fillna(0.0)


def make_models(seed: int):
    return [
        ExtraTreesRegressor(n_estimators=700, min_samples_leaf=2, max_features=0.9, n_jobs=-1, random_state=seed),
        RandomForestRegressor(n_estimators=600, min_samples_leaf=2, max_features=0.9, n_jobs=-1, random_state=seed + 1),
        HistGradientBoostingRegressor(loss="absolute_error", learning_rate=0.05, max_iter=250, max_leaf_nodes=12,
                                      min_samples_leaf=12, l2_regularization=1.0, random_state=seed + 2),
    ]


def fit_predict(x_train: np.ndarray, y_train: np.ndarray, x_pred: np.ndarray, seed: int = 17) -> np.ndarray:
    xt, xp = features(x_train), features(x_pred)
    preds = []
    for model in make_models(seed):
        model.fit(xt, y_train)
        preds.append(np.asarray(model.predict(xp), dtype=np.float64))
    # Trees preserve local neighborhoods; the absolute-error booster estimates
    # the central conditional response. A blend reduces variance at n=1,000.
    pred = 0.4 * preds[0] + 0.3 * preds[1] + 0.3 * preds[2]
    return np.maximum(pred, 0.0)


def official_score(pred: np.ndarray, true: np.ndarray) -> float:
    error = np.abs(np.asarray(pred) - np.asarray(true))
    return float(np.mean(np.maximum(0.0, 1.0 - np.log1p(0.1 * error) / 5.0)))


def write_submission(pred: np.ndarray, path: Path) -> None:
    frame = pd.DataFrame({"Experiment Number": np.arange(len(pred)), "t12": [f"{v:.4e}" for v in pred]})
    frame.to_csv(path, index=False)


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--mode", choices=("validate", "submit"), default="validate")
    parser.add_argument("--train-table", type=Path, required=True, help="Organizer training_data.dat")
    parser.add_argument("--val-table", type=Path)
    parser.add_argument("--test-table", type=Path)
    parser.add_argument("--output-dir", type=Path, default=Path("submission"))
    parser.add_argument("--seed", type=int, default=17)
    args = parser.parse_args()
    train = read_table(args.train_table)
    if train.shape[1] < 6:
        raise ValueError("Expected training table columns with t12 at zero-based column 5")
    x = select_inputs(train)
    y = pd.to_numeric(train.iloc[:, 5], errors="coerce").to_numpy(dtype=np.float64)
    valid = np.isfinite(y)
    x, y = x[valid], y[valid]
    if args.mode == "validate":
        ix, iv = train_test_split(np.arange(len(y)), test_size=0.2, random_state=args.seed)
        pred = fit_predict(x[ix], y[ix], x[iv], args.seed)
        error = np.abs(pred - y[iv])
        print(f"heldout={len(iv)} mean_score={official_score(pred, y[iv]):.6f} mae={error.mean():.6g} median_ae={np.median(error):.6g}")
        return
    if args.val_table is None or args.test_table is None:
        raise SystemExit("submit mode requires --val-table and --test-table")
    val_x, test_x = select_inputs(read_table(args.val_table)), select_inputs(read_table(args.test_table))
    val_pred = fit_predict(x, y, val_x, args.seed)
    test_pred = fit_predict(x, y, test_x, args.seed)
    args.output_dir.mkdir(parents=True, exist_ok=True)
    val_csv, test_csv = args.output_dir / "submission_val.csv", args.output_dir / "submission_test.csv"
    write_submission(val_pred, val_csv)
    write_submission(test_pred, test_csv)
    with zipfile.ZipFile(args.output_dir / "submission.zip", "w", compression=zipfile.ZIP_DEFLATED) as zf:
        zf.write(val_csv, val_csv.name)
        zf.write(test_csv, test_csv.name)
    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.