Checklist NOAI China 2024 Round 2 (China Stage) · Task 4
SOTA-AI Editorial
Solving the Pendulum Motion with Missing Data
Use PyTorch to regress the parameters of a damped pendulum with a hidden change of forcing from an interrupted angle recording, then extrapolate its motion.
Hints
Hint 1
Detect the gap from the time differences, not from angle values.
Hint 2
Fit damping and the pre-force frequency using only contiguous pre-gap observations.
Hint 3
Use the post-gap phase to estimate the changed frequency and force start.
Hint 4
Integrate the fitted post-force system beyond the final sample to find the next zero.
The solution in one line
The visible mirrored training curve is fit to 2.70e−4 rad RMSE with a PyTorch-initialized physical model, but the official parameter score is unmeasured.
The editorial
Solving the Pendulum Motion with Missing Data — NOAI China 2024 Round 2
Problem domain: Time-series physics parameter estimation
Evaluation metric: Score = (S1 + S2 + 2S3 + 2S4 + 2S5) / 8, where S1 = exp(−10|l̂−l|), S2 = exp(−10|μ̂−μ|), S3 = exp(−|F̂−F|), S4 = exp(−10|t̂_nextzero−t_nextzero|), and S5 = exp(−10|t̂_Fput−t_Fput|). A wrongly formatted submission scores 0.
Abridged statement
From an interrupted angle recording, infer a pendulum's rope length l, damping coefficient μ (miu in the required CSV), the magnitude F of a downward force that begins at unknown time t_Fput during a missing interval, and the next time the angle reaches zero after recording ends. The task uses g = 9.8; the motion is governed by θ'' = −μ θ' − (g/l + F/l · I[t ≥ t_Fput]) sin(θ) for unit mass. PyTorch is required for regression. The full submission writes the five quantities to the prescribed CSV columns.
Dataset analysis and EDA
The community mirror's pendulum_train.csv has 734 observed points from t=0 to t=15 seconds. The normal step is about 0.015015 seconds. One 4.009-second gap runs from t=3.003 to t=7.012; the second visible segment continues to the end of the recording. The two hidden evaluation recordings/labels were not accessible.
Experiments and selected method
pendulum_motion.py detects the interval from the sampling-time jump, reconstructs its regular grid, and uses torch.linalg.lstsq regression on finite-difference derivatives to initialize the physical coefficients. It then refines the piecewise nonlinear ODE fit with SciPy least_squares/solve_ivp and estimates the next zero crossing from the fitted terminal state. Thus PyTorch is used for the required regression initialization, while nonlinear refinement and integration use SciPy. The reported parameter fit uses all visible train angles.
| Result | Value | Provenance |
|---|---|---|
| Rope length l | 4.89923 | Local fit to the visible mirrored training trajectory |
| Damping μ | 0.50077 | Local fit; written as miu in the output CSV |
| Force F | 39.95445 | Local fit |
| Force-start time t_Fput | 4.41649 s | Local fit inside the missing interval |
| Next zero crossing | 15.87686 s | Extrapolated after the recording ends |
| RMSE on observed angles | 0.000270 rad | Fit diagnostic only; not the task score |
| Participant leaderboard A/B score | 0.8710 / 0.9186 | Values reported by the third-party participant notebook, not this implementation |
The mirror's own submission_train.csv contains a separate candidate estimate (4.91793, 0.49931, 40.13448, 15.8825, 4.41800). This is another candidate output, not ground truth, so its closeness is only a sanity check. No parameter score was measured here because the organizer's true parameters and hidden A/B test recordings are unavailable.
Result, compute, and limits
The visible-curve fit completed in about 16 seconds on CPU. Its small angle RMSE shows that the piecewise dynamics explain the mirrored training recording, but it does not verify accuracy on different hidden parameters. In batch mode the script reads one training file and two evaluation files, writes submission_train.csv separately, writes one-row submissionA.csv and submissionB.csv files with the exact columns l,miu,F,t_nextzerotheta,t_Fput, and zips A/B as submission.zip. Example: python solutions/china/2024/pendulum_motion.py --train /path/pendulum_train.csv --test-a /path/pendulum_testA.csv --test-b /path/pendulum_testB.csv --output-dir submission. The hidden A/B inputs were unavailable, so only the training candidate was produced locally; the batch output and archive were not generated on hidden data. Bohrium data/grader access was unavailable.
The SOTA page lists the organizer source license as unstated. The community GitHub repository has license: null. The mirror and notebook were kept under /tmp and are not included here.
Alternatives considered
- Directly smoothing and differentiating the full trace is fragile around the missing interval; derivative regression is limited to contiguous samples.
- A linearized small-angle model simplifies fitting but introduces bias when the initial angle is not small.
- Fitting the exact nonlinear pendulum equation and switching the forcing at a profiled time better uses the visible waveform, though it cannot reveal the hidden-test score.
Sources and reuse
- SOTA task page and official-material links
- Bohrium task page
- Community notebook and mirrored training/output files
The original materials have no license stated on the SOTA page, and the community repository declares no license. This report paraphrases the task and links to the sources without reproducing the notebook or any organizer data.
Code
Solution code
"""PyTorch-initialized pendulum fitting for NOAI China 2024 task 4.
The method reconstructs the missing time grid, uses ``torch.linalg.lstsq`` on
finite differences for regression initialization, then refines the nonlinear
ODE fit with SciPy's ``least_squares`` and ``solve_ivp``. It reports candidates;
official hidden A/B recordings are required for the three-file submission run.
"""
from __future__ import annotations
import argparse
from pathlib import Path
import zipfile
import numpy as np
import pandas as pd
import torch
from scipy.integrate import solve_ivp
from scipy.optimize import least_squares
GRAVITY = 9.8
def find_gap(time: np.ndarray) -> tuple[int, int, float]:
steps = np.diff(time)
nominal = float(np.median(steps[steps < np.quantile(steps, 0.75) * 2]))
indices = np.flatnonzero(steps > max(0.25, 5.0 * nominal))
if len(indices) != 1:
raise ValueError(f"Expected one missing interval; found {len(indices)}")
left = int(indices[0])
return left, left + 1, nominal
def reconstruct_grid(time: np.ndarray, nominal_step: float) -> tuple[np.ndarray, np.ndarray]:
count = int(round((time[-1] - time[0]) / nominal_step)) + 1
grid = np.linspace(time[0], time[-1], count, dtype=np.float64)
indices = np.rint((time - time[0]) / (grid[1] - grid[0])).astype(np.int64)
if np.max(np.abs(grid[indices] - time)) > 2e-4:
raise ValueError("Recorded times are not aligned to a recoverable regular grid")
return grid, indices
def torch_initial_guess(
theta: np.ndarray,
observed_indices: np.ndarray,
grid: np.ndarray,
left_index: int,
right_index: int,
) -> tuple[float, float, float]:
"""Use local finite differences and torch.linalg.lstsq for an initial fit."""
values = torch.as_tensor(theta, dtype=torch.float64)
grid_step = float(grid[1] - grid[0])
rows: list[list[float]] = []
targets: list[float] = []
segment_ids: list[int] = []
for j in range(1, len(observed_indices) - 1):
center = int(observed_indices[j])
previous = int(observed_indices[j - 1])
following = int(observed_indices[j + 1])
if center - previous != 1 or following - center != 1:
continue
if center < left_index:
segment = 0
elif center > right_index:
segment = 1
else:
continue
velocity = (values[j + 1] - values[j - 1]) / (2.0 * grid_step)
acceleration = (values[j + 1] - 2.0 * values[j] + values[j - 1]) / (grid_step**2)
if segment == 0:
rows.append([-float(velocity), -float(torch.sin(values[j])), 0.0])
else:
rows.append([-float(velocity), 0.0, -float(torch.sin(values[j]))])
targets.append(float(acceleration))
segment_ids.append(segment)
if len(rows) < 10 or set(segment_ids) != {0, 1}:
raise ValueError("Insufficient contiguous observations before and after the gap")
design = torch.tensor(rows, dtype=torch.float64)
target = torch.tensor(targets, dtype=torch.float64).unsqueeze(1)
solution = torch.linalg.lstsq(design, target).solution[:, 0]
mu = max(0.01, float(solution[0]))
beta_before = max(0.1, float(solution[1]))
beta_after = max(beta_before + 0.01, float(solution[2]))
length = float(np.clip(GRAVITY / beta_before, 0.3, 40.0))
force = float(np.clip(length * (beta_after - beta_before), 0.1, 1000.0))
return length, mu, force
def simulate_observations(
time: np.ndarray,
initial_angle: float,
length: float,
damping: float,
force: float,
force_time: float,
) -> tuple[np.ndarray, float]:
"""Integrate before/after the unknown force switch at full precision."""
gap_mask = time > force_time
before_time = time[~gap_mask]
after_time = time[gap_mask]
def equation(beta: float):
def derivative(_t: float, state: np.ndarray) -> tuple[float, float]:
theta_value, omega_value = state
return omega_value, -damping * omega_value - beta * np.sin(theta_value)
return derivative
beta_before = GRAVITY / length
beta_after = GRAVITY / length + force / length
before = solve_ivp(
equation(beta_before),
(float(time[0]), force_time),
(initial_angle, 0.0),
method="DOP853",
t_eval=np.concatenate((before_time, [force_time])),
rtol=1e-9,
atol=1e-11,
max_step=0.03,
)
state_at_force = before.y[:, -1]
predicted = np.empty_like(time)
predicted[~gap_mask] = before.y[0, :-1]
after = solve_ivp(
equation(beta_after),
(force_time, float(time[-1])),
state_at_force,
method="DOP853",
t_eval=after_time,
rtol=1e-9,
atol=1e-11,
max_step=0.03,
)
predicted[gap_mask] = after.y[0]
return predicted, float(after.y[1, -1])
def next_zero_crossing(
start_time: float,
angle: float,
velocity: float,
length: float,
damping: float,
force: float,
) -> float:
beta = GRAVITY / length + force / length
def equation(_t: float, state: np.ndarray) -> tuple[float, float]:
theta, omega = state
return omega, -damping * omega - beta * np.sin(theta)
def zero(_t: float, state: np.ndarray) -> float:
return float(state[0])
zero.terminal = True
zero.direction = 0
result = solve_ivp(
equation,
(start_time, start_time + 40.0),
(angle, velocity),
method="DOP853",
rtol=1e-10,
atol=1e-12,
events=zero,
max_step=0.01,
)
if len(result.t_events[0]) == 0:
return float("nan")
crossing = float(result.t_events[0][0])
if crossing <= start_time + 1e-7:
# If the last recorded angle is effectively zero, return the next crossing.
result = solve_ivp(
equation,
(start_time + 1e-5, start_time + 40.0),
result.y[:, -1],
method="DOP853",
rtol=1e-10,
atol=1e-12,
events=zero,
max_step=0.01,
)
if len(result.t_events[0]) == 0:
return float("nan")
crossing = float(result.t_events[0][0])
return crossing
def solve_recording(path: Path, *, max_nfev: int = 250) -> dict[str, float]:
frame = pd.read_csv(path)
if not {"t", "theta"}.issubset(frame.columns):
raise ValueError(f"Expected t and theta columns in {path}")
frame = frame.sort_values("t")
time = frame["t"].to_numpy(dtype=np.float64)
theta = frame["theta"].to_numpy(dtype=np.float64)
left_observed, right_observed, nominal = find_gap(time)
grid, observed_indices = reconstruct_grid(time, nominal)
left = int(observed_indices[left_observed])
right = int(observed_indices[right_observed])
init_length, init_damping, init_force = torch_initial_guess(
theta, observed_indices, grid, left, right
)
gap_left = float(grid[left])
gap_right = float(grid[right])
bounds = (
[0.3, 0.0, 0.0, gap_left + 1e-7],
[40.0, 20.0, 2000.0, gap_right - 1e-7],
)
best_fit = None
for fraction in (0.2, 0.5, 0.8):
initial = [init_length, init_damping, init_force, gap_left + fraction * (gap_right - gap_left)]
def residual(parameters: np.ndarray) -> np.ndarray:
prediction, _ = simulate_observations(
time, float(theta[0]), *parameters
)
return prediction - theta
fit = least_squares(
residual,
initial,
bounds=bounds,
x_scale="jac",
max_nfev=max_nfev,
ftol=1e-10,
xtol=1e-10,
gtol=1e-10,
)
if best_fit is None or np.dot(fit.fun, fit.fun) < np.dot(best_fit.fun, best_fit.fun):
best_fit = fit
assert best_fit is not None
length, damping, force, force_time = [float(p) for p in best_fit.x]
predicted, velocity_end = simulate_observations(
time, float(theta[0]), length, damping, force, force_time
)
residual_rmse = float(np.sqrt(np.mean((predicted - theta) ** 2)))
zero_time = next_zero_crossing(
float(time[-1]), float(theta[-1]), velocity_end, length, damping, force
)
return {
"l": length,
"miu": damping,
"F": force,
"t_nextzerotheta": zero_time,
"t_Fput": force_time,
"observed_angle_rmse": residual_rmse,
"gap_start": gap_left,
"gap_end": gap_right,
}
OUTPUT_COLUMNS = ("l", "miu", "F", "t_nextzerotheta", "t_Fput")
def write_result_csv(result: dict[str, float], output: Path) -> None:
output.parent.mkdir(parents=True, exist_ok=True)
pd.DataFrame([{key: result[key] for key in OUTPUT_COLUMNS}]).to_csv(output, index=False)
def solve_batch(
train_path: Path,
test_a_path: Path,
test_b_path: Path,
output_dir: Path,
*,
max_nfev: int,
) -> None:
"""Fit the visible training trace and both hidden recordings into named CSVs."""
output_dir.mkdir(parents=True, exist_ok=True)
inputs = (
("submission_train.csv", train_path),
("submissionA.csv", test_a_path),
("submissionB.csv", test_b_path),
)
for filename, input_path in inputs:
result = solve_recording(input_path, max_nfev=max_nfev)
output = output_dir / filename
write_result_csv(result, output)
print(f"{filename}: { {key: round(result[key], 8) for key in OUTPUT_COLUMNS} }")
print(f"saved {output}")
archive_path = output_dir / "submission.zip"
with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED) as archive:
for filename in ("submissionA.csv", "submissionB.csv"):
archive.write(output_dir / filename, arcname=filename)
print(f"saved {archive_path} (A/B CSVs; training CSV remains separate)")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--input", type=Path, help="Single CSV containing one t/theta recording")
parser.add_argument("--output", type=Path, default=Path("submission.csv"), help="Single-result CSV output")
parser.add_argument("--train", type=Path, help="Training recording for the three-file submission mode")
parser.add_argument("--test-a", type=Path, help="Hidden A recording CSV")
parser.add_argument("--test-b", type=Path, help="Hidden B recording CSV")
parser.add_argument("--output-dir", type=Path, default=Path("submission"), help="Directory for named batch CSVs")
parser.add_argument("--max-nfev", type=int, default=250, help="Maximum solver evaluations per initial event-time guess")
args = parser.parse_args()
torch.set_num_threads(1)
batch_paths = (args.train, args.test_a, args.test_b)
if any(path is not None for path in batch_paths):
if not all(path is not None for path in batch_paths):
parser.error("batch mode requires all of --train, --test-a, and --test-b")
solve_batch(*batch_paths, args.output_dir, max_nfev=args.max_nfev)
else:
if args.input is None:
parser.error("provide --input for one recording or all three batch paths")
result = solve_recording(args.input, max_nfev=args.max_nfev)
write_result_csv(result, args.output)
print("candidate:", {k: round(v, 8) for k, v in result.items()})
print(f"saved {args.output}")
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.