Discord

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

SOTA-AI Editorial

Predicting the Shooting Percentage of Basketball Stars

Train a small constrained PyTorch MLP that predicts whether a basketball star's shot is made from the shooting position.

Written
8 October 2026
Result
0.5978 default-threshold OOF accuracy on a community mirror; threshold-tuned OOF is optimistic, and 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

Start with the made-shot fraction as an accuracy baseline.

Hint 2

Scale the two position coordinates using training data only.

Hint 3

Use a small nonlinear MLP to approximate the spatial success surface.

Hint 4

Check the exact layer count and output threshold before packaging the model.

The solution in one line

A compliant two-feature 2 → 8 → 8 → 1 MLP reaches 0.5978 default-threshold OOF accuracy on an unlicensed community mirror; organizer hidden-test performance is unmeasured.

The editorial

Predicting the Shooting Percentage of Basketball Stars — NOAI China 2024 Round 2

Problem domain: Binary classification from two spatial features
Evaluation metric: Accuracy; a submission that violates the architecture rules receives 0.

Abridged statement

Train a PyTorch MLP to predict whether a basketball shot is made. The model input must be exactly loc_x and loc_y, the output is one made/missed label, and only linear layers and allowed activations may be used. It may have at most three nn.Linear layers with at most eight neurons per layer; define the model class MyModel directly rather than wrapping layers in nn.Sequential. The task lists nn.ReLU, nn.Sigmoid, nn.Tanh, nn.ELU, nn.LeakyReLU, and nn.PReLU as allowed activations. The score is test accuracy when constraints are met and zero otherwise. The official submission is submission.zip containing submission_model.py and submission_dic.pth.

Dataset analysis and EDA

The SOTA task summary describes 20,000 training rows and about 5,000 hidden test rows. The public community mirror linked below instead contains a 25,000-row CSV with labels on every row. In that mirror, 13,814 shots are misses and 11,186 are made shots (44.74% positive); the majority-class accuracy is 55.26%. The coordinate ranges are loc_x −250 to 248 and loc_y −44 to 791. The source and split discrepancy means this file cannot be treated as the official 20k training split or as hidden-test evidence.

Only the two permitted location columns enter the model. The other columns in the mirror are not used. No organizer data files are stored in this repository.

Experiments and selected method

The authored model in basketball_shooting.py is a 2 → 8 → 8 → 1 MLP with Tanh hidden activations and a logits output. It trains with BCEWithLogitsLoss and Adam. Standardization is fit inside each training fold and then folded into the first linear layer, so the saved model accepts the raw loc_x, loc_y pair without adding another model layer.

Result Accuracy Provenance
Five-fold stratified OOF, default logit threshold 0 0.5978 Locally measured on the 25,000-row community mirror; ROC-AUC 0.6102. Fold accuracies: 0.5832–0.6088.
OOF threshold sweep, threshold 0.27 0.6088 Locally measured on the same OOF logits; the threshold was selected on those same folds, so this is an optimistic tuning result rather than an unbiased estimate. The training command can fold this threshold into the output bias.
Organizer leaderboard A/B 0.5604 / 0.5677 Accuracy reported in the linked participant notebook; neither value is a result from this repository.

The default OOF result is 4.52 percentage points above the mirror's majority-class baseline. The threshold sweep slightly improves the same OOF predictions, but should be reselected from an inner validation split before claiming an unbiased threshold-tuned estimate.

In train mode, the script now writes the required submission.zip with a standalone MyModel definition and the raw model state dictionary under the exact required names. The model source takes two raw features; its final bias includes the selected 0.27-logit threshold shift. Example: python solutions/china/2024/basketball_shooting.py --mode train --data /path/to/data_train.csv --output submission.zip. This packaging path has not been run on organizer data because those assets are unavailable.

Result, compute, and limits

The five-fold run used CPU PyTorch and completed in about 8 seconds in this workspace. No official score was measured here. Bohrium's data and hidden-test grader require access unavailable in this environment, and the only readable copy was the third-party mirror. Its GitHub repository metadata reports no license (license: null), while SOTA lists the organizer source license as unstated. The mirrored data were used only from /tmp; they are not redistributed here. The public leaderboard values above are explicitly participant-reported reference results, not a verified organizer scorecard export.

Alternatives considered

  • A majority-class prediction scores 55.26% on the mirror but ignores the position signal.
  • shot_distance, minutes_remaining, and shot_id could improve prediction but are outside the stated input restriction.
  • Wider or deeper MLPs violate the eight-neuron/three-linear-layer cap.
  • A threshold chosen from the hidden test would be leakage; the code only exposes a train-time threshold parameter.

Sources and reuse

The task statement/data license is not stated on the SOTA page; the community repository has no declared license. This report paraphrases the rules and links to the sources. It does not reproduce the statement, dataset, or notebook.

Code

Solution code solution.py · 139 lines
"""Train a constraint-compliant classifier for the NOAI China 2024 task 1.

The network receives only ``loc_x`` and ``loc_y``. Run with ``--mode validate``
to measure stratified cross-validation accuracy, or ``--mode train`` to fit all
available labeled rows and save a checkpoint for inference.
"""

from __future__ import annotations

import argparse
import random
import tempfile
import zipfile
from pathlib import Path

import numpy as np
import pandas as pd
import torch
from sklearn.metrics import accuracy_score, roc_auc_score
from sklearn.model_selection import StratifiedKFold
from sklearn.preprocessing import StandardScaler
from torch import nn


class MyModel(nn.Module):
    """Three linear layers, no more than eight neurons per layer."""

    def __init__(self) -> None:
        super().__init__()
        self.fc1 = nn.Linear(2, 8)
        self.fc2 = nn.Linear(8, 8)
        self.fc3 = nn.Linear(8, 1)
        self.activation = nn.Tanh()

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        x = self.activation(self.fc1(x))
        x = self.activation(self.fc2(x))
        return self.fc3(x).squeeze(-1)


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


def load_xy(path: Path) -> tuple[np.ndarray, np.ndarray]:
    frame = pd.read_csv(path)
    required = {"loc_x", "loc_y", "shot_made_flag"}
    missing = required.difference(frame.columns)
    if missing:
        raise ValueError(f"Missing columns in {path}: {sorted(missing)}")
    frame = frame.dropna(subset=["loc_x", "loc_y", "shot_made_flag"])
    x = frame[["loc_x", "loc_y"]].to_numpy(dtype=np.float32)
    y = frame["shot_made_flag"].to_numpy(dtype=np.float32)
    return x, y


def fit_model(
    x: np.ndarray,
    y: np.ndarray,
    *,
    seed: int = 42,
    epochs: int = 250,
    learning_rate: float = 0.01,
    weight_decay: float = 1e-3,
) -> MyModel:
    seed_everything(seed)
    scaler = StandardScaler().fit(x)
    x_tensor = torch.as_tensor(scaler.transform(x), dtype=torch.float32)
    y_tensor = torch.as_tensor(y, dtype=torch.float32)
    model = MyModel()
    optimizer = torch.optim.Adam(
        model.parameters(), lr=learning_rate, weight_decay=weight_decay
    )
    loss_fn = nn.BCEWithLogitsLoss()
    model.train()
    for _ in range(epochs):
        optimizer.zero_grad(set_to_none=True)
        loss = loss_fn(model(x_tensor), y_tensor)
        loss.backward()
        optimizer.step()
    # Fold the fitted standardization into the first affine layer so the saved
    # submission model itself still accepts only the two raw allowed features.
    with torch.no_grad():
        original_weight = model.fc1.weight.detach().clone()
        scale = torch.as_tensor(scaler.scale_, dtype=original_weight.dtype)
        mean = torch.as_tensor(scaler.mean_, dtype=original_weight.dtype)
        model.fc1.weight.copy_(original_weight / scale.unsqueeze(0))
        model.fc1.bias.sub_(original_weight @ (mean / scale))
    return model.eval()


def validate(path: Path, folds: int, seed: int, epochs: int) -> None:
    x, y = load_xy(path)
    splitter = StratifiedKFold(n_splits=folds, shuffle=True, random_state=seed)
    oof_logits = np.empty(len(y), dtype=np.float32)
    fold_rows = []
    for fold, (train_idx, valid_idx) in enumerate(splitter.split(x, y), start=1):
        model = fit_model(
            x[train_idx], y[train_idx], seed=seed + fold, epochs=epochs
        )
        valid_x = torch.as_tensor(x[valid_idx], dtype=torch.float32)
        with torch.inference_mode():
            logits = model(valid_x).cpu().numpy()
        oof_logits[valid_idx] = logits
        fold_rows.append(
            (fold, accuracy_score(y[valid_idx], logits >= 0), roc_auc_score(y[valid_idx], logits))
        )
    for fold, accuracy, auc in fold_rows:
        print(f"fold={fold} accuracy={accuracy:.4f} roc_auc={auc:.4f}")
    print(f"oof_accuracy={accuracy_score(y, oof_logits >= 0):.4f}")
    print(f"oof_roc_auc={roc_auc_score(y, oof_logits):.4f}")
    print(f"rows={len(y)} positive_rate={y.mean():.4f}")


def submission_model_source() -> str:
    """Return the standalone model definition expected inside submission.zip."""
    return '''import torch\nfrom torch import nn\n\n\nclass MyModel(nn.Module):\n    def __init__(self):\n        super().__init__()\n        self.fc1 = nn.Linear(2, 8)\n        self.fc2 = nn.Linear(8, 8)\n        self.fc3 = nn.Linear(8, 1)\n        self.activation = nn.Tanh()\n\n    def forward(self, x):\n        x = self.activation(self.fc1(x))\n        x = self.activation(self.fc2(x))\n        return self.fc3(x).squeeze(-1)\n'''


def write_submission_package(model: MyModel, output: Path) -> None:
    """Write the two required submission files at the root of a zip archive."""
    output.parent.mkdir(parents=True, exist_ok=True)
    with tempfile.TemporaryDirectory(prefix="basketball-submission-") as temp_dir:
        temp = Path(temp_dir)
        model_file = temp / "submission_model.py"
        weights_file = temp / "submission_dic.pth"
        model_file.write_text(submission_model_source(), encoding="utf-8")
        torch.save(model.state_dict(), weights_file)
        with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as archive:
            archive.write(model_file, arcname="submission_model.py")
            archive.write(weights_file, arcname="submission_dic.pth")


def train_all(path: Path, output: Path, seed: int, epochs: int, threshold: float) -> None:
    x, y = load_xy(path)
    model = fit_model(x, y, seed=seed, epochs=epochs)
    # The task grader applies a 0.5 sigmoid threshold, equivalent to logit 0.
    # Shift the output bias to implement a threshold measured from OOF logits.
    with torch.no_grad():
        model.fc3.bias.sub_(threshold)
    write_submission_package(model, output)
    print(f"saved {output} (submission_model.py, submission_dic.pth; threshold={threshold:g})")


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--mode", choices=["validate", "train"], default="validate")
    parser.add_argument("--data", type=Path, required=True, help="CSV with loc_x, loc_y, shot_made_flag")
    parser.add_argument("--folds", type=int, default=5)
    parser.add_argument("--seed", type=int, default=42)
    parser.add_argument("--epochs", type=int, default=250)
    parser.add_argument("--threshold", type=float, default=0.27)
    parser.add_argument("--output", type=Path, default=Path("submission.zip"), help="Output submission.zip")
    args = parser.parse_args()
    torch.set_num_threads(2)
    if args.mode == "validate":
        validate(args.data, args.folds, args.seed, args.epochs)
    else:
        train_all(args.data, args.output, args.seed, args.epochs, args.threshold)


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.