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.
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
"""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()
Editorials are written by members of the SOTA community, not by the olympiad. The official statement and scoring win wherever an editorial differs.