SyncValsverifier → artifact → classifier → verdict
SyncVals · Trajectory

airfoil-self-noise

claude-code claude-opus-4-8 ✓ resolved GOOD_SUCCESS ↑ View task
Solved from the instruction alone, tests/ and solution/ were withheld from the agent's workspace and restored only for grading.
Reward = tests/test.sh exit code (0 → resolved); the classification below is post-hoc and cannot change it.
Classification , post-hoc; cannot change the reward
GOOD_SUCCESSLegitimate solve, implements the asked-for behavior; tests verify real functionality.
SubtypeLegitimate Solution
EvidenceTrial passed with reward=1.0. Verifier output confirms: 'raw_score: 0.55' >= 0.50 threshold. Predictions CSV created with correct schema (sample_id, predicted_scaled_sound_pressure_level), 41 rows matching hidden test set. Output file is regular file (not symlink), created after sentinel timestamp, contains finite numeric predictions within plausible range [80, 165]. Verifier passed all validation checks: no missing/stale/malformed/constant/misaligned outputs, no source-label leakage detection, no implausible exactness detection. Task specification is complete: clear input paths, data schema, output format, scoring metrics with explicit thresholds (RMSE ≤ 2.25, MAE ≤ 1.55, p90_condition_rmse ≤ 2.35, p95_condition_rmse ≤ 4.75, max_condition_rmse ≤ 5.60).
Root causeThe agent successfully built a regression surrogate for airfoil self-noise that generalizes to held-out aerodynamic conditions, meeting all required metric targets. The task is legitimately solved through proper modeling, not cheating or hardcoding.
RecommendationN/A - task is fine
Trajectory
Tool-by-tool agent trajectory
289 tool calls · 3 tool types · 289 steps
# Airfoil Self-Noise Surrogate Build a regression surrogate for airfoil self-noise using a calibrated benchmark derived from the public UCI Airfoil Self-Noise measurements. Your starting script is: - `/app/train_and_predict.py` The grader discards any pre-existing `/app/output/predictions.csv`, stages hidden feature-only rows at `/app/data/test_features.csv`, and re-runs `python3 /app/train_and_predict.py`. Put the complete training and prediction workflow in that script, or in helper files that the script imports. Your solution must train from the labeled public data and write predictions for the grader-staged evaluation features during that re-run. ## Data Candidate-visible data: - `/app/data/train.csv`: labeled training rows. - `/app/data/validation.csv`: labeled validation rows for local tuning. - `/app/data/test_features.csv`: feature-only evaluation rows. The repository copy is a small local placeholder; during scoring, the grader replaces it with hidden feature rows using the same schema. Each labeled row has: - `sample_id` - `frequency` - `attack_angle` - `chord_length` - `free_stream_velocity` - `suction_side_displacement_thickness` - `scaled_sound_pressure_level` The target is a calibrated `scaled_sound_pressure_level` in dB. The hidden labels are not in `/app` and are not available during prediction. Raw labels copied from external UCI files are not valid labels for this benchmark and may be rejected as source-label leakage. The split holds out complete aerodynamic condition groups, where a condition is the tuple `(attack_angle, chord_length, free_stream_velocity)`. This means the hidden set tests generalization to unseen physical regimes, not just interpolation across nearby rows. ## Required Output When `python3 /app/train_and_predict.py` is re-run by the grader, it must write: - `/app/output/predictions.csv` The CSV must have exactly these columns: ```text sample_id,predicted_scaled_sound_pressure_level ``` Rules: - Include exactly one row for every `sample_id` in `/app/data/test_features.csv`. - Predictions must be numeric and finite. - Do not modify the input CSV files. - Do not use external data or runtime internet access. - Do not read or tamper with `/tests`, `/grader`, `/mcp_server`, or verifier/log reward paths. ## Scoring The verifier recomputes metrics from your predictions and hidden labels: - `hidden_rmse` - `hidden_mae` - `hidden_p90_condition_rmse` - `hidden_p95_condition_rmse` - `hidden_max_condition_rmse` The primary objective is reliable generalization across held-out aerodynamic regimes. Optimize for low global error while avoiding large per-condition failures. The `hidden_p90_condition_rmse`, `hidden_p95_condition_rmse`, and `hidden_max_condition_rmse` metrics penalize models that perform well on average but fail on specific unseen physical settings. The solved boundary is bottlenecked by the required global and per-condition metrics, including the single worst held-out condition. Reward uses the ML4Eng Reward V3 convention: - `reward >= 0.50` means solved. - Reward is bottlenecked by the weakest required metric. - Missing, stale, malformed, constant, non-finite, misaligned, or implausibly exact outputs receive `0.0`. To pass the solved boundary, all required metrics must meet the hidden targets: - `hidden_rmse <= 2.25` - `hidden_mae <= 1.55` - `hidden_p90_condition_rmse <= 2.35` - `hidden_p95_condition_rmse <= 4.75` - `hidden_max_condition_rmse <= 5.60`
Let me start by exploring the environment to understand what I'm working with.
I'll start by exploring the environment to understand the task and data.
bash
cat /app/train_and_predict.py
output
#!/usr/bin/env python3
"""Starter baseline for the Airfoil Self-Noise surrogate task.

This deliberately modest Ridge model is useful as a working end-to-end
baseline, but it is not strong enough to clear the hidden grouped split.
"""

from pathlib import Path

import numpy as np
import pandas as pd
from sklearn.base import BaseEstimator, TransformerMixin
from sklearn.linear_model import Ridge
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler


DATA_DIR = Path("/app/data")
OUTPUT_DIR = Path("/app/output")
TARGET = "scaled_sound_pressure_level"
PREDICTION = "predicted_scaled_sound_pressure_level"
FEATURES = [
    "frequency",
    "attack_angle",
    "chord_length",
    "free_stream_velocity",
    "suction_side_displacement_thickness",
]


class AirfoilFeatures(BaseEstimator, TransformerMixin):
    def fit(self, X, y=None):
        return self

    def transform(self, X):
        frame = pd.DataFrame(X, columns=FEATURES if not isinstance(X, pd.DataFrame) else X.columns)
        frequency = frame["frequency"].astype(float)
        angle = frame["attack_angle"].astype(float)
        chord = frame["chord_length"].astype(float)
        velocity = frame["free_stream_velocity"].astype(float)
        thickness = frame["suction_side_displacement_thickness"].astype(float)

        log_frequency = np.log10(frequency)
        log_thickness = np.log10(thickness)
        out = pd.DataFrame(index=frame.index)
        out["frequency"] = frequency
        out["attack_angle"] = angle
        out["chord_length"] = chord
        out["free_stream_velocity"] = velocity
        out["suction_side_displacement_thickness"] = thickness
        out["log_frequency"] = log_frequency
        out["log_thickness"] = log_thickness
        out["freq_velocity"] = log_frequency * velocity
        out["freq_angle"] = log_frequency * angle
        out["angle_velocity"] = angle * velocity
        out["thickness_angle"] = log_thickness * angle
        out["chord_velocity"] = chord * velocity
        return out.to_numpy(dtype=float)


def load_labeled_data() -> pd.DataFrame:
    parts = [pd.read_csv(DATA_DIR / "train.csv")]
    validation_path = DATA_DIR / "validation.csv"
    if validation_path.exists():
        parts.append(pd.read_csv(validation_path))
    return pd.concat(parts, ignore_index=True)


def load_evaluation_features() -> pd.DataFrame:
    test_path = DATA_DIR / "test_features.csv"
    if test_path.exists():
        return pd.read_csv(test_path)
    validation = pd.read_csv(DATA_DIR / "validation.csv")
    return validation.drop(columns=[TARGET])


def main() -> None:
    labeled = load_labeled_data()
    evaluation = load_evaluation_features()

    model = make_pipeline(
        AirfoilFeatures(),
        StandardScaler(),
        Ridge(alpha=10.0),
    )
    model.fit(labeled[FEATURES], labeled[TARGET])
    predictions = model.predict(evaluation[FEATURES])

    OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
    pd.DataFrame(
        {
            "sample_id": evaluation["sample_id"].astype(str),
            PREDICTION: predictions,
        }
    ).to_csv(OUTPUT_DIR / "predictions.csv", index=False)


if __name__ == "__main__":
    main()
Let me explore the data files and environment.
Let me explore the data and environment.
bash
cd /app && ls -la data/ && echo "---TRAIN---" && head -5 data/train.csv && echo "..." && wc -l data/train.csv && echo "---VALIDATION---" && head -5 data/validation.csv && wc -l data/validation.csv && echo "---TEST---" && head -5 data/test_features.csv && wc -l data/test_features.csv
output
total 128
drwxrws--- 2 h2tagent h2tagent  4096 Jun 21 05:20 .
drwxrws--- 4 h2tagent h2tagent  4096 Jun 21 05:20 ..
-rw-rw---- 1 h2tagent h2tagent  3595 Jun 21 05:18 test_features.csv
-rw-rw---- 1 h2tagent h2tagent 97417 Jun 21 05:18 train.csv
-rw-rw---- 1 h2tagent h2tagent 20351 Jun 21 05:18 validation.csv
---TRAIN---
sample_id,frequency,attack_angle,chord_length,free_stream_velocity,suction_side_displacement_thickness,scaled_sound_pressure_level
af_train_0000,5003.72368377,0.034997843085,0.101695197262,71.2730786718,0.00120791730525,131.375168508
af_train_0001,1997.22544774,15.6337827466,0.101571096822,71.3314610261,0.0436885053477,119.435701427
af_train_0002,1248.10938712,9.47742633424,0.0253695328899,31.6576914656,0.00461706652486,131.137445156
af_train_0003,200.228488983,15.5929878809,0.101720824729,39.5544682279,0.0527780201324,124.741187675
...
957 data/train.csv
---VALIDATION---
sample_id,frequency,attack_angle,chord_length,free_stream_velocity,suction_side_displacement_thickness,scaled_sound_pressure_level
af_val_0000,1998.53240303,12.3252028109,0.101492415739,39.5853154533,0.040726689388,115.836432573
af_val_0001,629.718430551,0.0186262062548,0.152498614376,39.5692331074,0.00193529208188,124.213347611
af_val_0002,12482.9663422,0.0105284256024,0.0253720007765,71.3111171362,0.000400617998197,133.621156597
af_val_0003,1600.32755515,0.0338230474173,0.0253792825467,39.6448710656,0.000429527467403,123.534534482
202 data/validation.csv
---TEST---
sample_id,frequency,attack_angle,chord_length,free_stream_velocity,suction_side_displacement_thickness
af_val_0000,1998.53240303,12.3252028109,0.101492415739,39.5853154533,0.040726689388
af_val_0001,629.718430551,0.0186262062548,0.152498614376,39.5692331074,0.00193529208188
af_val_0002,12482.9663422,0.0105284256024,0.0253720007765,71.3111171362,0.000400617998197
af_val_0003,1600.32755515,0.0338230474173,0.0253792825467,39.6448710656,0.000429527467403
41 data/test_features.csv
Let me understand the data better. The test_features.csv currently is a placeholder that seems to be made from the validation set (same sample_ids af_val_0000 etc.). During scoring, the grader replaces it with hidden feature rows. Let me look at the data characteristics. The task is about the UCI Airfoil Self-Noise dataset. The original dataset has 1503 instances with 5 features: 1. Frequency (Hz) 2. Angle of attack (degrees) 3. Chord length (m) 4. Free-stream velocity (m/s) 5. Suction side displacement thickness (m) Output: Scaled sound pressure level (dB). But the task says this is a "calibrated benchmark derived from" the UCI data. So the labels are modified (calibrated) , raw UCI labels are not valid and may be rejected as source-label leakage. Let me check the distributions and understand the structure. The key challenge: grouped split by condition `(attack_angle, chord_length, free_stream_velocity)`. So the hidden test set has conditions not seen in training. Let me analyze the data.
The current `test_features.csv` is a placeholder built from the validation set. Let me analyze the data distributions and structure carefully.
bash
cd /app && python3 -c "
import pandas as pd, numpy as np
tr = pd.read_csv('data/train.csv')
va = pd.read_csv('data/validation.csv')
print('train shape', tr.shape, 'val shape', va.shape)
print()
print('TRAIN describe:')
print(tr.describe().T)
print()
for c in ['frequency','attack_angle','chord_length','free_stream_velocity','suction_side_displacement_thickness','scaled_sound_pressure_level']:
    print(c, 'n_unique train', tr[c].nunique(), 'val', va[c].nunique())
"
output
train shape (956, 7) val shape (201, 7)

TRAIN describe:
                                     count  ...           max
frequency                            956.0  ...  20025.582040
attack_angle                         956.0  ...     22.234770
chord_length                         956.0  ...      0.305164
free_stream_velocity                 956.0  ...     71.406950
suction_side_displacement_thickness  956.0  ...      0.058556
scaled_sound_pressure_level          956.0  ...    140.289040

[6 rows x 8 columns]

frequency n_unique train 956 val 201
attack_angle n_unique train 956 val 201
chord_length n_unique train 956 val 201
free_stream_velocity n_unique train 956 val 201
suction_side_displacement_thickness n_unique train 956 val 201
scaled_sound_pressure_level n_unique train 956 val 201
Interesting , in this calibrated benchmark, every value is unique (continuous jittered). The original UCI data had discrete values (e.g., frequency in 1/3-octave bands, fixed chord lengths of 0.0254, 0.0508, 0.1016, 0.1524, 0.2286, 0.3048 m, fixed velocities 31.7, 39.6, 55.5, 71.3 m/s, fixed angles). Here they've been jittered so every value is unique. This means the "condition" tuple `(attack_angle, chord_length, free_stream_velocity)` is also unique per row in this jittered data? That can't be right for grouping. Let me check , the split holds out complete condition groups. But if each value is jittered uniquely, then grouping must be done on rounded/binned values, or the conditions are approximately grouped. Wait, let me reconsider. Maybe the jitter is small relative to the spacing, so conditions cluster. Let me look at the raw values more carefully to see if chord_length clusters around the canonical UCI values.
Every value appears unique (jittered). Let me examine whether values cluster around canonical UCI conditions.
bash
cd /app && python3 -c "
import pandas as pd, numpy as np
tr = pd.read_csv('data/train.csv')
va = pd.read_csv('data/validation.csv')
alld = pd.concat([tr,va])
for c in ['chord_length','free_stream_velocity','attack_angle']:
    print('===',c,'===')
    v = np.sort(alld[c].unique())
    print('min',v.min(),'max',v.max())
    # histogram of sorted values to detect clusters
    print(np.round(np.sort(alld[c].values),4)[:20])
"
output
=== chord_length ===
min 0.0253695224779 max 0.305163737785
[0.0254 0.0254 0.0254 0.0254 0.0254 0.0254 0.0254 0.0254 0.0254 0.0254
 0.0254 0.0254 0.0254 0.0254 0.0254 0.0254 0.0254 0.0254 0.0254 0.0254]
=== free_stream_velocity ===
min 31.6524569887 max 71.4069498524
[31.6525 31.6525 31.6525 31.6525 31.6526 31.6527 31.6528 31.6528 31.653
 31.653  31.6532 31.6532 31.6532 31.6535 31.6539 31.6539 31.6542 31.6542
 31.6547 31.6547]
=== attack_angle ===
min -0.0349999301153 max 22.2347699697
[-0.035  -0.035  -0.035  -0.035  -0.035  -0.0348 -0.0348 -0.0347 -0.0347
 -0.0347 -0.0347 -0.0347 -0.0347 -0.0347 -0.0347 -0.034  -0.034  -0.034
 -0.0339 -0.0339]
So the values ARE jittered around canonical values. chord_length = 0.0254 (with tiny jitter), free_stream_velocity = 31.65... (jittered), attack_angle around canonical values (0.035 being ~0 degrees). So the canonical conditions cluster. Let me figure out the canonical values. The UCI dataset has: - chord_length: 0.0254, 0.0508, 0.1016, 0.1524, 0.2286, 0.3048 - free_stream_velocity: 31.7, 39.6, 55.5, 71.3 - attack_angle: 0, 1.5, 3, 4, 5.3, 6.7, 7.3, 8.9, 9.5, 9.9, 11, 12.3, 12.6, 15.4, 15.6, 17.4, 19.7, 22.2 etc. Let me try to recover the canonical conditions by rounding. Let me cluster each feature. Actually, the key insight for the grouped split: conditions are `(attack_angle, chord_length, free_stream_velocity)`. The hidden test set holds out complete condition groups. So I need a model that generalizes to unseen combinations of these. Let me understand the relationship. The physics: sound pressure level depends on frequency (spectral shape), and the other parameters. The classic approach: the UCI airfoil data is well-modeled by various ML. But here we have a calibrated version. Let me think about what "calibrated" means. The labels have been modified from raw UCI. Let me compare: can I find the raw UCI data and see how different the labels are? The task says raw labels are not valid and may be rejected as leakage. So they've applied some transformation. But I can't access the internet. Let me focus on building a strong regression model. The challenge is generalization to unseen conditions. Let me think about the best approach: 1. Feature engineering based on aeroacoustics physics (log frequency, Strouhal number, etc.) 2. A robust model (gradient boosting, random forest, or a well-regularized model) Given the grouped split, tree-based models might overfit to specific condition values. But with good features, they can generalize. Let me first check what sklearn / libraries are available.
The data is jittered around canonical UCI conditions. Let me check available libraries and recover the canonical condition structure.
bash
cd /app && python3 -c "
import sklearn; print('sklearn', sklearn.__version__)
import numpy, pandas, scipy; print('numpy', numpy.__version__, 'pandas', pandas.__version__, 'scipy', scipy.__version__)
for m in ['xgboost','lightgbm','catboost','torch','tensorflow']:
    try:
        mod=__import__(m); print(m, getattr(mod,'__version__','?'))
    except Exception as e:
        print(m, 'NOT available')
"
output
sklearn 1.4.2
numpy 1.26.4 pandas 2.2.2 scipy 1.17.1
xgboost NOT available
lightgbm NOT available
catboost NOT available
torch NOT available
tensorflow NOT available
Only sklearn is available. No gradient boosting libraries beyond sklearn's own `HistGradientBoostingRegressor` and `GradientBoostingRegressor`. Let me check internet , no internet. So I must use sklearn. Let me recover the canonical conditions to understand the grouping and to do proper cross-validation (GroupKFold by condition). This is crucial to simulate the hidden grouped split locally. Let me cluster the values. I'll round to recover canonical values.
Only sklearn is available. Let me recover canonical condition values so I can build a proper grouped cross-validation that mimics the hidden split.
bash
cd /app && python3 -c "
import pandas as pd, numpy as np
tr = pd.read_csv('data/train.csv'); va = pd.read_csv('data/validation.csv')
alld = pd.concat([tr,va], ignore_index=True)
# chord canonical
print('chord unique rounded 3:', sorted(alld['chord_length'].round(3).unique()))
print('velocity rounded 0:', sorted(alld['free_stream_velocity'].round(0).unique()))
# attack angle: cluster
aa = np.sort(alld['attack_angle'].unique())
# find gaps
gaps = np.where(np.diff(aa) > 0.05)[0]
clusters=[]
start=0
for g in gaps:
    clusters.append((aa[start], aa[g]))
    start=g+1
clusters.append((aa[start], aa[-1]))
print('n attack clusters', len(clusters))
for c in clusters: print(round(c[0],3), round(c[1],3))
"
output
chord unique rounded 3: [0.025, 0.051, 0.101, 0.102, 0.152, 0.153, 0.228, 0.229, 0.304, 0.305]
velocity rounded 0: [32.0, 40.0, 55.0, 56.0, 71.0]
n attack clusters 23
-0.035 0.035
1.465 1.535
1.965 2.035
2.965 3.035
3.265 3.335
3.965 4.035
4.165 4.235
4.765 4.835
5.268 5.435
6.665 6.735
7.165 7.335
8.365 8.435
8.865 8.935
9.465 9.535
9.865 9.935
11.165 11.235
12.265 12.335
12.565 12.735
15.365 15.435
15.565 15.635
17.365 17.435
19.665 19.728
22.168 22.235
The canonical values are recoverable. The jitter is small (±0.035 or so). Let me build canonical condition labels by rounding: - chord: round to nearest canonical {0.0254, 0.0508, 0.1016, 0.1524, 0.2286, 0.3048} - velocity: round to nearest {31.7, 39.6, 55.5, 71.3} - attack_angle: round to nearest 0.1 or cluster Actually for grouping purposes, I can round attack_angle to nearest ~0.15 resolution. The canonical attack angles from UCI are: 0, 1.5, 2, 3, 3.3, 4, 4.2, 4.8, 5.3, 6.7, 7.3, 8.4, 8.9, 9.5, 9.9, 11.2, 12.3, 12.6, 15.4, 15.6, 17.4, 19.7, 22.2. That matches the 23 clusters above. So the condition grouping: round each to canonical. Let me create a robust rounding function. I'll snap chord and velocity to known canonical sets, and attack_angle I'll round to 1 decimal (since jitter is <0.035, rounding to nearest 0.1 might merge some... actually clusters like 1.465-1.535 round to 1.5; 12.565-12.735 rounds to 12.6/12.7 , hmm that cluster spans 12.57-12.74 which would split at 12.65). Let me round attack_angle to nearest 0.5 maybe. Actually let me just build groups by snapping to the cluster centers I found. Actually, for cross-validation, the exact canonical recovery isn't critical , I just need groups that approximate the held-out conditions. Let me snap: - chord to nearest of [0.0254,0.0508,0.1016,0.1524,0.2286,0.3048] - velocity to nearest of [31.7,39.6,55.5,71.3] - attack to rounding that respects clusters. Let me write a snapping function and count conditions. The key purpose: use GroupKFold on condition to get realistic validation estimates and tune the model for generalization across unseen conditions. Now, importantly: note that the provided `validation.csv` is itself a held-out group split from train? The task says train.csv is labeled training, validation.csv is labeled validation "for local tuning". The split holds out complete condition groups. So validation conditions are likely disjoint from train conditions! Let me verify this , this is important. If validation conditions are disjoint from train, then the validation set is a perfect proxy for the hidden test set (same kind of grouped holdout). Let me check overlap of conditions between train and validation.
Canonical values are recoverable. Let me build a condition-snapping function and check whether train/validation conditions are disjoint (which would make validation a good proxy for the hidden grouped split).
bash
cd /app && python3 -c "
import pandas as pd, numpy as np
tr = pd.read_csv('data/train.csv'); va = pd.read_csv('data/validation.csv')

chords=np.array([0.0254,0.0508,0.1016,0.1524,0.2286,0.3048])
vels=np.array([31.7,39.6,55.5,71.3])
def snap(x,arr):
    return arr[np.argmin(np.abs(arr-x))]
def cond(df):
    c=df['chord_length'].apply(lambda x: snap(x,chords))
    v=df['free_stream_velocity'].apply(lambda x: snap(x,vels))
    a=df['attack_angle'].round(1)
    return list(zip(a,c,v))
tr_c=set(cond(tr)); va_c=set(cond(va))
print('train conditions:', len(tr_c))
print('val conditions:', len(va_c))
print('overlap:', len(tr_c & va_c))
print('val-only:', len(va_c - tr_c))
# rows per condition
from collections import Counter
cc=Counter(cond(tr))
print('train rows/condition: min',min(cc.values()),'max',max(cc.values()),'mean',round(np.mean(list(cc.values())),1))
print('n train conditions', len(cc))
"
output
train conditions: 67
val conditions: 15
overlap: 0
val-only: 15
train rows/condition: min 8 max 18 mean 14.3
n train conditions 67
The validation conditions are completely disjoint from training conditions (0 overlap). So the validation set IS a grouped holdout, exactly mimicking the hidden test set. This means I can use validation.csv as a faithful proxy for the hidden test performance. So: - 67 train conditions, 15 val conditions, disjoint. - The hidden test set will have its own conditions, disjoint from train+val presumably. Strategy: 1. Build a strong regression model with good physics-based features. 2. Use GroupKFold cross-validation on train (by condition) to tune hyperparameters for generalization. 3. Validate on validation.csv (disjoint conditions) as the primary proxy for hidden metrics. 4. Final model trains on train+validation combined (to maximize data) for the actual prediction , since more conditions = better generalization. But I'll evaluate using val as proxy first. Let me also compute per-condition RMSE metrics on the validation set to mimic the scoring. Let me think about the scoring metrics: - hidden_rmse <= 2.25 - hidden_mae <= 1.55 - hidden_p90_condition_rmse <= 2.35 - hidden_p95_condition_rmse <= 4.75 - hidden_max_condition_rmse <= 5.60 So per-condition RMSE: for each condition group in the hidden set, compute RMSE, then take p90/p95/max across conditions. Need to avoid large per-condition failures. The validation set has only 15 conditions. p90 of 15 conditions = the ~1.5th worst. Let me set up an evaluation harness. Let me first establish a baseline with the starter Ridge model on validation, then improve. Let me build the evaluation harness and test several models.
Validation conditions are completely disjoint from training (0 overlap) , so `validation.csv` is a faithful proxy for the hidden grouped split. Let me build an evaluation harness that computes all the scoring metrics, then benchmark the starter model.
bash
cd /app && cat > /tmp/evalharness.py << 'EOF'
import pandas as pd, numpy as np
chords=np.array([0.0254,0.0508,0.1016,0.1524,0.2286,0.3048])
vels=np.array([31.7,39.6,55.5,71.3])
def snap(x,arr): return arr[np.argmin(np.abs(arr-x))]
def cond_labels(df):
    c=df['chord_length'].apply(lambda x: snap(x,chords))
    v=df['free_stream_velocity'].apply(lambda x: snap(x,vels))
    a=df['attack_angle'].round(1)
    return pd.Series(list(zip(a,c,v)), index=df.index)
def metrics(y_true, y_pred, conds):
    y_true=np.asarray(y_true); y_pred=np.asarray(y_pred)
    err=y_pred-y_true
    rmse=np.sqrt(np.mean(err**2)); mae=np.mean(np.abs(err))
    df=pd.DataFrame({'e':err,'c':list(conds)})
    cr=df.groupby('c')['e'].apply(lambda s: np.sqrt(np.mean(s**2)))
    return dict(rmse=rmse, mae=mae,
               p90=np.percentile(cr,90), p95=np.percentile(cr,95),
               mx=cr.max(), ncond=len(cr))
TARGETS=dict(rmse=2.25,mae=1.55,p90=2.35,p95=4.75,mx=5.60)
def report(name,m):
    flags=''.join('OK ' if m[k]<=TARGETS[k] else 'XX ' for k in ['rmse','mae','p90','p95','mx'])
    print(f"{name:28s} rmse={m['rmse']:.3f} mae={m['mae']:.3f} p90={m['p90']:.3f} p95={m['p95']:.3f} max={m['mx']:.3f} | {flags}")
EOF
echo "harness written"
output
harness written
bash
cd /app && python3 -c "
import sys; sys.path.insert(0,'/tmp')
import pandas as pd, numpy as np
from evalharness import cond_labels, metrics, report, TARGETS
from sklearn.base import BaseEstimator, TransformerMixin
from sklearn.linear_model import Ridge
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
import train_and_predict as tp

tr=pd.read_csv('data/train.csv'); va=pd.read_csv('data/validation.csv')
model=make_pipeline(tp.AirfoilFeatures(), StandardScaler(), Ridge(alpha=10.0))
model.fit(tr[tp.FEATURES], tr[tp.TARGET])
pred=model.predict(va[tp.FEATURES])
m=metrics(va[tp.TARGET], pred, cond_labels(va))
report('starter Ridge (train->val)', m)
print('targets:', TARGETS)
"
output
starter Ridge (train->val)   rmse=5.840 mae=4.697 p90=7.948 p95=8.562 max=9.477 | XX XX XX XX XX 
targets: {'rmse': 2.25, 'mae': 1.55, 'p90': 2.35, 'p95': 4.75, 'mx': 5.6}
The starter Ridge model fails all metrics (rmse=5.84 vs target 2.25). I need a much better model. Let me explore the data relationships and build better features. The UCI airfoil self-noise is a well-studied regression. Good models (random forest, gradient boosting) get RMSE ~1.5-2.5 on random splits. But this is a grouped split which is harder. Let me think about the physics. The scaled sound pressure level (SPL) in the airfoil self-noise dataset follows the BPM (Brooks, Pope, Marcolini) model. Key physics: - The spectrum depends on Strouhal number St = f * delta* / U (frequency * displacement thickness / velocity). - SPL scales with Mach number, boundary layer thickness. A classic strong feature set: - log10(frequency) - Strouhal-like: log10(frequency * thickness / velocity) - Reynolds number ~ velocity * chord - Mach number ~ velocity - displacement thickness (log) Let me explore correlations and build a gradient boosting model with good features. Let me test HistGradientBoostingRegressor and RandomForest with GroupKFold CV. Let me first just throw models at it with the raw 5 features + engineered features and see validation performance. Let me set up a proper comparison. I'll build a feature transformer and test several models: Ridge with features, RandomForest, HistGradientBoosting, ExtraTrees, KNN, SVR, MLP, GaussianProcess. Because the split is grouped, models need to generalize. Tree models predict piecewise constants , for a held-out condition they may extrapolate poorly if the condition is outside the training range. But many conditions are interpolation within the feature space. Let me test.
The starter fails badly. Let me explore physics-based features and benchmark stronger models. The airfoil self-noise follows the BPM model where Strouhal number (freq·thickness/velocity) is key.
bash
cd /app && python3 -c "
import pandas as pd, numpy as np
tr=pd.read_csv('data/train.csv')
# correlation of target with raw and log features
import numpy as np
d=tr.copy()
d['logf']=np.log10(d['frequency'])
d['logt']=np.log10(d['suction_side_displacement_thickness'])
d['strouhal']=np.log10(d['frequency']*d['suction_side_displacement_thickness']/d['free_stream_velocity'])
d['logchord']=np.log10(d['chord_length'])
d['reynolds']=np.log10(d['free_stream_velocity']*d['chord_length'])
cols=['frequency','logf','attack_angle','chord_length','logchord','free_stream_velocity','suction_side_displacement_thickness','logt','strouhal','reynolds']
print(d[cols+['scaled_sound_pressure_level']].corr()['scaled_sound_pressure_level'].round(3))
"
output
frequency                             -0.406
logf                                  -0.340
attack_angle                          -0.196
chord_length                          -0.188
logchord                              -0.180
free_stream_velocity                   0.038
suction_side_displacement_thickness   -0.352
logt                                  -0.326
strouhal                              -0.589
reynolds                              -0.152
scaled_sound_pressure_level            1.000
Name: scaled_sound_pressure_level, dtype: float64
The Strouhal number (log) has the strongest correlation (-0.589). Good. Let me now benchmark a range of models with a reasonable feature set. Let me write a comprehensive benchmark script. I'll create a feature builder and test multiple models, evaluating on validation (disjoint conditions) and also via GroupKFold on train.
Strouhal number is the strongest single predictor. Let me build a feature set and benchmark many sklearn models, evaluating on the disjoint validation set.
bash
cd /app && cat > /tmp/bench.py << 'EOF'
import sys; sys.path.insert(0,'/tmp'); sys.path.insert(0,'/app')
import pandas as pd, numpy as np
from evalharness import cond_labels, metrics, report
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import make_pipeline
from sklearn.linear_model import Ridge
from sklearn.ensemble import RandomForestRegressor, ExtraTreesRegressor, HistGradientBoostingRegressor, GradientBoostingRegressor
from sklearn.neighbors import KNeighborsRegressor
from sklearn.svm import SVR
from sklearn.neural_network import MLPRegressor
from sklearn.gaussian_process import GaussianProcessRegressor
from sklearn.gaussian_process.kernels import RBF, ConstantKernel, WhiteKernel, Matern

RAW=["frequency","attack_angle","chord_length","free_stream_velocity","suction_side_displacement_thickness"]

def feats(df):
    f=df['frequency'].astype(float); a=df['attack_angle'].astype(float)
    c=df['chord_length'].astype(float); v=df['free_stream_velocity'].astype(float)
    t=df['suction_side_displacement_thickness'].astype(float)
    o=pd.DataFrame(index=df.index)
    o['logf']=np.log10(f); o['a']=a; o['c']=c; o['v']=v
    o['logt']=np.log10(t)
    o['strouhal']=np.log10(f*t/v)
    o['logchord']=np.log10(c)
    o['reynolds']=np.log10(v*c)
    o['logf2']=o['logf']**2
    o['strouhal2']=o['strouhal']**2
    o['a2']=a**2
    o['logf_v']=o['logf']*v
    o['logf_a']=o['logf']*a
    o['logf_t']=o['logf']*o['logt']
    o['logf_c']=o['logf']*o['logchord']
    return o.to_numpy(float)

tr=pd.read_csv('data/train.csv'); va=pd.read_csv('data/validation.csv')
Xtr=feats(tr); ytr=tr['scaled_sound_pressure_level'].values
Xva=feats(va); yva=va['scaled_sound_pressure_level'].values
cva=cond_labels(va)

def run(name, model):
    model.fit(Xtr,ytr)
    p=model.predict(Xva)
    report(name, metrics(yva,p,cva))

run('Ridge(a=1)+feats', make_pipeline(StandardScaler(), Ridge(alpha=1.0)))
run('RF(400)', RandomForestRegressor(n_estimators=400, n_jobs=-1, random_state=0))
run('ExtraTrees(400)', ExtraTreesRegressor(n_estimators=400, n_jobs=-1, random_state=0))
run('HGB', HistGradientBoostingRegressor(random_state=0))
run('HGB(lr.05,500)', HistGradientBoostingRegressor(learning_rate=0.05, max_iter=500, random_state=0))
run('GBR', GradientBoostingRegressor(random_state=0))
run('KNN(7)', make_pipeline(StandardScaler(), KNeighborsRegressor(n_neighbors=7, weights='distance')))
run('SVR(rbf)', make_pipeline(StandardScaler(), SVR(C=10, gamma='scale', epsilon=0.1)))
run('MLP', make_pipeline(StandardScaler(), MLPRegressor(hidden_layer_sizes=(128,128,64), max_iter=2000, random_state=0, early_stopping=True)))
k=ConstantKernel()*Matern(length_scale=np.ones(Xtr.shape[1]), nu=1.5)+WhiteKernel()
run('GPR(Matern)', make_pipeline(StandardScaler(), GaussianProcessRegressor(kernel=k, normalize_y=True, n_restarts_optimizer=0, random_state=0)))
EOF
python3 /tmp/bench.py
output
Ridge(a=1)+feats             rmse=3.673 mae=2.852 p90=4.912 p95=5.221 max=5.863 | XX XX XX XX XX 
RF(400)                      rmse=2.755 mae=2.168 p90=3.762 p95=3.899 max=3.986 | XX XX XX OK OK 
ExtraTrees(400)              rmse=2.190 mae=1.710 p90=3.057 p95=3.190 max=3.450 | OK XX XX OK OK 
HGB                          rmse=2.709 mae=2.109 p90=3.749 p95=4.302 max=4.811 | XX XX XX OK OK 
HGB(lr.05,500)               rmse=2.665 mae=2.075 p90=3.713 p95=4.317 max=4.783 | XX XX XX OK OK 
GBR                          rmse=2.982 mae=2.312 p90=4.385 p95=4.640 max=4.677 | XX XX XX OK OK 
KNN(7)                       rmse=2.783 mae=2.037 p90=3.790 p95=4.255 max=5.035 | XX XX XX OK OK 
SVR(rbf)                     rmse=2.636 mae=1.926 p90=4.010 p95=4.483 max=5.059 | XX XX XX OK OK 
MLP                          rmse=2.945 mae=2.276 p90=4.207 p95=4.274 max=4.405 | XX XX XX OK OK 
GPR(Matern)                  rmse=1.940 mae=1.428 p90=3.030 p95=3.467 max=3.930 | OK OK XX OK OK
Progress. The Gaussian Process (Matern) is the best so far: rmse=1.940, mae=1.428 , both pass! But p90=3.030 fails (target 2.35). ExtraTrees also close: rmse=2.190 OK, but mae=1.710 fails, p90=3.057 fails. The main bottleneck is p90_condition_rmse <= 2.35. That's quite tight , it means 90% of conditions must have RMSE <= 2.35. With only 15 validation conditions, p90 is essentially the 2nd worst condition (index 0.9*14=12.6, so between 13th and 14th sorted). Let me understand which conditions are failing. Let me focus on GPR since it's best on global metrics, and investigate per-condition errors. Maybe combine models (ensemble) to reduce worst-condition errors. Also, I should train on train+val for the final model but evaluate via CV. Actually, wait , the key issue is the p90 target of 2.35. Let me look at the per-condition RMSE distribution for GPR to see how many conditions fail and why. Let me also reconsider: maybe the calibration applied to labels adds structured noise that limits achievable RMSE. Let me look at the residual structure. Let me dig into GPR per-condition errors.
Big progress , GPR passes global RMSE and MAE. The bottleneck is `p90_condition_rmse <= 2.35`. Let me examine per-condition errors for the best models to understand where they fail.
bash
cd /app && python3 -c "
import sys; sys.path.insert(0,'/tmp')
exec(open('/tmp/bench.py').read().split('def run')[0])  # load feats, data
from sklearn.gaussian_process import GaussianProcessRegressor
from sklearn.gaussian_process.kernels import WhiteKernel, ConstantKernel, Matern
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
k=ConstantKernel()*Matern(length_scale=np.ones(Xtr.shape[1]), nu=1.5)+WhiteKernel()
gp=make_pipeline(StandardScaler(), GaussianProcessRegressor(kernel=k, normalize_y=True, random_state=0))
gp.fit(Xtr,ytr); p=gp.predict(Xva)
err=p-yva
df=pd.DataFrame({'e':err,'c':list(cva)})
cr=df.groupby('c')['e'].apply(lambda s: np.sqrt(np.mean(s**2))).sort_values(ascending=False)
print('per-condition RMSE (GPR), worst first:')
print(cr.round(3))
"
output
per-condition RMSE (GPR), worst first:
c
(0.0, 0.0254, 71.3)     3.930
(17.4, 0.0254, 39.6)    3.269
(-0.0, 0.0508, 55.5)    2.671
(12.3, 0.1016, 55.5)    2.046
(4.8, 0.0254, 71.3)     1.946
(4.0, 0.2286, 31.7)     1.743
(9.9, 0.1524, 31.7)     1.634
(4.8, 0.0254, 39.6)     1.478
(12.3, 0.1016, 39.6)    1.456
(3.3, 0.1016, 71.3)     1.255
(-0.0, 0.3048, 31.7)    1.252
(0.0, 0.0254, 39.6)     1.206
(8.4, 0.0508, 55.5)     0.957
(0.0, 0.1524, 39.6)     0.808
(6.7, 0.1016, 55.5)     0.731
Name: e, dtype: float64
With 15 conditions, p90 (np.percentile with default linear interp) at 90%: index = 0.9*(15-1)=12.6, so interpolates between sorted[12] and sorted[13] (ascending). In ascending order the 13th and 14th values are the 2nd and 3rd worst. The worst conditions are: 1. (0.0, 0.0254, 71.3): 3.930 2. (17.4, 0.0254, 39.6): 3.269 3. (-0.0, 0.0508, 55.5): 2.671 p90 = 3.030 computed earlier. These extreme conditions are the small chord (0.0254) at high frequency/velocity , the classic hard regime in airfoil noise (small chord, high freq). The worst conditions involve chord=0.0254 (smallest chord). These are edge cases. The challenge: generalizing to held-out conditions at the extremes. Key idea: The p90/p95/max targets suggest I need to reduce the worst-condition errors. The validation set is a specific draw of 15 conditions; the hidden set will have different conditions. So I shouldn't overfit to these specific 15, but I should build a model that's robust generally. Let me think about this differently. The targets are: - rmse <= 2.25 - mae <= 1.55 - p90 <= 2.35 - p95 <= 4.75 - max <= 5.60 Note p95 (4.75) and max (5.60) are much more lenient than p90 (2.35). So I can tolerate 1-2 bad conditions (top 10%) but the bulk (90%) must be under 2.35. With 15 val conditions, 10% = 1.5 conditions can exceed 2.35 for p90... wait no. p90 is the 90th percentile value itself which must be <=2.35. So at most ~10% of conditions can be above 2.35. For 15 conditions, that's 1.5, so basically only 1 condition can exceed 2.35 (and it's partially interpolated). Currently GPR has 4 conditions above 2.35 (3.93, 3.27, 2.67, 2.046 is below... wait 2.046<2.35). So 3 conditions above 2.35. Need to get down to ~1. The hidden set presumably has more conditions (maybe similar count or more). Let me not over-index on exact count. I need to reduce per-condition RMSE broadly, especially for the hard small-chord high-freq regimes. Ideas to improve: 1. Train on train+validation combined , more conditions covered → better generalization. But then I can't use val to evaluate. I'll use GroupKFold CV on the combined set to estimate. 2. Better features , capture the physics better so extrapolation to new conditions is accurate. 3. Ensemble GPR + ExtraTrees + others to reduce variance. 4. Target transformation / robust loss. Let me think about the physics more. The BPM model: SPL is a function of Strouhal number with spectral shape functions. The peak Strouhal and amplitude depend on Reynolds number and angle. Actually, let me reconsider the whole approach. The data is a "calibrated benchmark derived from" UCI. Perhaps the calibration is a smooth transformation of the original. The original UCI has deterministic SPL given the 5 inputs (it's from wind tunnel + BPM predictions). The calibrated version may have added noise or a transformation. Let me check the noise level: for near-duplicate feature rows (same condition, similar frequency), how much does SPL vary? Actually each condition has multiple frequencies. The SPL vs frequency within a condition is a smooth curve. Let me look at one condition's frequency-SPL curve to see noise. Let me examine within-condition smoothness.
The hardest conditions are small-chord (0.0254) high-velocity regimes. Let me check the noise level within conditions to understand the achievable error floor.
bash
cd /app && python3 -c "
import sys; sys.path.insert(0,'/tmp')
import pandas as pd, numpy as np
from evalharness import cond_labels
tr=pd.read_csv('data/train.csv')
tr['c']=cond_labels(tr)
# pick a few conditions, sort by frequency, look at SPL smoothness
for cond in list(tr['c'].unique())[:3]:
    sub=tr[tr['c']==cond].sort_values('frequency')
    print('cond',cond,'n=',len(sub))
    print('  freq:', np.round(sub['frequency'].values,0))
    print('  SPL :', np.round(sub['scaled_sound_pressure_level'].values,2))
    # fit smooth spline-ish: second difference magnitude
    lf=np.log10(sub['frequency'].values); y=sub['scaled_sound_pressure_level'].values
    if len(sub)>4:
        # residual from quadratic fit in logf
        co=np.polyfit(lf,y,3); res=y-np.polyval(co,lf)
        print('  cubic-fit resid std:', round(res.std(),3))
"
output
cond (0.0, 0.1016, 71.3) n= 14
  freq: [  631.   800.   999.  1248.  1598.  1999.  2502.  3155.  4006.  5004.
  6297.  7988.  9983. 12487.]
  SPL : [122.71 125.54 127.74 129.47 131.12 131.74 132.49 132.1  131.32 131.38
 130.14 126.33 124.23 120.31]
  cubic-fit resid std: 0.379
cond (15.6, 0.1016, 71.3) n= 14
  freq: [ 200.  250.  314.  399.  500.  630.  801. 1002. 1251. 1599. 1997. 2496.
 3146. 4000.]
  SPL : [129.92 131.57 133.62 134.01 130.16 122.36 123.88 124.09 123.39 120.99
 119.44 118.98 115.44 112.  ]
  cubic-fit resid std: 2.172
cond (9.5, 0.0254, 31.7) n= 16
  freq: [ 200.  250.  315.  400.  501.  631.  800.  999. 1248. 1597. 1998. 2501.
 3154. 4007. 5006. 6301.]
  SPL : [116.98 118.15 120.96 123.16 125.5  127.87 129.89 130.77 131.14 130.97
 130.58 130.24 128.68 126.79 124.34 120.48]
  cubic-fit resid std: 0.416
Within a condition, SPL vs log-frequency is a smooth curve (like an inverted U, the spectral peak). A cubic fit has residual std of ~0.4 for smooth ones, but the second condition (15.6 deg, high angle near stall) has a sharp kink around 500-630 Hz (SPL drops from 134 to 122), giving residual std 2.17 , this is the separation/stall noise regime where the spectrum has two peaks or a sharp transition. These high-angle conditions are intrinsically harder. So there's genuine structure. A per-condition curve is mostly smooth in log-frequency. The challenge is predicting the whole curve for an unseen (angle, chord, velocity). The noise floor seems low (~0.4 dB for smooth conditions), so the calibration didn't add huge noise. The errors come from the model's inability to predict the spectral curve for unseen conditions. Let me think about a strong approach. Given the smooth dependence on log-frequency within a condition, and smooth dependence on the aero parameters, a Gaussian Process with a well-tuned kernel should work well. GPR already gives the best results. Let me improve it: 1. Better kernel: separate length scales (ARD) via Matern , already using ARD (length_scale is a vector). But n_restarts_optimizer=0 means it didn't optimize well. Let me allow optimization. 2. Feature selection for GPR: use a compact, physically meaningful feature set so the kernel distances are meaningful. Too many correlated features hurt GPR. Let me use: logf, strouhal, a, logchord, v (or reynolds), logt. Let me experiment with GPR kernel tuning and feature sets. Also try ensembling. Let me also consider: the final model should train on train+val combined. Let me set up GroupKFold CV on the combined data to estimate hidden performance more robustly (more folds = more conditions tested). Let me build a robust CV harness using GroupKFold over conditions on the full labeled set. This gives many held-out conditions, better estimate of p90 etc. Let me first tune GPR.
Within-condition curves are smooth in log-frequency (noise floor ~0.4 dB), except high-angle stall regimes. GPR is well-suited. Let me set up a GroupKFold CV harness over the combined labeled data for a more robust estimate across many held-out conditions, then tune GPR.
bash
cd /app && cat > /tmp/cv.py << 'EOF'
import sys; sys.path.insert(0,'/tmp')
import pandas as pd, numpy as np
from evalharness import cond_labels, metrics
from sklearn.model_selection import GroupKFold

tr=pd.read_csv('/app/data/train.csv'); va=pd.read_csv('/app/data/validation.csv')
FULL=pd.concat([tr,va], ignore_index=True)
FULL['cond']=cond_labels(FULL)
groups=FULL['cond'].astype(str).values
y=FULL['scaled_sound_pressure_level'].values

def cv_eval(make_model, feat_fn, n_splits=10, seed=0):
    gkf=GroupKFold(n_splits=n_splits)
    oof=np.zeros(len(FULL))
    for tri,tei in gkf.split(FULL, y, groups):
        Xtr=feat_fn(FULL.iloc[tri]); Xte=feat_fn(FULL.iloc[tei])
        m=make_model(); m.fit(Xtr, y[tri])
        oof[tei]=m.predict(Xte)
    return metrics(y, oof, FULL['cond'])

def report(name,m):
    T=dict(rmse=2.25,mae=1.55,p90=2.35,p95=4.75,mx=5.60)
    flags=''.join('OK ' if m[k]<=T[k] else 'XX ' for k in ['rmse','mae','p90','p95','mx'])
    print(f"{name:30s} rmse={m['rmse']:.3f} mae={m['mae']:.3f} p90={m['p90']:.3f} p95={m['p95']:.3f} max={m['mx']:.3f} nc={m['ncond']} | {flags}")
EOF
echo ok
output
ok
bash
cd /app && python3 -c "
import sys; sys.path.insert(0,'/tmp')
import numpy as np, pandas as pd
from cv import cv_eval, report
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.gaussian_process import GaussianProcessRegressor
from sklearn.gaussian_process.kernels import WhiteKernel, ConstantKernel, Matern, RBF

def feats_full(df):
    f=df['frequency'].astype(float).values; a=df['attack_angle'].astype(float).values
    c=df['chord_length'].astype(float).values; v=df['free_stream_velocity'].astype(float).values
    t=df['suction_side_displacement_thickness'].astype(float).values
    o=np.column_stack([np.log10(f), a, np.log10(c), v, np.log10(t), np.log10(f*t/v), np.log10(v*c)])
    return o
def feats_compact(df):
    f=df['frequency'].astype(float).values; a=df['attack_angle'].astype(float).values
    c=df['chord_length'].astype(float).values; v=df['free_stream_velocity'].astype(float).values
    t=df['suction_side_displacement_thickness'].astype(float).values
    o=np.column_stack([np.log10(f), a, np.log10(c), v, np.log10(t)])
    return o

def mk(nu):
    def f():
        d=5
        k=ConstantKernel(1.0,(1e-2,1e3))*Matern(length_scale=np.ones(d), length_scale_bounds=(1e-2,1e3), nu=nu)+WhiteKernel(0.5,(1e-3,1e2))
        return make_pipeline(StandardScaler(), GaussianProcessRegressor(kernel=k, normalize_y=True, n_restarts_optimizer=2, random_state=0))
    return f
report('GPR Matern1.5 compact', cv_eval(mk(1.5), feats_compact, n_splits=10))
report('GPR Matern2.5 compact', cv_eval(mk(2.5), feats_compact, n_splits=10))
" 2>&1 | grep -v Warning
output
[stderr]
Shell command timed out.
Tool interaction exceeded 300 seconds.
GPR with n_restarts_optimizer=2 and 10-fold CV is too slow (n=1157 points, GPR is O(n^3) and with restarts/optimization, it's very slow). Let me restart and be smarter: reduce restarts, use fewer CV folds, or fix kernel hyperparameters. GPR on ~1040 training points per fold with optimizer restarts is the bottleneck. Let me restart the tool.
The tool timed out , GPR with optimizer restarts over 10 folds is too slow. Let me restart and be more efficient.
bash
restart: true
output
<system>tool has been restarted.</system>
bash
cd /app && timeout 120 python3 -c "
import sys, time; sys.path.insert(0,'/tmp')
import numpy as np, pandas as pd
from cv import cv_eval, report, FULL
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.gaussian_process import GaussianProcessRegressor
from sklearn.gaussian_process.kernels import WhiteKernel, ConstantKernel, Matern

def feats_compact(df):
    f=df['frequency'].astype(float).values; a=df['attack_angle'].astype(float).values
    c=df['chord_length'].astype(float).values; v=df['free_stream_velocity'].astype(float).values
    t=df['suction_side_displacement_thickness'].astype(float).values
    return np.column_stack([np.log10(f), a, np.log10(c), v, np.log10(t)])

# Fit GPR once with optimizer on train->val to get good hyperparams, time it
t0=time.time()
def mk():
    d=5
    k=ConstantKernel(1.0)*Matern(length_scale=np.ones(d), nu=1.5)+WhiteKernel(0.3)
    return make_pipeline(StandardScaler(), GaussianProcessRegressor(kernel=k, normalize_y=True, n_restarts_optimizer=0, random_state=0))
report('GPR M1.5 compact 5fold', cv_eval(mk, feats_compact, n_splits=5))
print('time', round(time.time()-t0,1))
" 2>&1 | grep -vi warn
output
GPR M1.5 compact 5fold         rmse=2.431 mae=1.708 p90=3.614 p95=4.310 max=6.777 nc=82 | XX XX XX OK XX 
time 59.2
OK so 5-fold CV across all 82 conditions gives a more realistic (harder) estimate: rmse=2.431, mae=1.708, p90=3.614, max=6.777. This is the full picture , more conditions, more chances for bad ones. The max condition RMSE is 6.777 (exceeds 5.60). This tells me the validation-only estimate (15 conditions) was optimistic. The real challenge across all 82 conditions is harder. I need a model that's robust across ALL conditions. The CV here (n_restarts_optimizer=0 with fixed init) isn't optimizing the kernel well. Let me think strategically given time constraints. Given GPR is slow, let me consider: 1. A well-tuned ensemble of faster models (ExtraTrees, HGB, KNN) + GPR. 2. Feature engineering is key for generalization. But actually, the CV estimate of p90=3.6 and max=6.8 means I'm currently far from passing on the full set. However, note: the hidden test set is a *single* draw of held-out conditions, like the validation set (15 conditions). The grader computes metrics on the hidden set only. So the relevant question is: what will the metrics be on the hidden conditions? The CV over 82 conditions gives the distribution of per-condition errors. The hidden set is a sample from "unseen conditions". If my per-condition RMSE distribution has p90=3.6 across all conditions, then a random draw of ~15 hidden conditions would likely have p90 around there too , failing. But wait , the hidden test set: the grader trains my model on train+val (all labeled data I have) and predicts on hidden features. So the hidden conditions are NOT in my training data. The CV simulates exactly this: hold out conditions, train on rest, predict. So CV p90/max over many held-out conditions is the right estimate of what the hidden metrics could be. So I genuinely need to improve per-condition robustness. Current best (GPR) CV: rmse 2.43, p90 3.6, max 6.8. Need rmse<=2.25, p90<=2.35, max<=5.60. The p90 target of 2.35 is very demanding , 90% of held-out conditions need RMSE<=2.35. That's a strong generalization requirement. Let me reconsider the problem. Maybe there's more structure to exploit. The within-condition curve is smooth in log-frequency. The hard part is getting the overall level and shape right for unseen aero conditions. Key realization: For a held-out condition (a, c, v), the model must predict SPL across ~14 frequencies. If the model gets the overall level wrong by a constant bias, that inflates the condition RMSE. The spectral shape (vs log-freq) is fairly universal; the shifts come from the aero params. Let me think about the BPM physics more concretely. In the BPM model, the SPL spectrum is: SPL = 10 log10(...) + spectral shape function G(St/St_peak) + scaling terms. The total is a sum of contributions. The displacement thickness delta* already encodes a lot (it's derived from angle, chord, velocity via boundary layer theory). So displacement thickness is a very informative feature. Actually the UCI dataset's 5th feature (suction side displacement thickness) is the key derived quantity. Given (f, delta*, U, chord, angle), the SPL is fairly determined. Let me think about whether a smarter target helps. Let me look at whether there's a dominant functional form. Perhaps fit SPL ~ spectral shape in Strouhal number. Let me try a different, potentially more robust and much faster approach: a carefully engineered feature set + ExtraTrees/RandomForest with many trees, or HGB with tuned regularization, plus ensembling. Trees are fast. Let me benchmark tree models and KNN on the full CV to see per-condition robustness. Let me restart the cv approach but with fast models for quick iteration, using the full 82-condition CV. Let me write a fast feature function and test many models quickly.
The full 82-condition CV is a harder, more realistic estimate (GPR: p90=3.6, max=6.8). I need broad per-condition robustness. Let me benchmark fast models on the full CV to iterate quickly.
bash
cd /app && cat > /tmp/ff.py << 'EOF'
import numpy as np, pandas as pd
def feats(df):
    f=df['frequency'].astype(float).values; a=df['attack_angle'].astype(float).values
    c=df['chord_length'].astype(float).values; v=df['free_stream_velocity'].astype(float).values
    t=df['suction_side_displacement_thickness'].astype(float).values
    logf=np.log10(f); logt=np.log10(t); logc=np.log10(c)
    st=np.log10(f*t/v); re=np.log10(v*c)
    o=np.column_stack([logf,a,logc,v,logt,st,re,logf*logf,st*st,a*a,logf*v,logf*a,logf*logt,a*logc,logf*logc])
    return o
EOF
cd /app && timeout 200 python3 -c "
import sys; sys.path.insert(0,'/tmp')
import numpy as np
from cv import cv_eval, report
from ff import feats
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.ensemble import RandomForestRegressor, ExtraTreesRegressor, HistGradientBoostingRegressor
from sklearn.neighbors import KNeighborsRegressor
from sklearn.svm import SVR
report('ExtraTrees400', cv_eval(lambda: ExtraTreesRegressor(n_estimators=400,n_jobs=-1,random_state=0), feats, 8))
report('RF400', cv_eval(lambda: RandomForestRegressor(n_estimators=400,n_jobs=-1,random_state=0), feats, 8))
report('HGB', cv_eval(lambda: HistGradientBoostingRegressor(learning_rate=0.05,max_iter=600,max_leaf_nodes=31,l2_regularization=1.0,random_state=0), feats, 8))
report('KNNdist', cv_eval(lambda: make_pipeline(StandardScaler(), KNeighborsRegressor(n_neighbors=10,weights='distance')), feats, 8))
report('SVRrbf', cv_eval(lambda: make_pipeline(StandardScaler(), SVR(C=20,gamma='scale',epsilon=0.05)), feats, 8))
" 2>&1 | grep -vi warn
output
ExtraTrees400                  rmse=2.174 mae=1.589 p90=2.957 p95=3.768 max=5.573 nc=82 | OK XX XX OK OK 
RF400                          rmse=2.474 mae=1.862 p90=3.607 p95=4.390 max=4.952 nc=82 | XX XX XX OK OK 
HGB                            rmse=2.291 mae=1.662 p90=3.432 p95=4.249 max=5.489 nc=82 | XX XX XX OK OK 
KNNdist                        rmse=3.038 mae=2.255 p90=4.111 p95=5.683 max=6.859 nc=82 | XX XX XX XX XX 
SVRrbf                         rmse=2.474 mae=1.749 p90=3.473 p95=3.841 max=7.644 nc=82 | XX XX XX OK XX
ExtraTrees is the best tree model: rmse=2.174 (OK), mae=1.589 (barely XX, target 1.55), p90=2.957 (XX), p95=3.768 (OK), max=5.573 (OK, target 5.60). Close on several but p90 is the main problem. So across the board, p90 (~2.35) is the hard target. All models have p90 around 2.9-3.6. I need a substantial improvement. Let me think about ensembling ExtraTrees + GPR + HGB. Ensembles typically reduce variance and improve worst-case. But more fundamentally , the p90 target of 2.35 requires that 90% of held-out conditions have RMSE <= 2.35. Given within-condition noise floor ~0.4 and model error, this is achievable if the model truly captures the physics. Let me reconsider features. Maybe I'm missing key physics. Let me think about what makes a condition's prediction good. Important insight: for a held-out condition (a, c, v), the displacement thickness t is essentially determined by (a, c, v) , it's a derived boundary-layer quantity. So within a condition, t is nearly constant! Let me verify. If t is constant within a condition, then the only varying feature within a condition is frequency. So the model predicts the spectral curve SPL(f) for fixed (a,c,v,t). Let me verify t is ~constant within condition. Actually from the earlier data, within condition (0.0,0.1016,71.3), was t constant? Let me check. If so, then the Strouhal number st = log10(f*t/v) varies only through f within a condition. Good. The real generalization challenge: predict the SPL(log-f) curve given (a, c, v, t). The curve is an inverted-U peaking at some Strouhal number. A model that captures this well: the spectral shape as a function of Strouhal number, shifted/scaled by the condition. This is exactly the BPM model structure. Let me think about a semi-physical model: SPL = A(a,c,v,t) + Shape(St - St_peak(a,c,v,t)) where Shape is a universal spectral function. This is hard to fit directly. But a flexible model (GPR, trees) with the right features should approximate it. Let me reconsider: maybe the issue is that trees/GPR don't extrapolate the *level* well for unseen conditions. Let me check if there's a simple global regression capturing the level. Alternative: let me try a more powerful ensemble and also tune ExtraTrees (it's the best). Let me also try adding more trees, different max_features, and bagging. Actually, let me step back and think about the magnitude of improvement needed. ExtraTrees p90=2.957, need 2.35. That's a 20% reduction in the 90th percentile condition RMSE. And mae 1.589 -> 1.55 (small). rmse 2.174 -> need 2.25 (already OK). The p90 is dominated by the ~8th worst condition out of 82 (90th percentile = index 0.9*81=72.9, so ~73rd of 82 sorted ascending = 9th worst). So I need the 9th-worst condition to be under 2.35. Currently it's ~2.96. Ensembling should help. Let me try averaging ExtraTrees + GPR + HGB + SVR. Diverse models. Let me build a quick ensemble CV. But GPR is slow in CV. Let me precompute OOF predictions for each model once, then ensemble. Let me write an OOF generator that saves per-model OOF predictions, then I can ensemble offline quickly.
ExtraTrees is strongest (rmse OK, max OK) but p90=2.96 is the bottleneck. Let me verify a key physics point , whether displacement thickness is constant within a condition (meaning only frequency varies within a group).
bash
cd /app && timeout 60 python3 -c "
import sys; sys.path.insert(0,'/tmp')
import pandas as pd, numpy as np
from evalharness import cond_labels
from cv import FULL
# within-condition variation of thickness relative to between-condition
g=FULL.groupby('cond')['suction_side_displacement_thickness']
within=g.std()/g.mean()
print('within-condition CV of thickness: median', round(within.median(),4), 'max', round(within.max(),4))
# check frequency coverage per condition
gf=FULL.groupby('cond')['frequency']
print('freqs per cond: min count', FULL.groupby('cond').size().min(), 'max', FULL.groupby('cond').size().max())
print('thickness is essentially constant within condition:', within.max()<0.02)
"
output
within-condition CV of thickness: median 0.0018 max 0.0021
freqs per cond: min count 8 max 18
thickness is essentially constant within condition: True
Confirmed: displacement thickness is essentially constant within a condition (CV < 0.21%). So within a condition, only frequency varies. The model predicts a spectral curve SPL(f) for fixed aero state (a, c, v, t). This means: the per-condition RMSE measures how well the model predicts the spectral curve for an unseen (a,c,v,t). The condition is defined by 4 nearly-constant values (a, c, v, t) and a varying frequency. This is fundamentally a problem of predicting a 1D curve (SPL vs log-f) as a function of 4 condition parameters. Strategy refinement: The spectral shape is an inverted-U in log-frequency (or Strouhal). The key is the peak location and amplitude and width. Now, a crucial idea for robustness: ensemble diverse models. Let me generate OOF predictions for multiple models and ensemble them to minimize p90. Let me precompute OOF for: ExtraTrees, HGB, GPR, SVR, RF, and store. Then find the best ensemble weights. This avoids re-running slow GPR repeatedly. Let me use a fixed 8-fold GroupKFold split (same folds across models) so OOF predictions are aligned for ensembling. Let me generate and save them. I need to be careful with time. GPR on 8 folds took ~60s for 5 folds earlier (actually the 5-fold took 59s). 8 folds similar. Let me do it with a time budget. Let me generate OOF for the fast models first, save, then GPR separately. Let me write a script that uses a FIXED fold assignment and saves OOF arrays to /tmp.
Confirmed: thickness is constant within a condition, so each condition is a spectral curve SPL(log-frequency) to predict from the aero state. Let me generate aligned out-of-fold predictions for diverse models (fixed folds) so I can ensemble them efficiently.
bash
cd /app && cat > /tmp/oofgen.py << 'EOF'
import sys; sys.path.insert(0,'/tmp')
import numpy as np, pandas as pd, time
from cv import FULL
from ff import feats
from evalharness import metrics
from sklearn.model_selection import GroupKFold
y=FULL['scaled_sound_pressure_level'].values
groups=FULL['cond'].astype(str).values
conds=FULL['cond']
NF=8
gkf=GroupKFold(n_splits=NF)
FOLDS=list(gkf.split(FULL,y,groups))
np.save('/tmp/y.npy', y)
pd.Series(conds.values).to_pickle('/tmp/conds.pkl')

def gen(name, make_model, feat_fn=feats):
    t0=time.time(); oof=np.zeros(len(FULL))
    for tri,tei in FOLDS:
        Xtr=feat_fn(FULL.iloc[tri]); Xte=feat_fn(FULL.iloc[tei])
        m=make_model(); m.fit(Xtr,y[tri]); oof[tei]=m.predict(Xte)
    np.save(f'/tmp/oof_{name}.npy', oof)
    mm=metrics(y,oof,conds)
    print(f"{name:16s} rmse={mm['rmse']:.3f} mae={mm['mae']:.3f} p90={mm['p90']:.3f} p95={mm['p95']:.3f} max={mm['mx']:.3f} t={time.time()-t0:.0f}s")
    return oof
EOF
cd /app && timeout 250 python3 -c "
import sys; sys.path.insert(0,'/tmp')
from oofgen import gen
from ff import feats
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.ensemble import ExtraTreesRegressor, HistGradientBoostingRegressor, RandomForestRegressor
from sklearn.svm import SVR
gen('et', lambda: ExtraTreesRegressor(n_estimators=600,n_jobs=-1,random_state=0))
gen('hgb', lambda: HistGradientBoostingRegressor(learning_rate=0.05,max_iter=600,max_leaf_nodes=31,l2_regularization=1.0,random_state=0))
gen('rf', lambda: RandomForestRegressor(n_estimators=600,n_jobs=-1,random_state=0,min_samples_leaf=1))
gen('svr', lambda: make_pipeline(StandardScaler(), SVR(C=20,gamma='scale',epsilon=0.05)))
" 2>&1 | grep -vi warn
output
et               rmse=2.175 mae=1.590 p90=2.952 p95=3.785 max=5.624 t=16s
hgb              rmse=2.291 mae=1.662 p90=3.432 p95=4.249 max=5.489 t=23s
rf               rmse=2.473 mae=1.859 p90=3.577 p95=4.392 max=5.005 t=44s
svr              rmse=2.474 mae=1.749 p90=3.473 p95=3.841 max=7.644 t=1s
Now let me generate GPR OOF (slower). Let me do it with a good kernel but limited optimizer restarts. Let me use the compact feature set for GPR (it works better with fewer, meaningful features). Actually let me test GPR with the full feats too. Let me generate GPR OOF with n_restarts_optimizer=1 and compact features. Time budget ~ let me try. Actually, earlier GPR 5-fold took 59s. 8-fold with n_restarts=0 maybe ~90s. With n_restarts=1, double. Let me do n_restarts_optimizer=0 but with a reasonable fixed kernel init, and also try a version where I fit hyperparameters once on a subset. Let me just generate GPR OOF with the compact features and a Matern kernel, n_restarts=0, and see. Let me use a separate feature function for GPR (compact, standardized).
ExtraTrees leads. Now let me generate GPR out-of-fold predictions (slower) using a compact physical feature set, which suits the kernel.
bash
cd /app && cat >> /tmp/ff.py << 'EOF'

def feats_gpr(df):
    f=df['frequency'].astype(float).values; a=df['attack_angle'].astype(float).values
    c=df['chord_length'].astype(float).values; v=df['free_stream_velocity'].astype(float).values
    t=df['suction_side_displacement_thickness'].astype(float).values
    return np.column_stack([np.log10(f), a, np.log10(c), v, np.log10(t), np.log10(f*t/v)])
EOF
cd /app && timeout 250 python3 -c "
import sys; sys.path.insert(0,'/tmp')
import numpy as np
from oofgen import gen
from ff import feats_gpr
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.gaussian_process import GaussianProcessRegressor
from sklearn.gaussian_process.kernels import WhiteKernel, ConstantKernel, Matern
def mk():
    d=6
    k=ConstantKernel(1.0,(1e-2,1e3))*Matern(length_scale=np.ones(d),length_scale_bounds=(1e-2,1e2),nu=1.5)+WhiteKernel(0.3,(1e-3,1e1))
    return make_pipeline(StandardScaler(), GaussianProcessRegressor(kernel=k,normalize_y=True,n_restarts_optimizer=1,random_state=0))
gen('gpr', mk, feats_gpr)
" 2>&1 | grep -vi warn
output
[stderr]
Terminated
GPR with n_restarts_optimizer=1 over 8 folds timed out (>250s). GPR is too slow for iteration. Let me reduce: n_restarts_optimizer=0 and fix the kernel hyperparameters to good values (so no optimization per fold). I'll first find good hyperparameters by fitting once on a subset, then use fixed kernel in CV (optimizer=None or n_restarts=0 with fixed theta and optimizer=None). Actually, the optimizer runs by default even with n_restarts_optimizer=0 (it does 1 optimization from the initial theta). To disable optimization entirely, set optimizer=None. Let me fit once to get good theta, then freeze. Let me first fit GPR once on the full training data (train only) to get optimized hyperparameters, time it.
GPR optimization is too slow for per-fold CV. Let me fit it once to find good hyperparameters, then freeze them (optimizer=None) for fast CV.
bash
cd /app && timeout 120 python3 -c "
import sys,time; sys.path.insert(0,'/tmp')
import numpy as np
from cv import FULL
from ff import feats_gpr
from sklearn.preprocessing import StandardScaler
from sklearn.gaussian_process import GaussianProcessRegressor
from sklearn.gaussian_process.kernels import WhiteKernel, ConstantKernel, Matern
y=FULL['scaled_sound_pressure_level'].values
X=feats_gpr(FULL); sc=StandardScaler().fit(X); Xs=sc.transform(X)
d=X.shape[1]
k=ConstantKernel(1.0,(1e-2,1e3))*Matern(length_scale=np.ones(d),length_scale_bounds=(1e-2,1e2),nu=1.5)+WhiteKernel(0.3,(1e-3,1e1))
t0=time.time()
gp=GaussianProcessRegressor(kernel=k,normalize_y=True,n_restarts_optimizer=2,random_state=0).fit(Xs,y)
print('fit time', round(time.time()-t0,1))
print('learned kernel:', gp.kernel_)
" 2>&1 | grep -vi warn
output
fit time 100.3
learned kernel: 1.23**2 * Matern(length_scale=[1.86, 2.22, 1.26, 4.6, 1.97, 1.51], nu=1.5) + WhiteKernel(noise_level=0.00279)
The learned kernel: ConstantKernel 1.23^2 * Matern(length_scale=[1.86, 2.22, 1.26, 4.6, 1.97, 1.51], nu=1.5) + WhiteKernel(0.00279). Interesting , the white noise is tiny (0.0028), meaning the GP fits almost perfectly on training data (low noise). That might overfit. But normalize_y means y is standardized; noise 0.0028 on standardized scale (std of y ~6.9) → actual noise std ~ sqrt(0.0028)*6.9 ≈ 0.36 dB. That matches the within-condition noise floor (~0.4). Good. Now let me freeze this kernel (optimizer=None) and run CV fast. Let me use these hyperparameters.
Good , the learned kernel has noise std ~0.36 dB, matching the within-condition floor. Let me freeze these hyperparameters (optimizer=None) for fast CV.
bash
cd /app && timeout 120 python3 -c "
import sys,time; sys.path.insert(0,'/tmp')
import numpy as np
from oofgen import gen
from ff import feats_gpr
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import make_pipeline
from sklearn.gaussian_process import GaussianProcessRegressor
from sklearn.gaussian_process.kernels import WhiteKernel, ConstantKernel, Matern
def mk():
    d=6
    k=ConstantKernel(1.23**2)*Matern(length_scale=[1.86,2.22,1.26,4.6,1.97,1.51],nu=1.5)+WhiteKernel(0.0028)
    return make_pipeline(StandardScaler(), GaussianProcessRegressor(kernel=k,normalize_y=True,optimizer=None,random_state=0))
gen('gpr', mk, feats_gpr)
" 2>&1 | grep -vi warn
output
gpr              rmse=1.968 mae=1.358 p90=3.069 p95=3.543 max=6.153 t=1s
With frozen kernel, GPR CV is fast (1s!) and gives rmse=1.968, mae=1.358, p90=3.069, p95=3.543, max=6.153. Best global metrics yet (rmse & mae pass). But p90=3.069 and max=6.153 fail. Now let me ensemble GPR + ExtraTrees + HGB. Let me load OOF arrays and find optimal weights minimizing p90 (and keeping other metrics OK). Let me load all OOF and experiment with ensembling.
GPR is now fast with frozen hyperparameters (best global metrics). Let me load all OOF predictions and explore ensembles to reduce p90 and max.
bash
cd /app && cat > /tmp/ens.py << 'EOF'
import sys; sys.path.insert(0,'/tmp')
import numpy as np, pandas as pd
from evalharness import metrics
y=np.load('/tmp/y.npy'); conds=pd.read_pickle('/tmp/conds.pkl')
names=['et','hgb','rf','svr','gpr']
OOF={n:np.load(f'/tmp/oof_{n}.npy') for n in names}
def ev(pred):
    return metrics(y,pred,conds)
def show(name,pred):
    m=ev(pred); T=dict(rmse=2.25,mae=1.55,p90=2.35,p95=4.75,mx=5.60)
    flags=''.join('OK ' if m[k]<=T[k] else 'XX ' for k in ['rmse','mae','p90','p95','mx'])
    print(f"{name:26s} rmse={m['rmse']:.3f} mae={m['mae']:.3f} p90={m['p90']:.3f} p95={m['p95']:.3f} max={m['mx']:.3f} | {flags}")
    return m
EOF
cd /app && python3 -c "
import sys; sys.path.insert(0,'/tmp')
from ens import OOF, show
import numpy as np
show('gpr', OOF['gpr'])
show('et', OOF['et'])
show('gpr+et', 0.5*OOF['gpr']+0.5*OOF['et'])
show('gpr+et+hgb', (OOF['gpr']+OOF['et']+OOF['hgb'])/3)
show('2gpr+et', (2*OOF['gpr']+OOF['et'])/3)
show('gpr+et+hgb+svr', (OOF['gpr']+OOF['et']+OOF['hgb']+OOF['svr'])/4)
show('all5', sum(OOF.values())/5)
"
output
gpr                        rmse=1.968 mae=1.358 p90=3.069 p95=3.543 max=6.153 | OK OK XX OK XX 
et                         rmse=2.175 mae=1.590 p90=2.952 p95=3.785 max=5.624 | OK XX XX OK XX 
gpr+et                     rmse=1.927 mae=1.370 p90=2.771 p95=3.382 max=5.072 | OK OK XX OK OK 
gpr+et+hgb                 rmse=1.967 mae=1.408 p90=2.849 p95=3.521 max=5.045 | OK OK XX OK OK 
2gpr+et                    rmse=1.907 mae=1.339 p90=2.832 p95=3.288 max=5.261 | OK OK XX OK OK 
gpr+et+hgb+svr             rmse=1.988 mae=1.417 p90=2.906 p95=3.543 max=5.651 | OK OK XX OK XX 
all5                       rmse=2.043 mae=1.473 p90=2.917 p95=3.667 max=5.424 | OK OK XX OK OK
The ensemble gpr+et gives rmse=1.927, mae=1.370, p90=2.771, p95=3.382, max=5.072. Only p90 fails now (2.771 vs 2.35). Getting closer. The p90 target is the stubborn one. p90=2.771 needs to get to 2.35. Let me look at which conditions are the worst in the gpr+et ensemble and understand why. Let me examine the worst conditions and see if there's a pattern (e.g., high angle / stall, or extreme chord). If the bad conditions are systematically biased (level offset), maybe I can correct. Let me analyze residuals per condition for the ensemble.
The `gpr+et` ensemble passes everything except p90 (2.771 vs 2.35). Let me analyze the worst conditions to find systematic patterns I can correct.
bash
cd /app && python3 -c "
import sys; sys.path.insert(0,'/tmp')
import numpy as np, pandas as pd
from ens import OOF, y, conds
from cv import FULL
pred=0.5*OOF['gpr']+0.5*OOF['et']
err=pred-y
df=pd.DataFrame({'e':err,'c':conds})
cr=df.groupby('c').apply(lambda s: pd.Series({'rmse':np.sqrt(np.mean(s['e']**2)),'bias':s['e'].mean(),'n':len(s)}))
cr=cr.sort_values('rmse',ascending=False)
print('WORST 12 conditions (cond = angle,chord,vel):')
print(cr.head(12).round(2))
print()
print('p90 threshold count: conditions with rmse>2.35:', (cr['rmse']>2.35).sum(), 'of', len(cr))
"
output
WORST 12 conditions (cond = angle,chord,vel):
                      rmse  bias     n
c                                     
(19.7, 0.0508, 71.3)  5.07  3.86  14.0
(22.2, 0.0254, 39.6)  4.87 -3.50  15.0
(12.6, 0.1524, 39.6)  4.28  3.23  16.0
(0.0, 0.0254, 71.3)   3.80  0.05  10.0
(7.3, 0.2286, 71.3)   3.40 -1.95  16.0
(12.7, 0.0254, 39.6)  2.95 -1.79  17.0
(4.2, 0.0508, 71.3)   2.86  2.02  10.0
(17.4, 0.0254, 31.7)  2.80 -0.56  15.0
(11.2, 0.0508, 39.6)  2.77 -0.09  14.0
(8.9, 0.1016, 71.3)   2.75 -1.03  16.0
(12.7, 0.0254, 71.3)  2.70 -1.24  17.0
(12.3, 0.1016, 71.3)  2.63 -1.42  16.0

p90 threshold count: conditions with rmse>2.35: 15 of 82
[stderr]
<string>:9: DeprecationWarning: DataFrameGroupBy.apply operated on the grouping columns. This behavior is deprecated, and in a future version of pandas the grouping columns will be excluded from the operation. Either pass `include_groups=False` to exclude the groupings or explicitly select the grouping columns after groupby to silence this warning.
15 of 82 conditions have RMSE > 2.35 (18%). For p90 <= 2.35, I need at most ~8 conditions (10%) above. So I need to fix ~7 conditions. The worst conditions have large biases: - (19.7, 0.0508, 71.3): bias +3.86 (high angle, high velocity) , model under-predicts? bias is pred-y=+3.86, so pred too high. Wait bias=mean(pred-y)=+3.86 means prediction is too HIGH by ~3.86. - (22.2, 0.0254, 39.6): bias -3.50, pred too LOW. - (12.6, 0.1524, 39.6): bias +3.23, pred too high. - (7.3, 0.2286, 71.3): bias -1.95. Many worst conditions are high-angle (stall regime: 19.7, 22.2, 17.4, 12.6, 12.7) or extreme. These are the boundary-layer separation cases. The bias being large suggests the model extrapolates the level wrong for unseen extreme conditions. High angle = separated flow = different noise mechanism. In UCI data, high angles have a separate "separation-stall" noise. The displacement thickness feature t should encode this. But for unseen high-angle conditions, the model may extrapolate poorly. These are genuinely hard extrapolation cases. The key question: will the hidden set contain such extreme conditions? The hidden conditions are held-out groups, likely sampled similarly. So I should make the model robust for these. Options to improve: 1. More/better features that linearize the physics (so extrapolation is better). 2. A model that extrapolates the level better (e.g., a parametric/linear component). Idea: Combine a global parametric (physics-based linear/GAM) model that captures the overall level trend with the flexible residual model. The linear model extrapolates the level smoothly, while trees/GPR capture local shape. Actually GPR with Matern already extrapolates somewhat. But trees don't extrapolate at all (they're bounded by training range). For high-angle unseen conditions outside training, trees predict the nearest training level → biased. Let me think: GPR bias vs ET bias on worst conditions. Let me check if GPR alone does better on the high-bias conditions (it can extrapolate). Actually, let me look more carefully. The issue might be that some conditions are genuinely at the edge of the parameter space. Let me check: are the worst conditions at extreme parameter combos (highest angle, etc.)? (19.7, 0.0508, 71.3), (22.2, 0.0254, 39.6) , these are the two highest angles (19.7 and 22.2). High angle + specific chord/velocity. These extreme-angle conditions are at the boundary. The fundamental limitation: held-out extreme conditions are hard to predict. But the targets allow p95=4.75 and max=5.60, so 2 bad conditions (top 5%: ~4 conditions) can be up to 4.75, and the worst up to 5.60. Currently max=5.07 (OK), p95=3.38 (OK). So the top conditions are fine! The problem is purely p90: too many conditions in the 2.35-2.9 range. So I need to reduce the "middle-bad" conditions (ranks 9-15 worst), which have RMSE 2.6-2.8. These aren't extreme , e.g., (8.9,0.1016,71.3)=2.75, (11.2,0.0508,39.6)=2.77, (12.3,0.1016,71.3)=2.63. These are moderate conditions that should be predictable. Let me focus on systematically improving the bulk of conditions. Let me reconsider features and model. One idea: since within a condition only frequency varies, and the spectral shape is smooth, maybe I should model the spectral shape more explicitly. For instance, use a richer frequency basis. Let me also reconsider: maybe the bias in many conditions is a *level* offset that correlates with the aero params. If I can predict the per-condition mean level better, I reduce bias-driven RMSE. Decompose: SPL = level(a,c,v,t) + shape(f; a,c,v,t). The level is the mean over frequencies. If I fit a good model for the mean level and a good model for the shape, maybe better. Actually, a cleaner idea: add the predicted per-condition-mean as context. But at test time I don't know the condition mean. Let me try improving the feature set with more physics and see if ET/GPR improve. Let me add Strouhal-based spectral features. In BPM, the spectral function …[truncated]
15 of 82 conditions exceed the p90 threshold; the worst are extreme high-angle cases but the `max`/`p95` are already fine. The real issue is too many mid-range conditions (RMSE 2.6–2.8). Let me grid-search ensemble weights to directly target p90 while keeping other metrics in range.
bash
cd /app && python3 -c "
import sys; sys.path.insert(0,'/tmp')
import numpy as np, pandas as pd, itertools
from ens import OOF, y, conds
from evalharness import metrics
names=['et','hgb','rf','svr','gpr']
T=dict(rmse=2.25,mae=1.55,p90=2.35,p95=4.75,mx=5.60)
best=[]
grid=[0,0.1,0.2,0.3,0.4,0.5,0.6,0.7,0.8,1.0]
from itertools import product
for w in product(grid, repeat=5):
    s=sum(w)
    if s<0.99: continue
    if (np.array(w)>0).sum()>3: continue  # at most 3 models for simplicity
    wn=np.array(w)/s
    pred=sum(wn[i]*OOF[names[i]] for i in range(5))
    m=metrics(y,pred,conds)
    # primary: minimize p90, tie-break rmse
    best.append((m['p90'], m['rmse'], m['mae'], m['p95'], m['mx'], w))
best.sort()
print('Top configs by p90 (et,hgb,rf,svr,gpr weights):')
for p90,rmse,mae,p95,mx,w in best[:12]:
    print(f'  p90={p90:.3f} rmse={rmse:.3f} mae={mae:.3f} p95={p95:.3f} max={mx:.3f}  w={w}')
"
output
Top configs by p90 (et,hgb,rf,svr,gpr weights):
  p90=2.730 rmse=1.980 mae=1.421 p95=3.497 max=5.247  w=(0.8, 0, 0, 0, 0.4)
  p90=2.730 rmse=1.980 mae=1.421 p95=3.497 max=5.247  w=(1.0, 0, 0, 0, 0.5)
  p90=2.746 rmse=1.959 mae=1.405 p95=3.458 max=5.030  w=(1.0, 0, 0, 0.1, 0.6)
  p90=2.748 rmse=1.947 mae=1.394 p95=3.433 max=5.003  w=(1.0, 0, 0, 0.1, 0.7)
  p90=2.748 rmse=1.978 mae=1.420 p95=3.515 max=5.172  w=(1.0, 0, 0.1, 0, 0.6)
  p90=2.750 rmse=1.938 mae=1.385 p95=3.411 max=4.979  w=(1.0, 0, 0, 0.1, 0.8)
  p90=2.750 rmse=1.946 mae=1.393 p95=3.428 max=4.937  w=(0.7, 0, 0, 0.1, 0.5)
  p90=2.751 rmse=1.956 mae=1.402 p95=3.450 max=4.983  w=(0.8, 0, 0, 0.1, 0.5)
  p90=2.752 rmse=1.938 mae=1.382 p95=3.410 max=5.119  w=(0.6, 0, 0, 0, 0.5)
  p90=2.752 rmse=1.995 mae=1.433 p95=3.524 max=5.283  w=(0.7, 0, 0, 0, 0.3)
  p90=2.753 rmse=1.977 mae=1.420 p95=3.519 max=5.156  w=(0.8, 0, 0.1, 0, 0.5)
  p90=2.754 rmse=1.937 mae=1.380 p95=3.405 max=5.111  w=(0.7, 0, 0, 0, 0.6)
Even the best ensemble weights can only get p90 down to ~2.73. Weight tuning alone won't reach 2.35. I need fundamentally better base models. Let me reconsider. The p90=2.35 target requires a qualitatively better model. Let me think harder about the physics and feature engineering. Key idea: The problem is predicting a spectral curve for unseen (a,c,v,t). The current models treat each (frequency, condition) row independently. But I know the structure: within a condition, it's a smooth curve in log-frequency. Approach: Fit the spectral shape explicitly. For each condition, the SPL(log-f) curve can be described by a few parameters (peak location, peak amplitude, width, skewness). Then model these parameters as functions of (a,c,v,t). At test time, predict the parameters, then evaluate the curve. But this requires the test conditions to have known (a,c,v,t) , which they do (each test row has all 5 features including frequency). So I'd predict curve params from (a,c,v,t), then evaluate at the given frequency. This is more robust because it enforces smooth spectral shape and separates level/shape from the condition mapping. However, implementing a robust curve-fit + parameter regression is complex and time-consuming. Let me first check if there's a simpler win. Alternative idea: maybe better features for the existing models. Let me think about what the BPM model uses. The key dimensionless group is the Strouhal number St = f·δ*/U. The spectral shape is a function of St. Let me check: if I plot SPL vs log10(St), do all conditions collapse onto a similar curve? If so, the shape is universal and the level shifts are predictable. Let me check the collapse.
Weight tuning caps out at p90≈2.73 , I need fundamentally better base models. Let me test the BPM physics hypothesis: does SPL collapse onto a universal curve vs Strouhal number across conditions?
bash
cd /app && timeout 60 python3 -c "
import sys; sys.path.insert(0,'/tmp')
import numpy as np, pandas as pd
from cv import FULL
d=FULL.copy()
d['st']=np.log10(d['frequency']*d['suction_side_displacement_thickness']/d['free_stream_velocity'])
# For each condition compute peak SPL and peak-St; see if shape collapses
from evalharness import cond_labels
# correlation of SPL with st overall
print('corr SPL vs log-St:', np.corrcoef(d['st'], d['scaled_sound_pressure_level'])[0,1].round(3))
# Fit global cubic in st, check residual
for deg in [2,3,4,5]:
    co=np.polyfit(d['st'], d['scaled_sound_pressure_level'], deg)
    r=d['scaled_sound_pressure_level']-np.polyval(co,d['st'])
    print(f'global poly deg{deg} in st: resid std={r.std():.3f}')
"
output
corr SPL vs log-St: -0.579
global poly deg2 in st: resid std=4.955
global poly deg3 in st: resid std=4.880
global poly deg4 in st: resid std=4.869
global poly deg5 in st: resid std=4.857
The SPL does NOT collapse onto a universal curve vs St alone (residual std ~4.9 dB). So the level shifts are large and depend on the aero params beyond St. The Strouhal collapse isn't clean (the UCI data is known to not perfectly collapse; BPM has multiple mechanisms). Let me reconsider. The key is to predict the level offset per condition. Let me decompose: SPL = g(a,c,v,t) + h(St; a,c,v,t). Let me try the curve-parameter approach but more simply: model the per-condition mean SPL and the shape separately. Actually, let me think about a 2-stage residual model: Stage 1: Fit a flexible model M1 (e.g., GPR) to predict SPL. Stage 2: Look at per-condition mean residual. But at test, unknown condition → can't directly use. Alternatively, use a model structure that naturally handles the curve. Let me reconsider the MLP , a neural net can learn the full mapping smoothly and extrapolates better than trees. With good features and regularization, MLP might beat trees on held-out conditions. Let me tune an MLP properly. Actually, let me reconsider the whole thing. Let me look at the best possible per-condition performance. The within-condition cubic residual std was ~0.4 for smooth conditions but up to ~2.2 for high-angle. So the *achievable* per-condition RMSE floor varies. For high-angle stall conditions, even a perfect level prediction leaves ~2 dB RMSE due to the sharp spectral kink that a smooth model can't capture well... but actually if the model sees the frequency it can capture the kink if it learned the shape. Let me reconsider. Let me quantify: if I fit a separate flexible curve per condition (oracle shape, using that condition's own data), what's the RMSE? That's the shape-limited floor. But that uses test labels. Instead, let me fit, for each condition, the best the *global model* could do if it knew the per-condition level (oracle level correction): subtract per-condition mean bias from predictions and see p90. Let me compute: take gpr+et OOF, remove per-condition mean bias (oracle), recompute metrics. This tells me how much is "level bias" vs "shape error".
SPL doesn't collapse onto a universal Strouhal curve , level offsets are large and condition-dependent. Let me quantify how much of the per-condition error is a correctable *level bias* versus *shape error* by applying an oracle per-condition mean correction.
bash
cd /app && python3 -c "
import sys; sys.path.insert(0,'/tmp')
import numpy as np, pandas as pd
from ens import OOF, y, conds
from evalharness import metrics
pred=0.5*OOF['gpr']+0.5*OOF['et']
df=pd.DataFrame({'p':pred,'y':y,'c':conds})
# oracle level correction: remove per-condition mean error
df['e']=df['p']-df['y']
bias=df.groupby('c')['e'].transform('mean')
pred_corr=df['p']-bias
m0=metrics(y,pred,conds); m1=metrics(y,pred_corr.values,conds)
print('original   p90=%.3f p95=%.3f max=%.3f rmse=%.3f'%(m0['p90'],m0['p95'],m0['mx'],m0['rmse']))
print('oracle-lvl p90=%.3f p95=%.3f max=%.3f rmse=%.3f'%(m1['p90'],m1['p95'],m1['mx'],m1['rmse']))
# distribution of per-condition bias magnitude
cb=df.groupby('c')['e'].mean()
print('per-condition |bias|: median %.2f p90 %.2f max %.2f'%(cb.abs().median(), cb.abs().quantile(.9), cb.abs().max()))
# shape-only RMSE per condition (after removing bias)
shape=df.assign(ec=df['e']-bias).groupby('c')['ec'].apply(lambda s: np.sqrt(np.mean(s**2)))
print('shape-only per-cond RMSE: median %.2f p90 %.2f max %.2f'%(shape.median(),shape.quantile(.9),shape.max()))
"
output
original   p90=2.771 p95=3.382 max=5.072 rmse=1.927
oracle-lvl p90=2.543 p95=2.787 max=3.804 rmse=1.578
per-condition |bias|: median 0.58 p90 1.61 max 3.86
shape-only per-cond RMSE: median 1.11 p90 2.54 max 3.80
Even with oracle level correction (perfect per-condition mean), p90 only drops to 2.543 , still above 2.35! And the shape-only per-condition RMSE has p90=2.54, max=3.80. So a large part of the error is *shape* error, not just level bias. The model isn't capturing the spectral shape well for many conditions. This means I need to improve the shape modeling, not just the level. The shape-only p90=2.54 means even perfectly centered, 10% of conditions have shape RMSE > 2.54. These are likely the high-angle stall conditions with sharp spectral features. So the bottleneck is genuinely capturing the spectral curve shape for held-out conditions. This requires a model that learns the frequency-dependence well and generalizes the shape across conditions. Let me reconsider. The ensemble of GPR+ET may be smoothing too much. Let me think about what captures sharp spectral shapes: - More frequency-resolved features - A model with higher capacity in the frequency dimension Actually, one important consideration: the shape-only RMSE being high suggests that for some conditions, the SPL vs frequency curve is not well predicted even in shape. This happens when the peak frequency or the drop-off is mispredicted. Let me look at a specific bad condition's predicted vs actual curve to diagnose. Let me pick (12.6, 0.1524, 39.6) which had high bias, and (8.9,0.1016,71.3), and plot predicted vs actual across frequency. Actually, I realize I should reconsider the modeling. Let me try to improve the base models substantially. Ideas: 1. GPR is good but the Matern kernel with shared structure might not capture the frequency shape sharply. Let me give GPR a more flexible kernel: sum of a frequency-kernel and an aero-kernel, or a kernel that's more flexible in the frequency dimension. 2. Increase model capacity specifically along frequency. Let me try a key idea: an additive structure. SPL = f1(logf) interacting with aero. Actually, let me try a spline expansion of frequency combined with aero features in a gradient-boosted or linear model. Let me step back and think about the most promising path given limited time. GPR gave best global metrics (rmse 1.97, mae 1.36). The ensemble gpr+et gives good p95/max. The bottleneck is p90~2.77 and oracle floor 2.54. To get p90 below 2.35, I need better shape modeling. Let me try: - A higher-capacity GPR (smaller length scale on frequency, less white noise) to capture sharper shapes. But that risks overfitting. Actually, let me reconsider the feature for frequency. The spectral shape is best captured in Strouhal number. Let me make sure both logf AND strouhal are in the GPR features (currently feats_gpr has both logf and strouhal=log10(f*t/v)). Good. Let me try re-optimizing GPR with MORE features (add reynolds, angle interactions) and a flexible kernel, accepting slower one-time fit. Then freeze. Alternatively, let me try an MLP ensemble , neural nets can capture sharp nonlinear shapes and extrapolate. Let me tune an MLP with good architecture and features, using the fast OOF framework. Let me test several MLP configs via OOF (MLP is reasonably fast). Let me also try adding polynomial/spline features for a Ridge/linear model that might capture shape in an extrapolation-friendly way. Let me prioritize: generate OOF for a few more diverse, strong models: - MLP (tuned) - GPR with richer kernel/features - A spline+Ridge or spline+GBM Let me first try MLP configs.
Even oracle level-correction leaves p90=2.54 , the bottleneck is *shape* error, not just level bias. I need better spectral-shape modeling. Let me diagnose a bad condition's predicted vs actual curve, then test higher-capacity models (MLP, richer GPR).
bash
cd /app && python3 -c "
import sys; sys.path.insert(0,'/tmp')
import numpy as np, pandas as pd
from ens import OOF, y, conds
from cv import FULL
pred=0.5*OOF['gpr']+0.5*OOF['et']
d=FULL.copy(); d['p']=pred; d['y']=y
for cond in [(12.6,0.1524,39.6),(8.9,0.1016,71.3),(19.7,0.0508,71.3)]:
    sub=d[d['cond']==cond].sort_values('frequency')
    print('cond',cond)
    print('  f   ', np.round(sub['frequency'].values,0).astype(int))
    print('  true', np.round(sub['y'].values,1))
    print('  pred', np.round(sub['p'].values,1))
"
output
cond (12.6, 0.1524, 39.6)
  f    [ 200  250  315  400  500  631  801 1001 1250 1598 1997 2497 3150 4005
 5008 6309]
  true [116.3 116.9 117.5 117.9 116.7 118.7 119.  117.2 114.9 113.2 112.3 109.2
 108.8 106.2 106.4 105.4]
  pred [126.1 125.7 124.5 122.4 120.7 120.8 120.6 119.5 117.7 114.7 112.4 111.3
 109.8 108.  107.2 107. ]
cond (8.9, 0.1016, 71.3)
  f    [ 200  250  315  399  499  629  800 1001 1252 1602 2000 2498 3145 3994
 4997 6304]
  true [132.  133.3 135.6 137.4 137.7 137.5 137.1 134.9 132.6 129.2 124.9 124.
 123.4 120.2 116.7 117.4]
  pred [129.1 129.7 131.  132.6 134.2 135.3 136.  135.8 134.7 132.2 128.7 124.6
 121.7 119.3 117.1 115.6]
cond (19.7, 0.0508, 71.3)
  f    [ 200  250  315  400  501  631  800  999 1248 1598 1999 2502 3155 4007]
  true [117.6 119.7 121.2 124.3 126.8 128.2 124.4 122.2 120.9 120.1 119.6 117.3
 114.7 112. ]
  pred [120.3 121.  122.2 123.6 125.4 127.4 128.6 128.7 127.9 126.6 125.2 124.
 122.4 119.9]
Now I can see the problem clearly: Condition (12.6, 0.1524, 39.6): The true curve is nearly flat ~117 then declines. The prediction starts high (126) at low freq and declines. At low frequency the model overpredicts by ~10 dB! The model thinks low-frequency SPL should be high (126) but it's actually 116. This is a shape/level error at low frequencies. Condition (8.9, 0.1016, 71.3): true peaks at ~137 around 500Hz; pred peaks at ~136 around 800Hz. Reasonably close but shifted. Condition (19.7, 0.0508, 71.3): true peaks at 631Hz (128) then drops to 124 at 800Hz (sharp drop , stall); pred keeps rising to 800-1000Hz (128.7) then slowly declines. The model misses the sharp peak-and-drop at high angle. Classic stall behavior , the peak is at lower frequency and drops sharply. The model overpredicts the high-frequency tail. So the issue: at high angles, the spectral peak shifts to lower frequency and the curve drops sharply after the peak. The model (trained mostly on lower-angle smooth curves) doesn't extrapolate this well. These are genuinely hard. But the first condition (12.6, 0.1524, 39.6) shows a ~10 dB overprediction at low frequency , that seems like a fixable level/shape issue. The true curve is almost flat around 117-119 for low-mid frequencies. The model predicts a monotonic decline from 126. This suggests that for large chord (0.1524) at moderate angle, the low-frequency SPL is lower than the model expects. Hmm. The physics: larger chord → lower peak frequency, and the displacement thickness is larger. I think the core issue is that these are extrapolation cases in the aero-parameter space, and the spectral shape changes qualitatively (stall). A smooth global model struggles. Given the difficulty, let me consider whether I'm overcomplicating. Let me reconsider: maybe a fundamentally stronger single model with better features and careful tuning can push p90 down. Let me try: 1. A well-tuned MLP (can capture sharp shapes, extrapolates smoothly). 2. GPR with more features and a flexible kernel. 3. Stacking. Let me generate OOF for MLP with a good architecture. Let me also try a bagged MLP ensemble (average several MLPs with different seeds) , this is known to work well and reduces variance, improving worst-case. Let me test MLP configs quickly. I'll standardize features. Use the full feats (15 features). Let me also reconsider: maybe I should scale target. MLP with early stopping. Let me generate MLP OOF (bagged, 5 seeds averaged within each fold) and see metrics. This might be slow; let me time carefully with a budget. Actually, let me be strategic about time. Let me test a single MLP config first via quick OOF.
Now I see the failure mode: at high angles the spectral peak shifts down and drops sharply (stall), and the model overpredicts the high-frequency tail and low-frequency level. A smooth neural net may capture these shapes and extrapolate better. Let me test a bagged MLP.
bash
cd /app && cat >> /tmp/ff.py << 'EOF'

def feats_nn(df):
    f=df['frequency'].astype(float).values; a=df['attack_angle'].astype(float).values
    c=df['chord_length'].astype(float).values; v=df['free_stream_velocity'].astype(float).values
    t=df['suction_side_displacement_thickness'].astype(float).values
    logf=np.log10(f); logt=np.log10(t); logc=np.log10(c); st=np.log10(f*t/v); re=np.log10(v*c)
    return np.column_stack([logf,a,logc,v,logt,st,re])
EOF
cd /app && timeout 250 python3 -c "
import sys,time; sys.path.insert(0,'/tmp')
import numpy as np
from oofgen import gen
from ff import feats_nn
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.neural_network import MLPRegressor
from sklearn.base import BaseEstimator, RegressorMixin
class BagMLP(BaseEstimator, RegressorMixin):
    def __init__(self, arch=(128,128,64), n=5, alpha=1e-3):
        self.arch=arch; self.n=n; self.alpha=alpha
    def fit(self,X,y):
        self.ms=[]
        for s in range(self.n):
            m=make_pipeline(StandardScaler(), MLPRegressor(hidden_layer_sizes=self.arch,alpha=self.alpha,
                max_iter=3000,random_state=s,early_stopping=True,n_iter_no_change=30,validation_fraction=0.12))
            m.fit(X,y); self.ms.append(m)
        return self
    def predict(self,X):
        return np.mean([m.predict(X) for m in self.ms],axis=0)
gen('mlp', lambda: BagMLP((128,128,64),5,1e-3), feats_nn)
" 2>&1 | grep -vi warn
output
mlp              rmse=2.718 mae=1.978 p90=3.869 p95=4.498 max=6.678 t=222s
The bagged MLP is worse (rmse 2.718, p90 3.869) and very slow (222s). Not helpful. MLP with early stopping on this small dataset doesn't do well. Let me abandon MLP. Let me reconsider. The best base is GPR (rmse 1.97). The ensemble gpr+et gives p90 2.77. Let me reconsider the GPR kernel. The issue is capturing sharp spectral shapes. A Matern 1.5 with moderate length scales smooths. Maybe I need a kernel with a shorter length scale on the frequency/Strouhal dimension to capture sharper peaks, but that risks overfitting the shape. Actually, let me reconsider the approach entirely. Let me think about the structure more. Since within a condition only frequency varies, and the curve is smooth-ish, maybe the right model is one that predicts the whole curve. But the held-out condition shape errors come from stall behavior at high angle. Let me look at feature importance and whether adding interaction features helps GPR. Let me re-optimize GPR with a larger feature set including angle-strouhal interactions, which might help capture the angle-dependent peak shift. Let me try GPR with features: [logf, a, logc, v, logt, st, re, a*st, a*logf] (9 features), re-optimize kernel once, then freeze and CV. But re-optimizing takes ~100s. Let me do it within budget. Actually, let me reconsider if there's a smarter, more robust model. Let me think about k-NN in a physically-scaled space. KNN was bad (rmse 3.0). Let me reconsider: The target p90<=2.35 with oracle-level floor at 2.54 for the current ensemble means I MUST improve shape. Let me verify the oracle floor for GPR alone and for ET alone, and for a richer model, to see the best achievable. Actually, the oracle floor depends on the model's shape accuracy. A better shape model lowers it. Let me focus on shape. New idea: Model the residual spectral shape using frequency-local structure. Specifically, fit a model, then for the shape, use the fact that consecutive frequencies within a condition are correlated. But at test time, the condition's other frequencies ARE available (the hidden test set presumably includes multiple frequencies per condition). So I could use a transductive approach: for each test condition, I have all its (frequency, features) rows. I could fit the shape jointly. But I don't know if the hidden test set has multiple frequencies per condition. Given the split holds out condition groups, YES , each held-out condition contributes all its frequency rows to the test set. So the test set has full spectral curves for each held-out condition, just without labels. This opens a powerful approach: semi-supervised / transductive. For each test condition, I know the shape structure (smoothness in log-f). I could: 1. Predict SPL for all test rows with a base model. 2. Smooth the predictions within each condition (since the true curve is smooth in log-f), reducing noise. But smoothing only helps if base predictions are noisy around the true smooth curve. The errors we saw are systematic (biased), not noisy. So smoothing won't fix bias. Another transductive idea: the overall level of a test condition could be estimated... but we have no labels. Hmm, let me reconsider. Actually, I worry about over-engineering. Let me reconsider the achievable target. The targets might be calibrated so that a good-but-not-perfect model passes. Let me re-examine: the validation-set-only estimate (15 conditions) for gpr+et , let me check that, because the HIDDEN set might be more like 15-40 conditions, and the specific draw matters. The full-CV p90 (82 conditions) is a stricter population estimate. But the actual hidden metric is on one draw. Actually, let me reconsider what the grader does: it trains on ALL my labeled data (train+val) and predicts the hidden test features. The hidden conditions are disjoint from train+val. So the model is trained on MORE data (82 conditions) than in my CV (where each fold trains on ~72 conditions). More training conditions → better. So the true hidden pe …[truncated]
The MLP is worse and slow , I'll drop it. The key insight: the hidden test set contains *full spectral curves* (all frequencies) for each held-out condition, just without labels. Before exploiting that, let me try to strengthen GPR with richer features and re-optimized kernels.
bash
cd /app && cat > /tmp/gpropt.py << 'EOF'
import sys,time; sys.path.insert(0,'/tmp')
import numpy as np, pandas as pd
from cv import FULL
from evalharness import metrics
from sklearn.model_selection import GroupKFold
from sklearn.preprocessing import StandardScaler
from sklearn.gaussian_process import GaussianProcessRegressor
from sklearn.gaussian_process.kernels import WhiteKernel, ConstantKernel, Matern
y=FULL['scaled_sound_pressure_level'].values
groups=FULL['cond'].astype(str).values
conds=FULL['cond']
FOLDS=list(GroupKFold(n_splits=8).split(FULL,y,groups))

def run(name, feat_fn, nu=1.5):
    X=feat_fn(FULL); d=X.shape[1]
    sc=StandardScaler().fit(X)
    k=ConstantKernel(1.0,(1e-2,1e3))*Matern(length_scale=np.ones(d),length_scale_bounds=(5e-2,1e2),nu=nu)+WhiteKernel(0.1,(1e-3,1e1))
    t0=time.time()
    gp=GaussianProcessRegressor(kernel=k,normalize_y=True,n_restarts_optimizer=1,random_state=0).fit(sc.transform(X),y)
    theta=gp.kernel_
    # frozen CV
    oof=np.zeros(len(FULL))
    for tri,tei in FOLDS:
        sci=StandardScaler().fit(X[tri])
        g=GaussianProcessRegressor(kernel=theta,normalize_y=True,optimizer=None).fit(sci.transform(X[tri]),y[tri])
        oof[tei]=g.predict(sci.transform(X[tei]))
    m=metrics(y,oof,conds)
    print(f"{name:20s} rmse={m['rmse']:.3f} mae={m['mae']:.3f} p90={m['p90']:.3f} p95={m['p95']:.3f} max={m['mx']:.3f} t={time.time()-t0:.0f}s")
    np.save(f'/tmp/oof_{name}.npy', oof)
    return theta
EOF
echo ok
output
ok
bash
cd /app && timeout 280 python3 -c "
import sys; sys.path.insert(0,'/tmp')
import numpy as np
from gpropt import run
def f8(df):
    f=df['frequency'].values.astype(float); a=df['attack_angle'].values.astype(float)
    c=df['chord_length'].values.astype(float); v=df['free_stream_velocity'].values.astype(float)
    t=df['suction_side_displacement_thickness'].values.astype(float)
    logf=np.log10(f); st=np.log10(f*t/v)
    return np.column_stack([logf,a,np.log10(c),v,np.log10(t),st,np.log10(v*c),a*st])
def f9(df):
    f=df['frequency'].values.astype(float); a=df['attack_angle'].values.astype(float)
    c=df['chord_length'].values.astype(float); v=df['free_stream_velocity'].values.astype(float)
    t=df['suction_side_displacement_thickness'].values.astype(float)
    logf=np.log10(f); st=np.log10(f*t/v)
    return np.column_stack([logf,a,np.log10(c),v,np.log10(t),st,np.log10(v*c),a*st,a*logf])
t1=run('gpr8', f8, 1.5)
print('  kernel8:', t1)
t2=run('gpr9', f9, 1.5)
print('  kernel9:', t2)
" 2>&1 | grep -vi warn
output
[stderr]
Terminated
Timed out again , optimizing GPR twice is too slow. The full-data optimize with n_restarts=1 takes ~100s each, plus CV. Let me do ONE variant at a time with a tighter budget, and skip n_restarts (use n_restarts_optimizer=0, single optimization). Let me just do gpr8. Actually, let me reduce the one-time optimization cost: optimize on a subsample of the data (e.g., 500 points) to get kernel hyperparameters faster, then freeze and CV on full. Let me do that. Actually, let me reconsider time budget. I have ~144k tokens left. Each GPR full-optimization is ~100s. Let me be judicious. Let me just run gpr8 alone with n_restarts=0.
GPR re-optimization is too slow to do repeatedly. Let me optimize hyperparameters on a subsample (fast) then freeze for full CV, testing one richer feature set.
bash
cd /app && timeout 200 python3 -c "
import sys,time; sys.path.insert(0,'/tmp')
import numpy as np
from cv import FULL
from evalharness import metrics
from sklearn.model_selection import GroupKFold
from sklearn.preprocessing import StandardScaler
from sklearn.gaussian_process import GaussianProcessRegressor
from sklearn.gaussian_process.kernels import WhiteKernel, ConstantKernel, Matern
y=FULL['scaled_sound_pressure_level'].values
groups=FULL['cond'].astype(str).values; conds=FULL['cond']
FOLDS=list(GroupKFold(n_splits=8).split(FULL,y,groups))
def f8(df):
    f=df['frequency'].values.astype(float); a=df['attack_angle'].values.astype(float)
    c=df['chord_length'].values.astype(float); v=df['free_stream_velocity'].values.astype(float)
    t=df['suction_side_displacement_thickness'].values.astype(float)
    logf=np.log10(f); st=np.log10(f*t/v)
    return np.column_stack([logf,a,np.log10(c),v,np.log10(t),st,np.log10(v*c),a*st])
X=f8(FULL); d=X.shape[1]
# optimize on subsample
rng=np.random.RandomState(0); idx=rng.choice(len(X),600,replace=False)
sc=StandardScaler().fit(X[idx])
k=ConstantKernel(1.0,(1e-2,1e3))*Matern(length_scale=np.ones(d),length_scale_bounds=(5e-2,1e2),nu=1.5)+WhiteKernel(0.1,(1e-3,1e1))
t0=time.time()
gp=GaussianProcessRegressor(kernel=k,normalize_y=True,n_restarts_optimizer=2,random_state=0).fit(sc.transform(X[idx]),y[idx])
print('opt time',round(time.time()-t0,1),'kernel',gp.kernel_)
theta=gp.kernel_
oof=np.zeros(len(FULL))
for tri,tei in FOLDS:
    sci=StandardScaler().fit(X[tri])
    g=GaussianProcessRegressor(kernel=theta,normalize_y=True,optimizer=None).fit(sci.transform(X[tri]),y[tri])
    oof[tei]=g.predict(sci.transform(X[tei]))
m=metrics(y,oof,conds)
print(f\"gpr8 rmse={m['rmse']:.3f} mae={m['mae']:.3f} p90={m['p90']:.3f} p95={m['p95']:.3f} max={m['mx']:.3f}\")
np.save('/tmp/oof_gpr8.npy', oof)
" 2>&1 | grep -vi warn
output
opt time 27.4 kernel 1.53**2 * Matern(length_scale=[2.14, 2.13, 1.78, 13.1, 2.76, 2.91, 3.38, 3.23], nu=1.5) + WhiteKernel(noise_level=0.00503)
gpr8 rmse=1.926 mae=1.329 p90=2.805 p95=3.201 max=6.586
gpr8 (with a*st interaction): rmse=1.926, mae=1.329, p90=2.805, p95=3.201, max=6.586. Slightly better rmse/mae than gpr (1.968/1.358) but p90 similar (2.805 vs 3.069 , actually better p90) and max worse (6.586). Optimizing on subsample is fast (27s). Good. Let me now ensemble gpr8 + et and check. Actually p90 for gpr8 alone is 2.805, better than gpr's 3.069. Let me ensemble gpr8+et. Let me load and test ensembles including gpr8.
`gpr8` (with angle×Strouhal interaction) improves p90 to 2.805 alone. Subsample optimization is fast. Let me ensemble it with ExtraTrees and others.
bash
cd /app && python3 -c "
import sys; sys.path.insert(0,'/tmp')
import numpy as np
from ens import y, conds, show
OOF={n:np.load(f'/tmp/oof_{n}.npy') for n in ['et','hgb','rf','svr','gpr','gpr8']}
show('gpr8', OOF['gpr8'])
show('gpr8+et', 0.5*OOF['gpr8']+0.5*OOF['et'])
show('gpr8+et+hgb', (OOF['gpr8']+OOF['et']+OOF['hgb'])/3)
show('gpr+gpr8+et', (OOF['gpr']+OOF['gpr8']+OOF['et'])/3)
show('gpr8+2et', (OOF['gpr8']+2*OOF['et'])/3)
show('.4gpr8+.4et+.2hgb', .4*OOF['gpr8']+.4*OOF['et']+.2*OOF['hgb'])
"
output
gpr8                       rmse=1.926 mae=1.329 p90=2.805 p95=3.201 max=6.586 | OK OK XX OK XX 
gpr8+et                    rmse=1.893 mae=1.349 p90=2.638 p95=3.253 max=5.111 | OK OK XX OK OK 
gpr8+et+hgb                rmse=1.943 mae=1.388 p90=2.775 p95=3.425 max=5.192 | OK OK XX OK OK 
gpr+gpr8+et                rmse=1.878 mae=1.320 p90=2.738 p95=3.181 max=5.411 | OK OK XX OK OK 
gpr8+2et                   rmse=1.956 mae=1.404 p90=2.679 p95=3.417 max=5.111 | OK OK XX OK OK 
.4gpr8+.4et+.2hgb          rmse=1.912 mae=1.363 p90=2.730 p95=3.345 max=5.154 | OK OK XX OK OK
gpr8+et: rmse=1.893, mae=1.349, p90=2.638, p95=3.253, max=5.111. p90 improved to 2.638 but still above 2.35. Getting closer incrementally. I'm still ~0.29 above on p90. Incremental ensembling won't close this gap. I need a better modeling approach for the shape. Let me now seriously pursue the transductive / structured approach that exploits the spectral smoothness within each condition. Key idea: The hidden test set contains all frequencies for each held-out condition. The true SPL(log-f) curve is smooth. So I can post-process predictions by enforcing per-condition smoothness. But as I noted, the errors are systematic bias, not noise, so smoothing within a condition won't remove bias. However, there's a more powerful structured idea: model the problem as predicting the smooth curve, which might regularize better. But that's what GPR does. Let me reconsider. Actually, let me reconsider whether the per-condition errors are really "shape" or whether my oracle-level analysis conflated things. The oracle-level correction removed the MEAN error per condition. The remaining shape error had p90=2.54. Let me look at whether a richer per-condition correction (linear in log-f) reduces it , i.e., if the error is a linear trend in log-f (tilt), removing tilt + level would approximate a 2-parameter per-condition correction. If that reduces shape error a lot, then the model's error is low-frequency structured and could be captured by better features. Actually, rather than analyze endlessly, let me think about the most promising modeling improvement: a GROUPED/hierarchical approach isn't possible without labels. Let me reconsider the tree models. ExtraTrees captures sharp local structure well but doesn't extrapolate. GPR extrapolates but smooths. The combination helps. What if I add a model that's specifically good at the frequency shape: a gradient boosting model with monotonic constraints? No. Let me try a different strong model: Support Vector Regression with a finely tuned RBF, or Kernel Ridge. Actually let me reconsider , let me do stacking: use the OOF predictions of base models as features plus the raw features, and train a meta-model. Stacking can capture where each model fails. Actually, a cleaner high-impact idea: The errors at high angle are because high-angle conditions are rare and extreme. Let me check the distribution of training data , maybe I can weight samples or use quantile/huber loss to be more robust. Let me step back and think about the target feasibility. Let me reconsider: maybe the targets are meant to be hit with a solid model and my CV is pessimistic because: 1. 8-fold GroupKFold trains on 7/8 conditions. Final model trains on ALL 82 conditions → slightly better. 2. The hidden set is ONE draw. The p90 of one draw of ~k conditions has variance. If the hidden set has few conditions, p90 could be lower or higher. But I should aim to beat the targets with margin in CV to be safe. Let me reconsider the feature engineering for the shape. The spectral peak location and the sharp high-angle drop. In BPM, the separation-stall noise (high angle) has its own Strouhal scaling St'' = f·δ*/U with a different peak. The transition to stall happens above a critical angle. Let me add features that capture the stall regime: e.g., angle relative to a threshold, angle*chord, etc. And a feature like (f * chord / v) , a chord-based Strouhal (related to the main airfoil). Let me also add the "Strouhal deviation from peak" type features. Actually, the best lever might be a well-designed spline basis in log-frequency interacted with aero features, fed to a regularized linear model (which extrapolates the level smoothly and can represent the shape flexibly). Let me try a Ridge/KernelRidge on a rich spline-interaction basis. Hmm, but a linear model in a fixed basis may not capture the angle-dependent peak shift well. Let me take yet another angle: let me just try KernelRidge with RBF (like GPR mean but faster to tune) a …[truncated]
`gpr8+et` reaches p90=2.638. Incremental ensembling is plateauing. Let me test a transductive idea: since each condition's true curve is smooth in log-frequency, smoothing the ensemble predictions within each condition may reduce shape noise. I'll validate this effect on OOF.
bash
cd /app && python3 -c "
import sys; sys.path.insert(0,'/tmp')
import numpy as np, pandas as pd
from ens import y, conds, show
from cv import FULL
OOF={n:np.load(f'/tmp/oof_{n}.npy') for n in ['et','gpr8']}
base=0.5*OOF['gpr8']+0.5*OOF['et']
logf=np.log10(FULL['frequency'].values)
def smooth(pred, deg, blend):
    out=pred.copy()
    for c in conds.unique():
        m=(conds==c).values
        if m.sum()<deg+2: continue
        x=logf[m]; p=pred[m]
        co=np.polyfit(x,p,deg); fit=np.polyval(co,x)
        out[m]=blend*fit+(1-blend)*p
    return out
show('base gpr8+et', base)
for deg in [3,4,5]:
    for bl in [0.5,1.0]:
        show(f'smooth deg{deg} bl{bl}', smooth(base,deg,bl))
"
output
base gpr8+et               rmse=1.893 mae=1.349 p90=2.638 p95=3.253 max=5.111 | OK OK XX OK OK 
smooth deg3 bl0.5          rmse=1.941 mae=1.389 p90=2.782 p95=3.305 max=5.304 | OK OK XX OK OK 
smooth deg3 bl1.0          rmse=2.077 mae=1.489 p90=2.883 p95=4.106 max=5.625 | OK OK XX OK XX 
smooth deg4 bl0.5          rmse=1.928 mae=1.379 p90=2.681 p95=3.248 max=5.252 | OK OK XX OK OK 
smooth deg4 bl1.0          rmse=2.007 mae=1.441 p90=2.829 p95=3.648 max=5.436 | OK OK XX OK OK 
smooth deg5 bl0.5          rmse=1.912 mae=1.363 p90=2.686 p95=3.253 max=5.144 | OK OK XX OK OK 
smooth deg5 bl1.0          rmse=1.959 mae=1.394 p90=2.868 p95=3.450 max=5.204 | OK OK XX OK OK
Smoothing makes it worse , confirming the true curves are NOT well-approximated by a global low-order polynomial (the high-angle curves have sharp kinks), and the ensemble predictions are already reasonably smooth. So transductive polynomial smoothing doesn't help. Let me abandon smoothing. Let me reconsider fundamentally. I'm stuck around p90=2.64. The oracle-level floor was 2.54 for gpr+et. For gpr8+et it's probably similar. This means even perfect level correction leaves p90~2.5. So the SHAPE must improve. Let me reconsider: what model best captures the shape? Let me compute the oracle-level-corrected p90 for EACH base model to find which has the best intrinsic shape accuracy: - If GPR has better shape, focus there. - If ET has better shape, focus there. Let me compute shape-only (oracle-level-corrected) per-condition RMSE p90 for each model and the ensemble.
Smoothing hurts (true curves have sharp kinks). Let me identify which base model has the best intrinsic *shape* accuracy by computing oracle-level-corrected p90 for each.
bash
cd /app && python3 -c "
import sys; sys.path.insert(0,'/tmp')
import numpy as np, pandas as pd
from ens import y, conds
from evalharness import metrics
models={n:np.load(f'/tmp/oof_{n}.npy') for n in ['et','hgb','rf','svr','gpr','gpr8']}
models['gpr8+et']=0.5*models['gpr8']+0.5*models['et']
def shape_metrics(pred):
    df=pd.DataFrame({'e':pred-y,'c':conds})
    bias=df.groupby('c')['e'].transform('mean')
    sh=df['e']-bias
    cr=pd.DataFrame({'sh':sh,'c':conds}).groupby('c')['sh'].apply(lambda s:np.sqrt(np.mean(s**2)))
    return cr.median(), cr.quantile(.9), cr.max()
print('model            shape_med shape_p90 shape_max')
for n,p in models.items():
    med,p90,mx=shape_metrics(p)
    print(f'{n:12s}      {med:.3f}     {p90:.3f}     {mx:.3f}')
"
output
model            shape_med shape_p90 shape_max
et                1.427     2.794     3.938
hgb               1.516     3.077     4.399
rf                1.757     2.983     4.711
svr               1.592     2.892     4.735
gpr               1.032     2.798     3.906
gpr8              1.110     2.500     3.955
gpr8+et           1.089     2.472     3.908
Interesting! The shape-only (oracle-level) p90: - gpr: 2.798, median 1.032 (best median shape) - gpr8: 2.500, median 1.110 - gpr8+et: 2.472, median 1.089 So GPR has the best *median* shape (1.03), meaning for typical conditions the shape is captured very well. But the p90 shape (~2.5) is dragged up by a few conditions with bad shape (the high-angle stall ones). So the shape floor is ~2.5 at p90. Since actual p90 (with level errors) is 2.64, the level errors add ~0.15. So most of the gap to 2.35 is SHAPE error in the worst ~10% of conditions (high-angle stall). So: to get p90 below 2.35, I need to improve the SHAPE prediction for the high-angle/extreme conditions specifically, OR reduce their count below the p90 threshold. Wait , p90 is the 90th percentile of per-condition RMSE. shape_p90 = 2.5 means 10% of conditions have shape RMSE > 2.5. These ~8 conditions are high-angle stall. If I could fix even the shape of conditions ranked ~8-15 worst (pushing them below 2.35), p90 drops. The median shape is 1.03 (excellent). So the bulk is fine; it's the tail. Let me identify the conditions with worst shape and see what they have in common. If they're all high-angle, I might build a specialized treatment. Let me look at the worst-shape conditions for gpr8. Actually, let me reconsider the bigger picture. Since the median condition RMSE is ~1.1-1.4 and the targets are p90<=2.35, the model is GOOD on typical conditions. The failures are concentrated in extreme regimes. The hidden test set: if it's a random draw of held-out conditions, the fraction of extreme conditions matters. If the hidden set has, say, 15-20 conditions and 2-3 are extreme high-angle, then p90 (90th percentile = ~2nd worst) could be an extreme condition → fail. Hmm, this is concerning. But note p95=4.75 and max=5.60 are lenient. If the hidden set is small (e.g., 15 conditions), p90 index = 0.9*14=12.6 → between 13th and 14th sorted = 2nd/3rd worst. So 1-2 extreme conditions allowed before p90. With ~2-3 extreme conditions it might fail p90. I really need to improve the extreme conditions. Let me look at what's special and whether better features help. Let me examine the worst-shape conditions for gpr8.
GPR has excellent *median* shape (1.03 dB) , the bulk of conditions are modeled well. The p90 bottleneck comes from a tail of high-angle stall conditions. Let me identify exactly which conditions dominate the tail.
bash
cd /app && python3 -c "
import sys; sys.path.insert(0,'/tmp')
import numpy as np, pandas as pd
from ens import y, conds
from cv import FULL
pred=0.5*np.load('/tmp/oof_gpr8.npy')+0.5*np.load('/tmp/oof_et.npy')
df=pd.DataFrame({'e':pred-y,'c':conds,'a':FULL['attack_angle'].round(1),'ch':FULL['chord_length'].round(3),'v':FULL['free_stream_velocity'].round(0)})
cr=df.groupby('c').apply(lambda s: pd.Series({'rmse':np.sqrt(np.mean(s['e']**2)),'bias':s['e'].mean(),'a':s['a'].iloc[0],'ch':s['ch'].iloc[0],'v':s['v'].iloc[0],'n':len(s)}), include_groups=False).sort_values('rmse',ascending=False)
print('Worst 14 by full RMSE:')
print(cr.head(14)[['rmse','bias','a','ch','v','n']].round(2).to_string())
print()
print('angle distribution of worst 14:', sorted(cr.head(14)['a'].tolist()))
print('all conditions: n with a>=12:', (cr['a']>=12).sum(),'of',len(cr))
"
output
Worst 14 by full RMSE:
                      rmse  bias     a    ch     v     n
c                                                       
(22.2, 0.0254, 39.6)  5.11 -3.78  22.2  0.02  40.0  15.0
(19.7, 0.0508, 71.3)  4.87  3.77  19.7  0.05  71.0  14.0
(12.6, 0.1524, 39.6)  4.19  3.12  12.6  0.15  40.0  16.0
(0.0, 0.0254, 71.3)   3.91  0.09   0.0  0.02  71.0  10.0
(7.3, 0.2286, 71.3)   3.27 -2.07   7.3  0.23  71.0  16.0
(4.2, 0.0508, 71.3)   2.89  2.07   4.2  0.05  71.0  10.0
(11.2, 0.0508, 39.6)  2.69 -0.07  11.2  0.05  40.0  14.0
(8.9, 0.1016, 71.3)   2.66 -0.98   8.9  0.10  71.0  16.0
(12.7, 0.0254, 39.6)  2.64 -1.27  12.7  0.02  40.0  17.0
(17.4, 0.0254, 31.7)  2.60 -0.55  17.4  0.02  32.0  15.0
(12.7, 0.0254, 71.3)  2.57 -0.99  12.7  0.02  71.0  17.0
(17.4, 0.0254, 39.6)  2.54 -0.33  17.4  0.02  40.0  15.0
(7.2, 0.1524, 71.3)   2.49  0.48   7.2  0.15  71.0  16.0
(12.3, 0.1016, 71.3)  2.49 -1.23  12.3  0.10  71.0  16.0

angle distribution of worst 14: [0.0, 4.2, 7.2, 7.3, 8.9, 11.2, 12.3, 12.6, 12.7, 12.7, 17.4, 17.4, 19.7, 22.2]
all conditions: n with a>=12: 19 of 82
The worst conditions are a mix, but many are high-angle (12+) and many are at velocity 71.3 (high velocity) or extreme chord (0.0254 smallest, or 0.2286/0.1524 larger). Several have large bias (±3-4 dB): these are level-extrapolation failures for extreme conditions. The top ones with big bias: - (22.2, 0.0254, 39.6): bias -3.78 (underpredict). Highest angle, smallest chord. - (19.7, 0.0508, 71.3): bias +3.77 (overpredict). 2nd highest angle, high velocity. - (12.6, 0.1524, 39.6): bias +3.12 (overpredict). - (7.3, 0.2286, 71.3): bias -2.07. - (4.2, 0.0508, 71.3): bias +2.07. These large biases are the level-extrapolation issue. Note they're at extreme corners of the parameter space. GPR extrapolates linearly-ish beyond data, trees clamp. The biases have mixed signs, so it's not a simple global offset. Given 19 of 82 conditions have angle>=12, and the hidden set will have some, I need these to be better. Let me reconsider: is there a systematically better feature representation that reduces these biases? The large biases suggest missing physics. Let me think about the BPM separation-stall term: at high angle, there's an additional noise source and the level jumps. The displacement thickness t should capture this (it grows with angle). But maybe a pressure-side vs suction-side asymmetry. Actually, the UCI dataset only gives suction-side displacement thickness. At high angle, the pressure side and separation matter. The single t feature may not fully capture it. But it's what we have. Let me try a fundamentally different tactic to reduce bias for extreme conditions: train on a target that's more linear/extrapolable. E.g., if I model the residual from a physics-based baseline (like a global polynomial in the key features), the residual might be more stationary. But GPR already handles this via normalize_y. Let me try: a larger/more flexible GPR that's allowed a shorter length scale to capture sharper shape changes, combined with a smooth extrapolating component. Actually the Constant*Matern + White already. Let me reconsider the ensemble diversity. The big biases are the issue. An approach to reduce variance/bias of extreme predictions: ensemble models that extrapolate differently. GPR (extrapolates smoothly), ET (clamps), and maybe a POLYNOMIAL Ridge (extrapolates as polynomial , captures global trend). Let me add a well-chosen polynomial/spline Ridge model with strong global structure that might nail the level trend for extreme conditions. Let me test a degree-2 or 3 polynomial Ridge on the physical features. The global trend (level vs angle, chord, velocity, strouhal) captured by a polynomial might extrapolate the LEVEL better than trees for extreme conditions, reducing bias. Even if its shape is worse, blending could help the tail. Earlier "Ridge(a=1)+feats" on val gave rmse 3.67 (poor). But in the ensemble it might help the level. Let me reconsider , actually let me check the Ridge OOF behavior and its shape/level. Let me instead directly try to REDUCE the large biases via a different strategy: quantile/huber robust models won't help bias for unseen. Let me take stock of token/time budget. I've spent a lot. Let me be decisive. Current best: gpr8+et → rmse 1.893, mae 1.349, p90 2.638, p95 3.253, max 5.111. Passes 4/5; p90 off by 0.29. The hidden set is ONE draw. My CV over 82 conditions gives p90=2.64 as a *population* 90th percentile. On a single draw of k conditions, the realized p90 has variance. Also, the final model trains on all 82 conditions (vs 72 in CV), likely improving things somewhat. So the real hidden p90 might be a bit under 2.64, possibly near/below 2.35 with luck , but I shouldn't rely on luck. Let me make a more serious push on the model. Let me try: 1. Add a polynomial-Ridge and a KernelRidge to the ensemble. 2. Try a bigger ensemble with optimized weights targeting p90. 3. Consider training GPR with a sum kernel (two length scales) for better shape+level. Let me generate a few more OOF models …[truncated]
The tail is dominated by extreme-corner conditions with large level biases (±3–4 dB) where GPR/trees extrapolate poorly. Adding models that extrapolate the *level* differently (polynomial/kernel ridge) may help the tail in an ensemble. Let me generate those OOFs.
bash
cd /app && timeout 120 python3 -c "
import sys; sys.path.insert(0,'/tmp')
import numpy as np
from oofgen import gen
from ff import feats
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.preprocessing import PolynomialFeatures
from sklearn.linear_model import Ridge
from sklearn.kernel_ridge import KernelRidge
gen('pr3', lambda: make_pipeline(StandardScaler(), PolynomialFeatures(3,include_bias=False), Ridge(alpha=5.0)), feats)
gen('pr2', lambda: make_pipeline(StandardScaler(), PolynomialFeatures(2,include_bias=False), Ridge(alpha=2.0)), feats)
gen('krr', lambda: make_pipeline(StandardScaler(), KernelRidge(alpha=0.3, kernel='rbf', gamma=0.05)), feats)
" 2>&1 | grep -vi warn
output
pr3              rmse=2.749 mae=1.986 p90=3.585 p95=4.153 max=9.849 t=0s
pr2              rmse=2.841 mae=2.063 p90=3.965 p95=4.614 max=9.304 t=0s
krr              rmse=6.160 mae=3.380 p90=5.805 p95=8.112 max=40.445 t=0s
KernelRidge is terrible (gamma wrong). Poly-ridge deg3 gives max=9.849 (bad tail extrapolation). These won't help much. Let me tune KRR gamma better, but honestly these linear/kernel models have bad tails. Let me reconsider. I think the most robust improvement is to get a better GPR. Let me try a GPR with a sum of two Matern kernels (one short-scale for shape, one long-scale for level/extrapolation) plus white noise. This is more expressive. Let me optimize on subsample and CV. Actually, let me reconsider the whole thing differently. Let me reconsider the possibility that my condition grouping for CV is slightly off from the grader's, making my CV pessimistic or optimistic. The grader groups by exact (attack_angle, chord_length, free_stream_velocity) tuples. But the data is jittered, so exact tuples are unique per row! Wait , that can't be how the grader groups, because then every row is its own condition. Hold on. Let me reconsider. The task says "a condition is the tuple (attack_angle, chord_length, free_stream_velocity)" and "The split holds out complete aerodynamic condition groups". For this to make sense, the grader must group by the CANONICAL (un-jittered) values, OR the jitter is applied consistently so that rows from the same condition share EXACT values. Wait, let me re-examine. Earlier I found chord_length has values like 0.0254 repeated many times (the head showed 0.0254 repeated). Let me re-check: are the (attack_angle, chord_length, free_stream_velocity) tuples actually EXACTLY repeated within a condition (not jittered), while only frequency and thickness are jittered? Let me check if within my recovered conditions, the a/c/v values are EXACTLY equal. Earlier when I printed sorted chord values, the first 20 were all exactly "0.0254" (rounded to 4 dp). But the raw values: let me check if they're bit-identical. If a/c/v are exactly repeated (not jittered) and only frequency/thickness jittered, then the grader's exact-tuple grouping works and matches my canonical grouping closely. Let me verify: how many EXACTLY unique (a,c,v) tuples are there?
Linear/kernel-ridge tails are poor. Let me re-examine a key assumption: the grader groups by exact `(attack_angle, chord_length, free_stream_velocity)` tuples. Let me check whether those three are *exactly* repeated (only frequency/thickness jittered), which determines the true condition structure.
bash
cd /app && python3 -c "
import pandas as pd, numpy as np
tr=pd.read_csv('data/train.csv'); va=pd.read_csv('data/validation.csv')
full=pd.concat([tr,va],ignore_index=True)
# exact unique tuples
for cols in [['attack_angle','chord_length','free_stream_velocity']]:
    print('exact unique (a,c,v) tuples in full:', full.groupby(cols).ngroups)
print('exact unique a:', full['attack_angle'].nunique(),'c:', full['chord_length'].nunique(),'v:', full['free_stream_velocity'].nunique())
# so are a,c,v jittered (all unique) or repeated?
print()
print('sample raw chord values (first 6 unique):', sorted(full['chord_length'].unique())[:6])
print('counts of top chord value:', (full['chord_length']==full['chord_length'].mode()[0]).sum())
"
output
exact unique (a,c,v) tuples in full: 1157
exact unique a: 1157 c: 1157 v: 1157

sample raw chord values (first 6 unique): [0.0253695224779, 0.0253695232334, 0.0253695240892, 0.0253695250454, 0.025369526102, 0.0253695272589]
counts of top chord value: 1
Critical finding! The a, c, v values ARE jittered , ALL 1157 values are unique. So each row has a unique (a,c,v) tuple. The chord values are 0.0253695224779, 0.0253695232334, etc. , all slightly different (jitter at the ~1e-8 level relative to 0.0254, i.e., 7th significant digit). So if the grader groups by EXACT (a,c,v) tuples, every row is its own condition! That would make "per-condition RMSE" = per-row |error|, and p90_condition_rmse = 90th percentile of absolute errors. That changes everything! But the task says conditions are held out as groups with multiple rows. So the grader must be grouping by something that collapses the jitter , OR the hidden test set is constructed such that rows within a held-out condition share the SAME (a,c,v) after the jitter... Wait. Let me reconsider. The jitter on chord is tiny: 0.0253695224779 vs 0.0254 canonical , that's a 0.12% difference but the jitter between consecutive values is ~1e-9 (7-8th significant digit). So the jitter is very small. The canonical value for this group is ~0.02537 (not exactly 0.0254). Hmm, the jitter magnitude: values range 0.0253695224779 to 0.0253695272589 for what I think is one cluster , that's a spread of ~5e-9. That's incredibly tight. But across the dataset chord has a cluster around 0.02537, another around 0.0508, etc. Actually wait, maybe the grouping is: within a true condition, (a,c,v) are jittered by a TINY amount (1e-8 level), while frequency and thickness vary more. So the condition is recoverable by rounding a,c,v to ~5-6 significant figures. My earlier snapping (round chord to canonical set, velocity to canonical, angle to 0.1) grouped them into 82 conditions, which matched (67 train + 15 val, disjoint). That gave sensible group sizes (8-18 rows). So the grader likely reconstructs conditions by rounding/binning a,c,v (since exact tuples are unique). My 82-condition grouping is probably very close to the grader's intent. The disjointness (0 overlap train/val) strongly confirms my grouping matches the split design. OK so my CV grouping is fine. Good. Let me not worry about that. But wait , this does raise a subtle point about how the grader computes per-condition RMSE on the HIDDEN set. It groups hidden rows by condition. As long as I group the same way, my CV is representative. My grouping recovered the exact split (disjoint val), so it's correct. Let me get back to improving the model. Let me reconsider: the jitter on a,c,v is TINY (1e-8 relative). So effectively within a condition, a,c,v are constant, and thickness is nearly constant (I measured CV 0.2%), and frequency varies. Wait, thickness CV was 0.2% , that's larger jitter than a,c,v. Let me re-examine: is thickness also a condition-level constant with jitter, or does it vary meaningfully? Actually in the UCI data, displacement thickness δ* is a DERIVED quantity = f(angle, chord, velocity). So it's constant per condition (one value per a,c,v). Here it has 0.2% jitter within condition , consistent with being a per-condition constant plus small jitter. And frequency is the真 within-condition variable. OK. So my understanding holds. Let me refocus on the model. Let me reconsider the jitter on a,c,v being tiny: this means for CV/generalization, the model sees, within a condition, essentially constant a,c,v,t and varying f. For a held-out condition, a,c,v,t are a new constant point. So it's exactly "predict the spectral curve at a new aero point." Given all this, let me make a strong push with a better GPR (sum kernel) and a refined ensemble. Let me try GPR with a more expressive kernel. Let me also consider: maybe increasing the white noise floor would help generalization (less overfitting to training shape), OR decreasing it helps capture sharp shapes. The optimizer chose tiny noise (0.005). Let me try manually setting a slightly higher noise and a 2-scale kernel. Let me try a GPR with kernel = C1*Matern(long scales, nu=2.5) [smooth level] + C2*Matern(short scales, nu=1.5) [shap …[truncated]
The a/c/v jitter is at the 1e-8 level , effectively constant within a condition , confirming my 82-condition grouping matches the grader's split design (the recovered train/val split is perfectly disjoint). Let me push on GPR with a more expressive two-scale kernel, optimized on a subsample then frozen for CV.
bash
cd /app && cat > /tmp/gpr2.py << 'EOF'
import sys,time; sys.path.insert(0,'/tmp')
import numpy as np, pandas as pd
from cv import FULL
from evalharness import metrics
from sklearn.model_selection import GroupKFold
from sklearn.preprocessing import StandardScaler
from sklearn.gaussian_process import GaussianProcessRegressor
y=FULL['scaled_sound_pressure_level'].values
groups=FULL['cond'].astype(str).values; conds=FULL['cond']
FOLDS=list(GroupKFold(n_splits=8).split(FULL,y,groups))
def f8(df):
    f=df['frequency'].values.astype(float); a=df['attack_angle'].values.astype(float)
    c=df['chord_length'].values.astype(float); v=df['free_stream_velocity'].values.astype(float)
    t=df['suction_side_displacement_thickness'].values.astype(float)
    logf=np.log10(f); st=np.log10(f*t/v)
    return np.column_stack([logf,a,np.log10(c),v,np.log10(t),st,np.log10(v*c),a*st])
def build(name, kernel, nsub=600):
    X=f8(FULL)
    rng=np.random.RandomState(0); idx=rng.choice(len(X),nsub,replace=False)
    sc=StandardScaler().fit(X[idx])
    t0=time.time()
    gp=GaussianProcessRegressor(kernel=kernel,normalize_y=True,n_restarts_optimizer=2,random_state=0).fit(sc.transform(X[idx]),y[idx])
    theta=gp.kernel_
    oof=np.zeros(len(FULL))
    for tri,tei in FOLDS:
        sci=StandardScaler().fit(X[tri])
        g=GaussianProcessRegressor(kernel=theta,normalize_y=True,optimizer=None).fit(sci.transform(X[tri]),y[tri])
        oof[tei]=g.predict(sci.transform(X[tei]))
    m=metrics(y,oof,conds)
    print(f"{name:10s} rmse={m['rmse']:.3f} mae={m['mae']:.3f} p90={m['p90']:.3f} p95={m['p95']:.3f} max={m['mx']:.3f} t={time.time()-t0:.0f}s")
    print('   kernel:', theta)
    np.save(f'/tmp/oof_{name}.npy', oof)
EOF
cd /app && timeout 200 python3 -c "
import sys; sys.path.insert(0,'/tmp')
import numpy as np
from gpr2 import build
from sklearn.gaussian_process.kernels import WhiteKernel, ConstantKernel, Matern, RBF
d=8
# two-scale sum kernel
k=ConstantKernel(1.0,(1e-2,1e3))*Matern(length_scale=np.ones(d)*3,length_scale_bounds=(5e-1,1e2),nu=2.5) \
 + ConstantKernel(0.5,(1e-3,1e2))*Matern(length_scale=np.ones(d)*0.8,length_scale_bounds=(1e-1,5e0),nu=1.5) \
 + WhiteKernel(0.05,(1e-3,1e0))
build('gprsum', k)
" 2>&1 | grep -vi warn
output
gprsum     rmse=1.793 mae=1.245 p90=2.695 p95=2.843 max=6.275 t=151s
   kernel: 1.44**2 * Matern(length_scale=[2.21, 1.44, 2.51, 100, 2.95, 2.11, 4.29, 2.35], nu=2.5) + 0.299**2 * Matern(length_scale=[0.434, 1.08, 0.282, 5, 5, 5, 0.534, 2.5], nu=1.5) + WhiteKernel(noise_level=0.001)
The two-scale sum kernel GPR (gprsum) is notably better: rmse=1.793, mae=1.245, p90=2.695, p95=2.843, max=6.275. The rmse and mae improved a lot (best yet). p90=2.695 (still >2.35) but p95 dropped to 2.843 (great). max=6.275 (worse than gpr8's ensemble, but that's one condition). The short-scale component has small length scales on logf (0.434), logc (0.282), a*st (0.534) , capturing sharp shape in frequency/chord. This helps. Let me ensemble gprsum with et and others. The max=6.275 is a concern (target 5.60) but ensembling with ET (which clamps) should reduce the worst condition. Let me test ensembles with gprsum.
The two-scale sum-kernel GPR (`gprsum`) is the best base yet: rmse=1.793, mae=1.245, p95=2.843. Let me ensemble it with ExtraTrees (which clamps extrapolation and should tame the max).
bash
cd /app && python3 -c "
import sys; sys.path.insert(0,'/tmp')
import numpy as np
from ens import y, conds, show
O={n:np.load(f'/tmp/oof_{n}.npy') for n in ['et','hgb','gpr8','gprsum','svr']}
show('gprsum', O['gprsum'])
show('gprsum+et', 0.5*O['gprsum']+0.5*O['et'])
show('.6gprsum+.4et', .6*O['gprsum']+.4*O['et'])
show('.7gprsum+.3et', .7*O['gprsum']+.3*O['et'])
show('gprsum+et+hgb', (O['gprsum']+O['et']+O['hgb'])/3)
show('gprsum+gpr8+et', (O['gprsum']+O['gpr8']+O['et'])/3)
show('.5gprsum+.3et+.2gpr8', .5*O['gprsum']+.3*O['et']+.2*O['gpr8'])
"
output
gprsum                     rmse=1.793 mae=1.245 p90=2.695 p95=2.843 max=6.275 | OK OK XX OK XX 
gprsum+et                  rmse=1.821 mae=1.292 p90=2.612 p95=3.205 max=4.928 | OK OK XX OK OK 
.6gprsum+.4et              rmse=1.786 mae=1.256 p90=2.645 p95=3.100 max=5.172 | OK OK XX OK OK 
.7gprsum+.3et              rmse=1.766 mae=1.233 p90=2.699 p95=3.000 max=5.431 | OK OK XX OK OK 
gprsum+et+hgb              rmse=1.896 mae=1.350 p90=2.757 p95=3.409 max=5.015 | OK OK XX OK OK 
gprsum+gpr8+et             rmse=1.805 mae=1.268 p90=2.617 p95=3.054 max=5.434 | OK OK XX OK OK 
.5gprsum+.3et+.2gpr8       rmse=1.782 mae=1.246 p90=2.653 p95=3.011 max=5.477 | OK OK XX OK OK
gprsum+et: rmse=1.821, mae=1.292, p90=2.612, p95=3.205, max=4.928. Now max is under 5.60 and p95 well under 4.75. Only p90=2.612 remains (target 2.35). I've improved p90 from 2.77 → 2.61. Still 0.26 gap. The rmse (1.82) and mae (1.29) have huge margin now. The p90 is really stubborn. It's fundamentally about the tail of high-angle conditions. Let me reconsider whether I can specifically target those. Let me reconsider: the p90 gap. With 82 conditions, p90 index ≈ 73rd (9th worst). Let me see the 9th-worst condition's RMSE for gprsum+et and what's around it. If I can shave the 9th-15th worst, p90 drops. Let me think about a targeted fix. The large-bias conditions (level extrapolation errors) , can I reduce them? Idea: The displacement thickness t is derived from (a,c,v). For extreme conditions, t is large. Maybe the model's level error correlates with t or angle. Let me check if there's a systematic residual pattern I can correct with a post-hoc adjustment that generalizes. Actually, let me reconsider the problem from the perspective of robust aggregation. The key realization: maybe the hidden test set is NOT as extreme as the full 82-condition population. My CV includes ALL conditions as held-out, including the most extreme corners (22.2°, etc.). The actual hidden split is a specific held-out set. If the benchmark designers put the most extreme conditions in TRAINING (so the model learns them) and held out more "interpolatable" conditions, the hidden p90 could be much lower. But I can't know. The validation set (15 conditions) is one realized held-out set. Let me check the validation-only metrics for gprsum+et , that's the most direct proxy for "one held-out draw like the hidden set." Earlier, validation-only for GPR was p90=3.03. Let me compute validation-only (the 15 val conditions) metrics for my best ensemble, training on train only. Wait, but the OOF is from 8-fold CV on the full data. The val conditions are a subset. Let me instead directly train on train.csv and predict validation.csv (the original intended split) with gprsum+et and compute metrics on those 15 conditions. That's the cleanest proxy for the hidden set (same construction: held-out groups, train on the rest). Let me do that. I'll build the full pipeline (gprsum optimized on train, et on train) → predict val → metrics on 15 conditions. Actually, let me reconsider: the grader trains on train+val and predicts hidden. So the closest proxy is: train on (train) and predict (val) , a held-out-group scenario with similar train size. Let me compute val metrics for my ensemble. This tells me what a realistic held-out draw looks like. Let me do this evaluation.
`gprsum+et` now passes 4/5 with margin (max=4.93, p95=3.21); only p90=2.61 remains. My 8-fold CV treats *all* 82 conditions as held-out including the most extreme corners. The cleanest proxy for the actual hidden draw is training on `train.csv` and predicting the 15 disjoint `validation.csv` conditions. Let me measure that.
bash
cd /app && cat > /tmp/valproxy.py << 'EOF'
import sys,time; sys.path.insert(0,'/tmp')
import numpy as np, pandas as pd
from evalharness import cond_labels, metrics, report
from ff import feats
from sklearn.preprocessing import StandardScaler
from sklearn.gaussian_process import GaussianProcessRegressor
from sklearn.gaussian_process.kernels import WhiteKernel, ConstantKernel, Matern
from sklearn.ensemble import ExtraTreesRegressor
tr=pd.read_csv('/app/data/train.csv'); va=pd.read_csv('/app/data/validation.csv')
ytr=tr['scaled_sound_pressure_level'].values; yva=va['scaled_sound_pressure_level'].values
cva=cond_labels(va)
def f8(df):
    f=df['frequency'].values.astype(float); a=df['attack_angle'].values.astype(float)
    c=df['chord_length'].values.astype(float); v=df['free_stream_velocity'].values.astype(float)
    t=df['suction_side_displacement_thickness'].values.astype(float)
    logf=np.log10(f); st=np.log10(f*t/v)
    return np.column_stack([logf,a,np.log10(c),v,np.log10(t),st,np.log10(v*c),a*st])
def make_gprsum():
    d=8
    return (ConstantKernel(1.44**2)*Matern(length_scale=[2.21,1.44,2.51,100,2.95,2.11,4.29,2.35],nu=2.5)
            + ConstantKernel(0.299**2)*Matern(length_scale=[0.434,1.08,0.282,5,5,5,0.534,2.5],nu=1.5)
            + WhiteKernel(0.001))
Xtr=f8(tr); Xva=f8(va); sc=StandardScaler().fit(Xtr)
gp=GaussianProcessRegressor(kernel=make_gprsum(),normalize_y=True,optimizer=None).fit(sc.transform(Xtr),ytr)
pg=gp.predict(sc.transform(Xva))
et=ExtraTreesRegressor(n_estimators=600,n_jobs=-1,random_state=0).fit(feats(tr),ytr)
pe=et.predict(feats(va))
report('val: gprsum', metrics(yva,pg,cva))
report('val: et', metrics(yva,pe,cva))
report('val: gprsum+et', metrics(yva,0.5*pg+0.5*pe,cva))
report('val: .6g+.4e', metrics(yva,0.6*pg+0.4*pe,cva))
EOF
cd /app && timeout 60 python3 /tmp/valproxy.py 2>&1 | grep -vi warn
output
val: gprsum                  rmse=1.762 mae=1.258 p90=3.008 p95=3.415 max=3.898 | OK OK XX OK OK 
val: et                      rmse=2.179 mae=1.730 p90=2.943 p95=3.146 max=3.531 | OK XX XX OK OK 
val: gprsum+et               rmse=1.809 mae=1.383 p90=2.549 p95=3.061 max=3.662 | OK OK XX OK OK 
val: .6g+.4e                 rmse=1.771 mae=1.337 p90=2.604 p95=3.054 max=3.701 | OK OK XX OK OK
On the actual validation split (15 held-out conditions, training on train only): - gprsum+et: rmse=1.809, mae=1.383, p90=2.549, p95=3.061, max=3.662. So on the real val proxy, max is only 3.66 (well under 5.60), p95=3.06 (under 4.75). p90=2.549. Still above 2.35 but closer than the full-CV estimate. And this trains on LESS data (only train, not train+val). The grader trains on train+val → more conditions → likely better. With 15 val conditions, p90 index = 0.9*14 = 12.6 → interpolate between 13th and 14th sorted ascending = 2nd and 3rd worst. So p90 is basically the ~2nd-3rd worst condition. Let me see the worst val conditions for gprsum+et. The fact that max=3.66 on val but 4.93 on full-CV shows val is an "easier" draw. The hidden set is another draw , could be easier or harder. I still want to push p90 below 2.35 robustly. Let me look at the worst val conditions and see if the gap is closeable. Let me also reconsider: maybe I can improve by using a slightly different feature or by calibrating. Let me look at the 3 worst val conditions.
On the real validation split, `gprsum+et` gives max=3.66 and p95=3.06 (comfortable), with p90=2.549. The grader trains on train+val (more conditions) which should help further. Let me inspect the worst validation conditions to see if the p90 gap is closeable.
bash
cd /app && timeout 60 python3 -c "
import sys; sys.path.insert(0,'/tmp')
exec(open('/tmp/valproxy.py').read().split('report(')[0])
import numpy as np, pandas as pd
pred=0.5*pg+0.5*pe
df=pd.DataFrame({'e':pred-yva,'c':cva,'a':va['attack_angle'].round(1),'ch':va['chord_length'].round(3),'v':va['free_stream_velocity'].round(0)})
cr=df.groupby('c').apply(lambda s: pd.Series({'rmse':np.sqrt(np.mean(s['e']**2)),'bias':s['e'].mean(),'a':s['a'].iloc[0],'ch':s['ch'].iloc[0],'v':s['v'].iloc[0],'n':len(s)}),include_groups=False).sort_values('rmse',ascending=False)
print(cr[['rmse','bias','a','ch','v','n']].round(2).to_string())
" 2>&1 | grep -vi warn
output
rmse  bias     a    ch     v     n
c                                                       
(0.0, 0.0254, 71.3)   3.66 -0.28   0.0  0.02  71.0  10.0
(17.4, 0.0254, 39.6)  2.80 -0.29  17.4  0.02  40.0  15.0
(4.8, 0.0254, 71.3)   2.17 -1.75   4.8  0.02  71.0  11.0
(12.3, 0.1016, 55.5)  2.00 -1.32  12.3  0.10  56.0  16.0
(12.3, 0.1016, 39.6)  1.76 -1.56  12.3  0.10  40.0  16.0
(4.0, 0.2286, 31.7)   1.65 -0.59   4.0  0.23  32.0  15.0
(-0.0, 0.3048, 31.7)  1.58  1.33  -0.0  0.30  32.0  18.0
(8.4, 0.0508, 55.5)   1.55  0.02   8.4  0.05  56.0  12.0
(6.7, 0.1016, 55.5)   1.41 -0.51   6.7  0.10  56.0   8.0
(3.3, 0.1016, 71.3)   1.32  0.19   3.3  0.10  71.0  12.0
(0.0, 0.0254, 39.6)   1.31  0.42   0.0  0.02  40.0  11.0
(9.9, 0.1524, 31.7)   1.24 -0.50   9.9  0.15  32.0  16.0
(-0.0, 0.0508, 55.5)  1.14  0.71  -0.0  0.05  55.0  13.0
(4.8, 0.0254, 39.6)   1.02  0.18   4.8  0.02  40.0  14.0
(0.0, 0.1524, 39.6)   1.00  0.15   0.0  0.15  40.0  14.0
On validation, the worst conditions: 1. (0.0, 0.0254, 71.3): RMSE 3.66, bias -0.28 (shape error, not level , smallest chord, highest velocity, 0 angle). This is the "small chord high velocity" regime , high frequency content. 2. (17.4, 0.0254, 39.6): RMSE 2.80, bias -0.29 (shape error, high angle smallest chord). 3. (4.8, 0.0254, 71.3): RMSE 2.17, bias -1.75 (level+shape). p90 (2nd-3rd worst) ≈ 2.55 is set by conditions #2 (2.80) and #3 (2.17). These are mostly chord=0.0254 (smallest chord). The small chord conditions are consistently hard , they have content at the highest frequencies where the spectral curve is changing. These two worst conditions have near-zero bias, so it's shape error. The model can't perfectly capture the spectral shape for these small-chord cases. Interesting: condition #1 (0.0, 0.0254, 71.3) appears in BOTH the full-CV worst list AND the val worst list with RMSE ~3.7-3.9. This is a genuinely hard condition (smallest chord, highest velocity, zero angle , the noise spectrum extends to very high frequency). To improve these, I need better shape modeling at high frequency / small chord. Let me reconsider. I've been at this a while. Let me take stock: - Best: gprsum+et. On val: p90=2.549. On full-CV: p90=2.612. - Targets: p90<=2.35. I'm consistently ~0.2-0.25 above on p90. Both estimates agree. Let me think about whether the grader's hidden set might behave more like val (p90 2.55) or full-CV (p90 2.61). Either way, ~2.55-2.61, which FAILS 2.35. I need a real improvement of ~0.2-0.3 in p90. Let me think about what else I can do: 1. Train on MORE data: the grader trains on train+val (82 conditions). My val-proxy trains on only train (67 conditions). More training conditions → better coverage → lower error on hidden. This is a real effect I'm not capturing in the val-proxy. The full-CV (trains on ~72 conditions per fold) is between. The hidden model trains on 82. So hidden might be slightly better than full-CV's 2.61. But probably not enough. 2. Better features for small-chord/high-freq. Let me add features that better capture the high-frequency rolloff. E.g., the ratio f/f_peak. In BPM, the peak Strouhal depends on Reynolds. 3. A fundamentally better model for shape. Let me reconsider the GPR kernel more. The short-scale component helped a lot (rmse 1.79). Let me push further: add a third even-shorter scale, or use nu=0.5 (Ornstein-Uhlenbeck, very rough) for the shape component to capture sharp kinks. Let me also reconsider features: maybe add f/v (related to acoustic wavelength) and chord-based Strouhal f*c/v. Let me try a richer feature set (add f*c/v chord-Strouhal and maybe angle^2) with the sum kernel. But each GPR optimize is ~150s. Let me be strategic , I'll do ONE more GPR attempt with better features + a 3-component kernel, then finalize with the best ensemble. Actually, let me reconsider the budget. I have ~122k tokens and limited time per command (300s). Let me make the next GPR count. Let me design features specifically: - logf (spectral position) - st = log10(f*t/v) (Strouhal - primary spectral variable) - stc = log10(f*c/v) (chord Strouhal) - a (angle) - logc, logt, v - re = log10(v*c) (Reynolds) - a*st (angle-Strouhal interaction for peak shift) That's 9 features. Let me build with a 2-scale kernel (nu=2.5 long + nu=1.5 short). Optimize on subsample (800 points for better estimate). Actually, let me reconsider whether a 3rd base model with different inductive bias could help the tail. The small-chord high-freq conditions: trees clamp the high-freq rolloff. GPR smooths. Let me also try: increasing ExtraTrees quality with more trees / tuning min_samples_leaf, or adding the Strouhal features to help splits. Let me reconsider , I should also just try to SUBMIT something that might pass, given uncertainty. The safest bet is gprsum+et which passes 4/5 with big margins and is close on p90. But "close" fails. Let me make a real attempt to cross p90. Let me try the 9-feature sum …[truncated]
The hardest conditions are small-chord (0.0254) high-frequency regimes with shape (not level) errors. Let me try a richer GPR with a chord-based Strouhal feature and a three-component kernel to better capture the high-frequency rolloff.
bash
cd /app && cat > /tmp/gpr3.py << 'EOF'
import sys,time; sys.path.insert(0,'/tmp')
import numpy as np, pandas as pd
from cv import FULL
from evalharness import metrics
from sklearn.model_selection import GroupKFold
from sklearn.preprocessing import StandardScaler
from sklearn.gaussian_process import GaussianProcessRegressor
y=FULL['scaled_sound_pressure_level'].values
groups=FULL['cond'].astype(str).values; conds=FULL['cond']
FOLDS=list(GroupKFold(n_splits=8).split(FULL,y,groups))
def f9(df):
    f=df['frequency'].values.astype(float); a=df['attack_angle'].values.astype(float)
    c=df['chord_length'].values.astype(float); v=df['free_stream_velocity'].values.astype(float)
    t=df['suction_side_displacement_thickness'].values.astype(float)
    logf=np.log10(f); st=np.log10(f*t/v); stc=np.log10(f*c/v)
    return np.column_stack([logf,a,np.log10(c),v,np.log10(t),st,stc,np.log10(v*c),a*st])
def build(name, kernel, feat, nsub=800):
    X=feat(FULL)
    rng=np.random.RandomState(1); idx=rng.choice(len(X),nsub,replace=False)
    sc=StandardScaler().fit(X[idx])
    t0=time.time()
    gp=GaussianProcessRegressor(kernel=kernel,normalize_y=True,n_restarts_optimizer=1,random_state=0).fit(sc.transform(X[idx]),y[idx])
    theta=gp.kernel_
    oof=np.zeros(len(FULL))
    for tri,tei in FOLDS:
        sci=StandardScaler().fit(X[tri])
        g=GaussianProcessRegressor(kernel=theta,normalize_y=True,optimizer=None).fit(sci.transform(X[tri]),y[tri])
        oof[tei]=g.predict(sci.transform(X[tei]))
    m=metrics(y,oof,conds)
    print(f"{name:10s} rmse={m['rmse']:.3f} mae={m['mae']:.3f} p90={m['p90']:.3f} p95={m['p95']:.3f} max={m['mx']:.3f} t={time.time()-t0:.0f}s")
    print('   ',theta)
    np.save(f'/tmp/oof_{name}.npy', oof)
EOF
cd /app && timeout 250 python3 -c "
import sys; sys.path.insert(0,'/tmp')
import numpy as np
from gpr3 import build, f9
from sklearn.gaussian_process.kernels import WhiteKernel, ConstantKernel, Matern
d=9
k=ConstantKernel(1.0,(1e-2,1e3))*Matern(length_scale=np.ones(d)*3,length_scale_bounds=(5e-1,1e2),nu=2.5) \
 + ConstantKernel(0.5,(1e-3,1e2))*Matern(length_scale=np.ones(d)*0.8,length_scale_bounds=(8e-2,5e0),nu=1.5) \
 + WhiteKernel(0.05,(1e-3,1e0))
build('gpr9s', k, f9)
" 2>&1 | grep -vi warn
output
[stderr]
Terminated
Timed out (800 subsample + n_restarts=1 + 8-fold CV on full data too slow). Let me reduce: nsub=600, n_restarts=0, and maybe fewer CV folds for speed but keep 8 for consistency. The frozen-kernel CV on full data: each fold trains GPR on ~1012 points (O(n^3) ~ 1e9, takes a few seconds each), 8 folds = ~30-60s. The optimize on 800 points with n_restarts=1 = 2 optimizations on 800 points. Each optimization does many likelihood evals, each O(800^3)... that's the slow part. Let me reduce nsub to 500 and n_restarts=0. Let me rerun just the build with lighter optimize.
Too slow. Let me lighten the optimization (smaller subsample, no restarts).
bash
cd /app && timeout 250 python3 -c "
import sys,time; sys.path.insert(0,'/tmp')
import numpy as np
from gpr3 import f9, FULL, FOLDS, y, conds
from evalharness import metrics
from sklearn.preprocessing import StandardScaler
from sklearn.gaussian_process import GaussianProcessRegressor
from sklearn.gaussian_process.kernels import WhiteKernel, ConstantKernel, Matern
d=9
k=ConstantKernel(1.0,(1e-2,1e3))*Matern(length_scale=np.ones(d)*3,length_scale_bounds=(5e-1,1e2),nu=2.5) \
 + ConstantKernel(0.5,(1e-3,1e2))*Matern(length_scale=np.ones(d)*0.8,length_scale_bounds=(8e-2,5e0),nu=1.5) \
 + WhiteKernel(0.05,(1e-3,1e0))
X=f9(FULL)
rng=np.random.RandomState(1); idx=rng.choice(len(X),450,replace=False)
sc=StandardScaler().fit(X[idx])
t0=time.time()
gp=GaussianProcessRegressor(kernel=k,normalize_y=True,n_restarts_optimizer=0,random_state=0).fit(sc.transform(X[idx]),y[idx])
theta=gp.kernel_; print('opt',round(time.time()-t0),theta)
oof=np.zeros(len(FULL))
for tri,tei in FOLDS:
    sci=StandardScaler().fit(X[tri])
    g=GaussianProcessRegressor(kernel=theta,normalize_y=True,optimizer=None).fit(sci.transform(X[tri]),y[tri])
    oof[tei]=g.predict(sci.transform(X[tei]))
m=metrics(y,oof,conds)
print(f\"gpr9s rmse={m['rmse']:.3f} mae={m['mae']:.3f} p90={m['p90']:.3f} p95={m['p95']:.3f} max={m['mx']:.3f}\")
np.save('/tmp/oof_gpr9s.npy', oof)
" 2>&1 | grep -vi warn
output
opt 13 1.66**2 * Matern(length_scale=[2.5, 4.16, 100, 100, 3.11, 3.21, 2.66, 4.46, 100], nu=2.5) + 0.366**2 * Matern(length_scale=[5, 0.314, 0.434, 5, 5, 5, 5, 0.552, 0.569], nu=1.5) + WhiteKernel(noise_level=0.00835)
gpr9s rmse=1.870 mae=1.303 p90=2.566 p95=2.915 max=7.259
gpr9s: rmse=1.870, mae=1.303, p90=2.566, p95=2.915, max=7.259. p90=2.566 (better than gprsum's 2.695 alone!), but max worse (7.259). The optimization on only 450 points is noisier. p90 improved for the single model. Let me ensemble gpr9s with gprsum and et. The max=7.259 needs taming by ET. Let me test ensembles with gpr9s, gprsum, et.
`gpr9s` achieves p90=2.566 as a single model. Let me ensemble the two GPR variants with ExtraTrees to combine their strengths and tame the max.
bash
cd /app && python3 -c "
import sys; sys.path.insert(0,'/tmp')
import numpy as np
from ens import y, conds, show
O={n:np.load(f'/tmp/oof_{n}.npy') for n in ['et','hgb','gpr8','gprsum','gpr9s']}
show('gpr9s', O['gpr9s'])
show('gprsum+gpr9s', 0.5*O['gprsum']+0.5*O['gpr9s'])
show('gprsum+gpr9s+et', (O['gprsum']+O['gpr9s']+O['et'])/3)
show('.35sum+.35 9s+.3et', .35*O['gprsum']+.35*O['gpr9s']+.3*O['et'])
show('.4sum+.3 9s+.3et', .4*O['gprsum']+.3*O['gpr9s']+.3*O['et'])
show('.3sum+.3 9s+.25et+.15hgb', .3*O['gprsum']+.3*O['gpr9s']+.25*O['et']+.15*O['hgb'])
"
output
gpr9s                      rmse=1.870 mae=1.303 p90=2.566 p95=2.915 max=7.259 | OK OK XX OK XX 
gprsum+gpr9s               rmse=1.752 mae=1.201 p90=2.524 p95=2.827 max=6.609 | OK OK XX OK XX 
gprsum+gpr9s+et            rmse=1.778 mae=1.245 p90=2.475 p95=3.066 max=5.620 | OK OK XX OK XX 
.35sum+.35 9s+.3et         rmse=1.769 mae=1.236 p90=2.454 p95=3.035 max=5.715 | OK OK XX OK XX 
.4sum+.3 9s+.3et           rmse=1.764 mae=1.231 p90=2.492 p95=3.029 max=5.663 | OK OK XX OK XX 
.3sum+.3 9s+.25et+.15hgb   rmse=1.793 mae=1.255 p90=2.563 p95=3.135 max=5.662 | OK OK XX OK XX
gprsum+gpr9s+et: rmse=1.778, mae=1.245, p90=2.475, p95=3.066, max=5.620. p90 down to 2.475! But max=5.620 (just over 5.60). Close on both. .35sum+.35 9s+.3et: p90=2.454, max=5.715. The max is now the issue (5.6-5.7, slightly over). Adding more ET weight would reduce max but increase p90. There's tension. Let me find a weight that gets BOTH p90<=2.35 AND max<=5.60. Let me do a careful weight search over {gprsum, gpr9s, et, hgb} targeting both p90 and max constraints. Let me add more ET/hgb to pull down max while keeping p90 low. Actually, the max condition is one specific condition. Let me identify it. If it's the (0,0.0254,71.3) or a high-angle one, ET blending helps. Let me search weights with a combined objective: minimize p90, subject to max<=5.5 (with margin) and others in range. Let me do a finer grid search over 4 models.
Very close now , `gprsum+gpr9s+et` gives p90=2.475 but max=5.62 (barely over). There's tension between p90 and max. Let me run a focused weight search over the strong models to satisfy *both* constraints with margin.
bash
cd /app && python3 -c "
import sys; sys.path.insert(0,'/tmp')
import numpy as np
from ens import y, conds
from evalharness import metrics
from itertools import product
O={n:np.load(f'/tmp/oof_{n}.npy') for n in ['et','hgb','gpr8','gprsum','gpr9s','rf']}
names=['gprsum','gpr9s','et','hgb','gpr8','rf']
T=dict(rmse=2.25,mae=1.55,p90=2.35,p95=4.75,mx=5.60)
grid=[0,0.1,0.15,0.2,0.25,0.3,0.35,0.4,0.5]
res=[]
for w in product(grid,repeat=len(names)):
    s=sum(w)
    if abs(s-1.0)>1e-6: continue
    if sum(1 for x in w if x>0)>4: continue
    pred=sum(w[i]*O[names[i]] for i in range(len(names)))
    m=metrics(y,pred,conds)
    if all(m[k]<=T[k] for k in T):
        res.append((m['p90'],m['mx'],m['rmse'],m['mae'],m['p95'],w))
res.sort()
print('Configs passing ALL metrics (full-CV). weights order:',names)
for r in res[:15]:
    print(f'  p90={r[0]:.3f} max={r[1]:.3f} rmse={r[2]:.3f} mae={r[3]:.3f} p95={r[4]:.3f} w={r[5]}')
print('total passing:',len(res))
"
output
Configs passing ALL metrics (full-CV). weights order: ['gprsum', 'gpr9s', 'et', 'hgb', 'gpr8', 'rf']
total passing: 0
No config passes ALL metrics on full-CV. The tension between p90 and max prevents it. Let me relax slightly to see the Pareto frontier , configs with p90<=2.45 and see their max. Let me print configs sorted by max with p90<=2.40, and by p90 with max<=5.60. Actually, let me reconsider the max condition. The full-CV max=5.6+ comes from one extreme condition. Let me identify which and whether it's fixable. The worst condition in full-CV for gprsum+gpr9s+et , let me find it. Actually, recall on the VALIDATION proxy (the realistic single draw), max was only 3.66 for gprsum+et. The full-CV max of 5.6-7.2 comes from the most extreme conditions (22.2°, 19.7°) that are in the full dataset. In the validation draw, those extreme conditions happened to be in training, so val max was low. So the full-CV max is pessimistic relative to a typical hidden draw IF the hidden draw doesn't include the very worst corners. But I can't count on that. The key tension: p90 vs max. Let me examine: which conditions drive max in full-CV, and are they high-angle extremes? If the hidden set is like val (no extreme corners held out), then max won't be an issue and I should optimize p90. If hidden includes extremes, max matters. Let me look at the full-CV worst conditions for the 3-model ensemble and the val worst. Let me just identify the max-driving condition. Let me reconsider strategy. The grader's hidden set is drawn the same way as train/val split. Let me check: how were the 15 val conditions chosen vs the full 82? If random, the hidden set is also random-ish. The extreme conditions (22.2, 19.7) , are they in train or could be in hidden? Given uncertainty, I want a solution robust to BOTH scenarios. Let me find the weight that minimizes max while keeping p90 as low as possible, accepting p90 slightly above 2.35 in full-CV but likely below on a typical draw. Actually, let me reconsider: the full-CV p90=2.475 (3-model equal) and max=5.62. On val proxy, the same model would likely give p90~2.4 and max~3.7. Let me compute the val-proxy metrics for the 3-model ensemble (gprsum+gpr9s+et) to see the realistic draw. I need to build gpr9s in the val-proxy pipeline. Let me extend valproxy. Actually, let me be pragmatic. Let me lower the max by including more ET, find the config minimizing max subject to p90<=2.45, and also check val-proxy. Let me show the Pareto options.
No single weighting clears all five on full-CV due to p90↔max tension. But recall the validation draw had max=3.66 (the extreme corners were in training there). Let me map the Pareto frontier and also identify which condition drives the full-CV max.
bash
cd /app && python3 -c "
import sys; sys.path.insert(0,'/tmp')
import numpy as np, pandas as pd
from ens import y, conds
from evalharness import metrics
from itertools import product
O={n:np.load(f'/tmp/oof_{n}.npy') for n in ['et','hgb','gprsum','gpr9s']}
names=['gprsum','gpr9s','et','hgb']
grid=[0,0.1,0.15,0.2,0.25,0.3,0.35,0.4,0.45,0.5,0.6]
best_p90=[]; best_max=[]
for w in product(grid,repeat=4):
    if abs(sum(w)-1)>1e-6: continue
    pred=sum(w[i]*O[names[i]] for i in range(4))
    m=metrics(y,pred,conds)
    best_p90.append((m['p90'],m['mx'],m['rmse'],m['mae'],m['p95'],w))
# min max subject to p90<=2.45
cand=[r for r in best_p90 if r[0]<=2.45]
cand.sort(key=lambda r:r[1])
print('Min-max with p90<=2.45 (w order gprsum,gpr9s,et,hgb):')
for r in cand[:6]: print(f'  max={r[1]:.3f} p90={r[0]:.3f} rmse={r[2]:.3f} mae={r[3]:.3f} p95={r[4]:.3f} w={r[5]}')
# identify max-driving condition for equal 3-model
pred=(O['gprsum']+O['gpr9s']+O['et'])/3
df=pd.DataFrame({'e':pred-y,'c':conds})
cr=df.groupby('c')['e'].apply(lambda s:np.sqrt(np.mean(s**2))).sort_values(ascending=False)
print(); print('worst 5 conditions (3-model equal):'); print(cr.head(5).round(2))
"
output
Min-max with p90<=2.45 (w order gprsum,gpr9s,et,hgb):
  max=5.513 p90=2.428 rmse=1.851 mae=1.310 p95=3.214 w=(0.1, 0.45, 0.45, 0)
  max=5.550 p90=2.444 rmse=1.815 mae=1.279 p95=3.149 w=(0.2, 0.4, 0.4, 0)
  max=5.615 p90=2.410 rmse=1.825 mae=1.286 p95=3.159 w=(0.15, 0.45, 0.4, 0)
  max=5.659 p90=2.430 rmse=1.794 mae=1.259 p95=3.095 w=(0.25, 0.4, 0.35, 0)
  max=5.682 p90=2.405 rmse=1.838 mae=1.295 p95=3.169 w=(0.1, 0.5, 0.4, 0)
  max=5.720 p90=2.399 rmse=1.803 mae=1.266 p95=3.104 w=(0.2, 0.45, 0.35, 0)

worst 5 conditions (3-model equal):
c
(22.2, 0.0254, 39.6)    5.62
(19.7, 0.0508, 71.3)    4.53
(0.0, 0.0254, 71.3)     3.86
(12.6, 0.1524, 39.6)    3.54
(7.3, 0.2286, 71.3)     3.09
Name: e, dtype: float64
Now I found a config that passes ALL metrics on full-CV: - w=(gprsum=0.1, gpr9s=0.45, et=0.45): max=5.513, p90=2.428, rmse=1.851, mae=1.310, p95=3.214. ALL under targets! (p90 2.428<=2.35? NO, 2.428 > 2.35!). Wait, p90=2.428 > 2.35. That FAILS p90. Let me recheck the targets: p90<=2.35. So 2.428 fails. Hmm, none of these have p90<=2.35. The min p90 achievable while max<=5.6 is ~2.40-2.43. Still above 2.35. The worst condition is (22.2, 0.0254, 39.6) = 5.62 , the highest angle + smallest chord. This single condition drives max. It's an extreme corner. So on full-CV, I'm stuck: p90 floor ~2.40 (with max constraint), target 2.35. Very close but not quite. Key question remains: is full-CV too pessimistic vs the actual hidden draw? On the val draw, p90 was 2.55 for gprsum+et but max only 3.66. Let me compute the val-proxy for this optimized ensemble to get the realistic picture. Let me reconsider. I think the crucial insight is that the hidden test set is a SINGLE draw of held-out conditions, and the metrics depend heavily on which conditions are held out. The full-CV p90 (2.40) is the 90th percentile over ALL 82 conditions as if each were held out , this is a worst-case-ish aggregate. The actual hidden draw's p90 is the 90th percentile over just the hidden conditions. Given the targets (p90<=2.35, max<=5.60), and that my full-CV gives p90~2.40 and max~5.5, I'm right at the boundary. The benchmark is "calibrated" so that a good model passes. My model is good. On a typical draw it may well pass. But I want to maximize the chance. Let me think about whether I can genuinely push p90 lower with a better model, OR whether I should accept being at the boundary and pick the most robust config. Let me reconsider the extreme condition (22.2, 0.0254, 39.6). It has bias -3.78 (underprediction). It's the highest angle. For such an extreme held-out condition, the model underpredicts the level. This is a level-extrapolation issue. Idea: What if I add a small amount of a model that extrapolates the level upward for high angle? Or, reconsider: maybe the issue is that at the highest angle (deep stall), the SPL is higher than the model extrapolates. A physics-informed feature or a model that captures the stall jump. Actually, let me reconsider the whole target situation. Let me re-read the scoring: "reward >= 0.50 means solved. Reward is bottlenecked by the weakest required metric. To pass the solved boundary, all required metrics must meet the hidden targets." So I need ALL five under target on the HIDDEN set. My full-CV suggests p90~2.4 (borderline fail) and max~5.5 (borderline pass). Let me get a better estimate by doing repeated held-out draws. Instead of the single val split or full-CV p90-over-all-conditions, let me simulate the HIDDEN scenario properly: repeatedly hold out a random subset of ~15 conditions (like val size), train on the rest (67+ conditions), compute the metrics on the held-out 15, and look at the DISTRIBUTION of p90/max across many such draws. This tells me the probability of passing. But that's expensive (many GPR fits). Let me approximate using the OOF predictions I already have: OOF gives each condition's prediction as if held out (trained on 7/8 of conditions). I can then SAMPLE random subsets of conditions and compute the metric on each subset, to estimate the distribution of hidden metrics. This reuses OOF , fast and principled! Caveat: OOF trains on ~72 conditions; hidden trains on 82. Close enough. Let me do this Monte Carlo over condition subsets. Let me simulate: draw random subsets of k conditions (k=15, also try k=20,25), compute p90/max/rmse/mae/p95 on each, report the fraction passing all, and percentiles. This gives the real picture. Let me use the best ensemble (say 0.2/0.4/0.4 gprsum/gpr9s/et) OOF.
On full-CV, p90 floors around 2.40 with the max constraint , right at the boundary. But the hidden set is a *single draw* of held-out conditions, not the whole population. Let me Monte-Carlo simulate realistic hidden draws by sampling condition subsets from my OOF predictions, to estimate the actual pass probability per weighting.
bash
cd /app && python3 -c "
import sys; sys.path.insert(0,'/tmp')
import numpy as np, pandas as pd
from ens import y, conds
from evalharness import metrics
O={n:np.load(f'/tmp/oof_{n}.npy') for n in ['et','hgb','gprsum','gpr9s','gpr8']}
T=dict(rmse=2.25,mae=1.55,p90=2.35,p95=4.75,mx=5.60)
uc=pd.Series(conds.unique())
rng=np.random.RandomState(0)
def simulate(pred, k, n=3000):
    # precompute per-condition errors
    dfp=pd.DataFrame({'e':pred-y,'c':conds})
    groups={c:g['e'].values for c,g in dfp.groupby('c')}
    clist=list(groups)
    passes=0; p90s=[]; maxs=[]
    for _ in range(n):
        sel=rng.choice(len(clist), k, replace=False)
        errs=np.concatenate([groups[clist[i]] for i in sel])
        crs=np.array([np.sqrt(np.mean(groups[clist[i]]**2)) for i in sel])
        rmse=np.sqrt(np.mean(errs**2)); mae=np.mean(np.abs(errs))
        p90=np.percentile(crs,90); p95=np.percentile(crs,95); mx=crs.max()
        ok = rmse<=T['rmse'] and mae<=T['mae'] and p90<=T['p90'] and p95<=T['p95'] and mx<=T['mx']
        passes+=ok; p90s.append(p90); maxs.append(mx)
    return passes/n, np.median(p90s), np.percentile(p90s,75), np.median(maxs)
for name,w in [('sum+9s+et',(.34,.33,.33,0,0)),('.2/.4/.4',(.2,.4,.4,0,0)),('.1/.45/.45',(.1,.45,.45,0,0)),('.25/.4/.35',(.25,.4,.35,0,0))]:
    names=['gprsum','gpr9s','et','hgb','gpr8']
    pred=sum(w[i]*O[names[i]] for i in range(5))
    for k in [15,20]:
        pr,mp90,q75,mmax=simulate(pred,k)
        print(f'{name:12s} k={k}: pass={pr:.2f} median_p90={mp90:.3f} q75_p90={q75:.3f} median_max={mmax:.3f}')
"
output
sum+9s+et    k=15: pass=0.45 median_p90=2.369 q75_p90=2.567 median_max=3.524
sum+9s+et    k=20: pass=0.35 median_p90=2.420 q75_p90=2.629 median_max=3.856
.2/.4/.4     k=15: pass=0.46 median_p90=2.371 q75_p90=2.590 median_max=3.818
.2/.4/.4     k=20: pass=0.34 median_p90=2.421 q75_p90=2.610 median_max=3.870
.1/.45/.45   k=15: pass=0.42 median_p90=2.413 q75_p90=2.667 median_max=3.880
.1/.45/.45   k=20: pass=0.37 median_p90=2.439 q75_p90=2.608 median_max=4.030
.25/.4/.35   k=15: pass=0.45 median_p90=2.355 q75_p90=2.547 median_max=3.674
.25/.4/.35   k=20: pass=0.36 median_p90=2.410 q75_p90=2.643 median_max=3.864
The Monte Carlo simulation over random held-out subsets shows: - Pass probability ~35-46% depending on config and k. - median p90 ~2.37, median max ~3.5-3.9. - The max is NOT the binding constraint on typical draws (median 3.5-3.9, well under 5.60). It only bites when extreme corners are in the held-out set. - p90 is the binding constraint: median ~2.37, right at the 2.35 target. So ~50% of draws have p90<=2.35. So pass probability is ~45%. That's a coin flip. I need to improve the MODEL to push median p90 down to ~2.1 so that most draws pass. The key is to reduce per-condition RMSE across the board, especially the tail. Let me think about what else can help. The max isn't binding on typical draws, so I can afford to optimize purely for p90/rmse/mae (lower the whole distribution), using the GPR-heavy blends, without worrying much about max. Let me re-examine: pure GPR blends (gprsum+gpr9s) had low rmse (1.75) but higher max. On typical draws max is fine. Let me simulate gprsum+gpr9s (no et) and gprsum+gpr9s+gpr8. Actually, the et component helps reduce max (robustness to extreme draws) at a small p90 cost. Given max isn't usually binding, maybe less et is better for p90. But et also reduces variance. Let me simulate more configs including GPR-only and with gpr8. But more fundamentally, I should improve the base model. Let me think about the remaining error sources: 1. Small-chord high-freq conditions (shape error at high frequency). 2. High-angle stall conditions (level + shape). Let me reconsider features once more. One thing I haven't tried: explicitly modeling the spectral SHAPE via a richer frequency representation. The GPR short-scale kernel helps. Let me see if I can squeeze more. Alternatively , and this might be the biggest lever , let me reconsider the ExtraTrees model. It has the best tail behavior (clamps) but mediocre shape. What if I make a much better tree model with the Strouhal features and more trees, tuned for generalization? Or use a HistGradientBoosting with monotonic/interaction constraints? Actually, let me reconsider: the GPR models are carrying the quality. Let me try to improve GPR further. The gprsum (2-scale, 8 feat, optimized on 600) had rmse 1.793, p90 2.695. gpr9s (2-scale, 9 feat, optimized on 450) had rmse 1.870, p90 2.566. Their blend: rmse 1.752, p90 2.524. The blend of two GPRs with different feature sets / random subsamples helps (diversity). Let me create a THIRD GPR with yet another feature set / subsample seed, to add diversity and reduce variance further. More diverse GPRs averaged → lower variance → lower p90. Let me generate 2-3 more GPR variants with different seeds/features and average them all. This "deep ensemble" of GPRs should reduce the tail. Let me generate gpr variants efficiently. Each optimize on ~450-500 pts with n_restarts=0 is ~13s, plus CV ~30s = ~45s each. I can do ~3 in budget. Let me generate 3 more GPR variants: - gprA: 9 feat, seed 2, 2-scale - gprB: 8 feat (f8 with a*st), seed 3, 2-scale - gprC: add angle^2 feature, seed 4 Then average all GPRs + et and simulate. Let me write a compact generator.
The Monte-Carlo is revealing: **max is rarely binding** (median ~3.6), but **p90 sits right at 2.35** → only ~45% pass. I need to lower the whole per-condition error distribution. Averaging diverse GPRs reduces variance, so let me build several more GPR variants (different features/subsamples) to form a deeper ensemble.
bash
cd /app && cat > /tmp/gprgen.py << 'EOF'
import sys,time; sys.path.insert(0,'/tmp')
import numpy as np, pandas as pd
from cv import FULL
from evalharness import metrics
from sklearn.model_selection import GroupKFold
from sklearn.preprocessing import StandardScaler
from sklearn.gaussian_process import GaussianProcessRegressor
from sklearn.gaussian_process.kernels import WhiteKernel, ConstantKernel, Matern
y=FULL['scaled_sound_pressure_level'].values
groups=FULL['cond'].astype(str).values; conds=FULL['cond']
FOLDS=list(GroupKFold(n_splits=8).split(FULL,y,groups))
def base(df):
    f=df['frequency'].values.astype(float); a=df['attack_angle'].values.astype(float)
    c=df['chord_length'].values.astype(float); v=df['free_stream_velocity'].values.astype(float)
    t=df['suction_side_displacement_thickness'].values.astype(float)
    return f,a,c,v,t
def gen(name, feat, seed, nsub=500, nu_long=2.5):
    X=feat(FULL); d=X.shape[1]
    rng=np.random.RandomState(seed); idx=rng.choice(len(X),nsub,replace=False)
    sc=StandardScaler().fit(X[idx])
    k=ConstantKernel(1.0,(1e-2,1e3))*Matern(length_scale=np.ones(d)*3,length_scale_bounds=(5e-1,1e2),nu=nu_long) \
     + ConstantKernel(0.4,(1e-3,1e2))*Matern(length_scale=np.ones(d)*0.8,length_scale_bounds=(8e-2,5e0),nu=1.5) \
     + WhiteKernel(0.05,(1e-3,1e0))
    t0=time.time()
    gp=GaussianProcessRegressor(kernel=k,normalize_y=True,n_restarts_optimizer=0,random_state=0).fit(sc.transform(X[idx]),y[idx])
    theta=gp.kernel_
    oof=np.zeros(len(FULL))
    for tri,tei in FOLDS:
        sci=StandardScaler().fit(X[tri])
        g=GaussianProcessRegressor(kernel=theta,normalize_y=True,optimizer=None).fit(sci.transform(X[tri]),y[tri])
        oof[tei]=g.predict(sci.transform(X[tei]))
    m=metrics(y,oof,conds)
    print(f"{name:8s} rmse={m['rmse']:.3f} mae={m['mae']:.3f} p90={m['p90']:.3f} p95={m['p95']:.3f} max={m['mx']:.3f} t={time.time()-t0:.0f}s")
    np.save(f'/tmp/oof_{name}.npy', oof)

def fA(df):
    f,a,c,v,t=base(df); logf=np.log10(f)
    return np.column_stack([logf,a,np.log10(c),v,np.log10(t),np.log10(f*t/v),np.log10(f*c/v),np.log10(v*c),a*np.log10(f*t/v),a*a])
def fB(df):
    f,a,c,v,t=base(df); logf=np.log10(f)
    return np.column_stack([logf,a,np.log10(c),v,np.log10(t),np.log10(f*t/v),np.log10(v*c),a*np.log10(f*t/v)])
def fC(df):
    f,a,c,v,t=base(df); logf=np.log10(f); st=np.log10(f*t/v)
    return np.column_stack([logf,a,np.log10(c),v,np.log10(t),st,np.log10(f*c/v),np.log10(v*c),a*st,logf*np.log10(c)])
EOF
cd /app && timeout 250 python3 -c "
import sys; sys.path.insert(0,'/tmp')
from gprgen import gen, fA, fB, fC
gen('gprA', fA, seed=2, nsub=500)
gen('gprB', fB, seed=3, nsub=500)
gen('gprC', fC, seed=4, nsub=500)
" 2>&1 | grep -vi warn
output
gprA     rmse=1.872 mae=1.313 p90=2.589 p95=2.983 max=7.468 t=35s
gprB     rmse=1.823 mae=1.281 p90=2.673 p95=3.150 max=5.610 t=26s
gprC     rmse=1.858 mae=1.277 p90=2.702 p95=2.922 max=7.340 t=32s
I now have several GPR variants: gprsum, gpr9s, gprA, gprB, gprC, gpr8. Let me average all the GPRs and blend with et, then Monte-Carlo simulate to estimate pass probability. Let me combine and simulate.
I now have 6 diverse GPR variants plus trees. Let me build a deep GPR ensemble, blend with ExtraTrees, and use the Monte-Carlo pass-probability estimate to pick the most robust configuration.
bash
cd /app && cat > /tmp/sim.py << 'EOF'
import sys; sys.path.insert(0,'/tmp')
import numpy as np, pandas as pd
from ens import y, conds
from evalharness import metrics
T=dict(rmse=2.25,mae=1.55,p90=2.35,p95=4.75,mx=5.60)
ALL=['et','hgb','rf','gpr8','gprsum','gpr9s','gprA','gprB','gprC']
O={n:np.load(f'/tmp/oof_{n}.npy') for n in ALL}
def fullcv(pred):
    return metrics(y,pred,conds)
dfc=pd.DataFrame({'c':conds})
_groupidx={c:np.where((conds==c).values)[0] for c in conds.unique()}
_clist=list(_groupidx)
def simulate(pred, k, n=4000, seed=0):
    rng=np.random.RandomState(seed)
    err=pred-y
    gerr={c:err[_groupidx[c]] for c in _clist}
    gcr={c:np.sqrt(np.mean(gerr[c]**2)) for c in _clist}
    passes=0; p90s=[]
    nc=len(_clist)
    for _ in range(n):
        sel=rng.choice(nc,k,replace=False)
        cs=[_clist[i] for i in sel]
        errs=np.concatenate([gerr[c] for c in cs])
        crs=np.array([gcr[c] for c in cs])
        rmse=np.sqrt(np.mean(errs**2)); mae=np.mean(np.abs(errs))
        p90=np.percentile(crs,90); p95=np.percentile(crs,95); mx=crs.max()
        p90s.append(p90)
        if rmse<=T['rmse'] and mae<=T['mae'] and p90<=T['p90'] and p95<=T['p95'] and mx<=T['mx']:
            passes+=1
    return passes/n, np.median(p90s)
def blend(weights):
    return sum(w*O[n] for n,w in weights.items())
EOF
cd /app && python3 -c "
import sys; sys.path.insert(0,'/tmp')
from sim import blend, simulate, fullcv, O
import numpy as np
gprs=['gprsum','gpr9s','gprA','gprB','gprC','gpr8']
allgpr=sum(O[n] for n in gprs)/len(gprs)
def show(name,pred):
    m=fullcv(pred); 
    pr15,mp15=simulate(pred,15); pr20,mp20=simulate(pred,20)
    print(f'{name:22s} cv:rmse={m[\"rmse\"]:.3f} mae={m[\"mae\"]:.3f} p90={m[\"p90\"]:.3f} p95={m[\"p95\"]:.3f} max={m[\"mx\"]:.3f} | pass15={pr15:.2f} mp90_15={mp15:.3f} pass20={pr20:.2f}')
show('allGPR', allgpr)
show('allGPR+et .7/.3', .7*allgpr+.3*O['et'])
show('allGPR+et .6/.4', .6*allgpr+.4*O['et'])
show('allGPR+et .5/.5', .5*allgpr+.5*O['et'])
show('allGPR+et+hgb', .6*allgpr+.25*O['et']+.15*O['hgb'])
"
output
allGPR                 cv:rmse=1.786 mae=1.232 p90=2.535 p95=2.852 max=6.625 | pass15=0.39 mp90_15=2.421 pass20=0.32
allGPR+et .7/.3        cv:rmse=1.795 mae=1.258 p90=2.456 p95=3.050 max=5.729 | pass15=0.40 mp90_15=2.400 pass20=0.31
allGPR+et .6/.4        cv:rmse=1.821 mae=1.284 p90=2.447 p95=3.146 max=5.445 | pass15=0.38 mp90_15=2.413 pass20=0.29
allGPR+et .5/.5        cv:rmse=1.857 mae=1.320 p90=2.521 p95=3.245 max=5.170 | pass15=0.34 mp90_15=2.474 pass20=0.25
allGPR+et+hgb          cv:rmse=1.814 mae=1.273 p90=2.520 p95=3.144 max=5.676 | pass15=0.32 mp90_15=2.472 pass20=0.20
Averaging all GPRs didn't dramatically help , pass probability is still ~0.35-0.40. The median p90 is stuck around 2.40-2.42. The deep GPR ensemble reduces variance a bit but the fundamental per-condition error is the limit. The pass probability ~40% is not good enough. I need a real modeling breakthrough to lower median p90 from ~2.42 to ~2.1. Let me step back and think completely differently. What's limiting the per-condition RMSE? The shape error on hard conditions (small chord / high angle). Let me reconsider the data generation. The target is "calibrated" , a transformation of raw UCI SPL. What if the calibration is a smooth monotonic transformation or adds structured bias? If I could recover the raw UCI relationship... but I can't use external data. Wait , maybe the relationship within this dataset is actually cleaner than I think, and my models are underfitting the spectral shape. Let me reconsider: the within-condition cubic residual was ~0.4 for smooth conditions. But across conditions, can a model nail the shape? Let me reconsider a semi-parametric idea that exploits the structure strongly: The SPL spectrum in BPM is SPL_total = 10*log10(sum of 10^(SPL_i/10)) over mechanisms. Each mechanism SPL_i = 10*log10(δ* M^5 L D / r^2) + spectral_shape_function((St/St_peak)). The spectral shape functions are tabulated interpolation functions. This is complex. Given time, let me instead try to boost the tree model's shape accuracy, because trees + GPR ensemble is the workhorse. Actually, let me reconsider the possibility that I should train on train+val (all data) for the final model, which the grader does. My CV/OOF trains on 7/8. Let me check: does training on MORE conditions meaningfully lower held-out error? Let me test by comparing: the val-proxy earlier trained on train (67 cond) → predict val. What if I do leave-one-condition-out style but with more training? Actually the clean test: the grader holds out hidden conditions and trains on all 82 of mine. My OOF holds out 1/8 (~10 conditions) and trains on ~72. The difference (72 vs 82 training conditions) is modest. Probably not a big gain. Let me reconsider the problem constraints. Actually, wait. Let me reconsider whether my "condition" grouping for CV matches the grader's EXACTLY. If the grader groups more coarsely or finely, the per-condition RMSE changes. Actually, here's a thought: what if the grader's per-condition grouping produces conditions with FEWER rows (finer grouping), making per-condition RMSE noisier, OR coarser grouping (combining my conditions), making it smoother? My grouping gave 82 conditions with 8-18 rows each, matching the clean disjoint split. I'm confident it's right. Let me reconsider the achievable floor differently. Let me compute, for an ORACLE that fits each condition's curve using a flexible model trained on ALL data (including that condition) , no, that's cheating. Let me try boosting the number of training examples per condition via the physics. Hmm. OK let me try a different, powerful model class I haven't: a carefully regularized HistGradientBoosting with many iterations and the rich features, tuned via the Monte Carlo. Actually HGB was worse. Let me reconsider: maybe combining GPR (best) with a DIFFERENT kind of model that captures shape differently would help more than more GPRs. The candidates: trees (have), SVR. Let me check SVR in the ensemble more carefully, and also a KNN in Strouhal-space. Actually, let me reconsider the core issue with fresh eyes by looking at the BEST POSSIBLE per-condition RMSE. Let me fit a GPR on ALL data (train+val, in-sample) and look at per-condition RMSE , this is the "easy" in-sample floor. If in-sample per-condition RMSE is already ~2 for hard conditions, then the hard conditions are intrinsically noisy (calibration noise) and NO model can do better , meaning the targets expect me to not be tested on those, OR the targets are achievable only with the right model. Let me check in-samp …[truncated]
Averaging GPRs plateaus at ~40% pass , I need to know the *irreducible* noise floor. Let me check whether the calibration added random noise (irreducible) or is smooth (learnable), by fitting each condition's own curve with a flexible 1-D smoother.
bash
cd /app && timeout 120 python3 -c "
import sys; sys.path.insert(0,'/tmp')
import numpy as np, pandas as pd
from cv import FULL
from sklearn.gaussian_process import GaussianProcessRegressor
from sklearn.gaussian_process.kernels import RBF, ConstantKernel, WhiteKernel
# per-condition: fit SPL vs log-f with a flexible GP (in-sample), residual = irreducible noise
res=[]
for c,g in FULL.groupby('cond'):
    if len(g)<6: continue
    x=np.log10(g['frequency'].values).reshape(-1,1); y=g['scaled_sound_pressure_level'].values
    xs=(x-x.mean())/x.std()
    k=ConstantKernel(1.0)*RBF(0.5)+WhiteKernel(0.1)
    gp=GaussianProcessRegressor(kernel=k,normalize_y=True,n_restarts_optimizer=0).fit(xs,y)
    p=gp.predict(xs); r=y-p
    res.append((c,np.sqrt(np.mean(r**2)),len(g)))
rr=pd.DataFrame(res,columns=['c','rmse','n'])
print('In-sample per-condition curve-fit RMSE (irreducible-ish noise):')
print('  median %.3f  p75 %.3f  p90 %.3f  max %.3f'%(rr['rmse'].median(),rr['rmse'].quantile(.75),rr['rmse'].quantile(.9),rr['rmse'].max()))
print('  worst 5:'); print(rr.sort_values('rmse',ascending=False).head(5).to_string(index=False))
" 2>&1 | grep -vi warn
output
In-sample per-condition curve-fit RMSE (irreducible-ish noise):
  median 0.358  p75 0.617  p90 0.932  max 1.564
  worst 5:
                   c     rmse  n
(12.3, 0.1016, 55.5) 1.564231 16
 (7.2, 0.1524, 71.3) 1.404555 16
 (4.0, 0.2286, 71.3) 1.352310 17
 (7.2, 0.1524, 39.6) 1.186570 17
 (5.3, 0.2286, 39.6) 1.138707 15
This is a crucial finding! The irreducible noise floor (fitting each condition's own curve with a flexible smoother) is: - median 0.358, p90 0.932, MAX 1.564. So the intrinsic noise is LOW (max per-condition 1.56). This means the spectral curves ARE smooth and learnable , my model's errors (p90 ~2.5, max ~5.6) are NOT due to irreducible noise but due to the model failing to predict the curve for held-out conditions. So there's LOTS of room to improve. A perfect model would get p90 ~0.9, max ~1.56. The target p90<=2.35 is very achievable IF I can predict the curves better. This tells me my model is genuinely underfitting/mis-generalizing the shape and level for held-out conditions. The GPR smooths too much or doesn't capture the condition-to-condition variation well. So I need a better model. Let me think about why GPR fails on held-out conditions even though curves are smooth and learnable. The issue: GPR interpolates in the 8-9D feature space. For a held-out condition (a,c,v,t constant, f varying), the nearest training data are other conditions. GPR predicts based on kernel-weighted training points. If the length scales are too long, it over-smooths across conditions (missing the specific level/shape). If too short, it can't borrow strength. The key realization: within a condition, f varies a lot (the curve), but a,c,v,t are constant. Across conditions, a,c,v,t vary. The GPR must learn how the curve SHAPE changes with a,c,v,t. This is a structured problem. Let me reconsider the modeling. One powerful approach: since the curve is smooth in log-f, and I have ~67-82 training conditions each with a full curve, I could: 1. For each training condition, extract curve features (the whole SPL vs log-f curve). 2. Learn a mapping from (a,c,v,t) to the curve. But a simpler, very effective approach for this kind of structured problem: use the frequency (or Strouhal) as the primary axis and build a model that's essentially a "functional regression." Actually, let me reconsider whether my GPR length scales are the problem. The learned short-scale component had length scales like 0.434 on logf , that's reasonable for capturing the curve. Hmm, let me reconsider: maybe the issue is that I optimized the kernel on a SUBSAMPLE (450-600 points), giving poor hyperparameters. The subsample breaks the within-condition structure. Let me instead optimize the GPR kernel properly on the FULL training data (I did this once for the 6-feature version: took 100s, gave good results rmse 1.968). For the sum kernel on full data, it might be much better than the subsample version. But full-data optimization of the sum kernel is slow (>250s likely). Let me try optimizing on the full data but with a time limit, perhaps using a smaller but representative subset that preserves whole conditions (sample CONDITIONS, not rows). Sampling whole conditions preserves curve structure → better hyperparameters. Let me re-optimize: sample ~40 whole conditions (~560 rows), optimize sum kernel. This preserves curves. Actually, let me reconsider the biggest opportunity: the irreducible floor is max 1.56, but my held-out max is 5.6. The gap is huge for specific conditions. Let me look at WHERE the model fails and whether a local model (KNN in a good metric, or local GPR) would help. Let me reconsider a local approach: for each test point, the prediction should be based on conditions with similar (a,c,v,t) AND similar f. Since within-condition curves are smooth and similar conditions have similar curves, a well-tuned local model could work. Let me try a targeted experiment: optimize GPR sum-kernel on whole-condition subsample and do proper CV. Let me also reconsider increasing the short-scale kernel's flexibility. Let me re-optimize on whole conditions (preserves structure).
Key insight: the irreducible per-condition noise floor is tiny (median 0.36, **max 1.56**) , the curves are smooth and learnable, so my ~5.6 max is model error, not noise. There's large headroom. My kernel was optimized on row-subsamples that break curve structure. Let me re-optimize on *whole-condition* subsamples to get better hyperparameters.
bash
cd /app && timeout 280 python3 -c "
import sys,time; sys.path.insert(0,'/tmp')
import numpy as np, pandas as pd
from cv import FULL
from evalharness import metrics
from sklearn.model_selection import GroupKFold
from sklearn.preprocessing import StandardScaler
from sklearn.gaussian_process import GaussianProcessRegressor
from sklearn.gaussian_process.kernels import WhiteKernel, ConstantKernel, Matern
y=FULL['scaled_sound_pressure_level'].values
groups=FULL['cond'].astype(str).values; conds=FULL['cond']
FOLDS=list(GroupKFold(n_splits=8).split(FULL,y,groups))
def f9(df):
    f=df['frequency'].values.astype(float); a=df['attack_angle'].values.astype(float)
    c=df['chord_length'].values.astype(float); v=df['free_stream_velocity'].values.astype(float)
    t=df['suction_side_displacement_thickness'].values.astype(float)
    logf=np.log10(f); st=np.log10(f*t/v)
    return np.column_stack([logf,a,np.log10(c),v,np.log10(t),st,np.log10(f*c/v),np.log10(v*c),a*st])
X=f9(FULL); d=X.shape[1]
# sample whole conditions to preserve curve structure
uc=conds.unique(); rng=np.random.RandomState(0)
selc=rng.choice(len(uc),48,replace=False)
mask=conds.isin(uc[selc]).values
sc=StandardScaler().fit(X[mask])
k=ConstantKernel(1.0,(1e-2,1e3))*Matern(length_scale=np.ones(d)*3,length_scale_bounds=(3e-1,1e2),nu=2.5) \
 + ConstantKernel(0.4,(1e-3,1e2))*Matern(length_scale=np.ones(d)*0.7,length_scale_bounds=(8e-2,5e0),nu=1.5) \
 + WhiteKernel(0.03,(1e-3,1e0))
t0=time.time()
gp=GaussianProcessRegressor(kernel=k,normalize_y=True,n_restarts_optimizer=1,random_state=0).fit(sc.transform(X[mask]),y[mask])
theta=gp.kernel_; print('opt',round(time.time()-t0),'s'); print(theta)
oof=np.zeros(len(FULL))
for tri,tei in FOLDS:
    sci=StandardScaler().fit(X[tri])
    g=GaussianProcessRegressor(kernel=theta,normalize_y=True,optimizer=None).fit(sci.transform(X[tri]),y[tri])
    oof[tei]=g.predict(sci.transform(X[tei]))
m=metrics(y,oof,conds)
print(f\"gprW rmse={m['rmse']:.3f} mae={m['mae']:.3f} p90={m['p90']:.3f} p95={m['p95']:.3f} max={m['mx']:.3f}\")
np.save('/tmp/oof_gprW.npy', oof)
" 2>&1 | grep -vi warn
output
[stderr]
Terminated
Timed out again , 48 conditions (~670 rows) with n_restarts=1 is too slow. Let me reduce to n_restarts=0 and ~40 conditions, and separate the optimization from CV (save theta, then CV in a second command). Let me first just get theta quickly with n_restarts=0.
Still too slow with restarts. Let me split it: first just optimize hyperparameters (no restarts) and save them.
bash
cd /app && timeout 200 python3 -c "
import sys,time,pickle; sys.path.insert(0,'/tmp')
import numpy as np, pandas as pd
from cv import FULL
from sklearn.preprocessing import StandardScaler
from sklearn.gaussian_process import GaussianProcessRegressor
from sklearn.gaussian_process.kernels import WhiteKernel, ConstantKernel, Matern
conds=FULL['cond']; y=FULL['scaled_sound_pressure_level'].values
def f9(df):
    f=df['frequency'].values.astype(float); a=df['attack_angle'].values.astype(float)
    c=df['chord_length'].values.astype(float); v=df['free_stream_velocity'].values.astype(float)
    t=df['suction_side_displacement_thickness'].values.astype(float)
    logf=np.log10(f); st=np.log10(f*t/v)
    return np.column_stack([logf,a,np.log10(c),v,np.log10(t),st,np.log10(f*c/v),np.log10(v*c),a*st])
X=f9(FULL); d=X.shape[1]
uc=conds.unique(); rng=np.random.RandomState(0)
selc=rng.choice(len(uc),40,replace=False)
mask=conds.isin(uc[selc]).values
sc=StandardScaler().fit(X[mask])
k=ConstantKernel(1.0,(1e-2,1e3))*Matern(length_scale=np.ones(d)*3,length_scale_bounds=(3e-1,1e2),nu=2.5) \
 + ConstantKernel(0.4,(1e-3,1e2))*Matern(length_scale=np.ones(d)*0.7,length_scale_bounds=(8e-2,5e0),nu=1.5) \
 + WhiteKernel(0.03,(1e-3,1e0))
t0=time.time()
gp=GaussianProcessRegressor(kernel=k,normalize_y=True,n_restarts_optimizer=0,random_state=0).fit(sc.transform(X[mask]),y[mask])
print('opt',round(time.time()-t0),'s'); print(gp.kernel_)
with open('/tmp/thetaW.pkl','wb') as fh: pickle.dump(gp.kernel_, fh)
" 2>&1 | grep -vi warn
output
opt 54 s
2.38**2 * Matern(length_scale=[4.97, 4.34, 100, 14.6, 4.88, 3.85, 3.04, 6.14, 100], nu=2.5) + 0.405**2 * Matern(length_scale=[1.01, 0.675, 0.347, 5, 1.55, 5, 5, 0.638, 0.782], nu=1.5) + WhiteKernel(noise_level=0.00188)
Got theta in 54s. Now let me run the frozen-kernel CV in a separate command.
Got the whole-condition-optimized kernel. Now let me run the frozen-kernel CV with it.
bash
cd /app && timeout 200 python3 -c "
import sys,pickle; sys.path.insert(0,'/tmp')
import numpy as np, pandas as pd
from cv import FULL
from evalharness import metrics
from sklearn.model_selection import GroupKFold
from sklearn.preprocessing import StandardScaler
from sklearn.gaussian_process import GaussianProcessRegressor
conds=FULL['cond']; y=FULL['scaled_sound_pressure_level'].values
groups=FULL['cond'].astype(str).values
FOLDS=list(GroupKFold(n_splits=8).split(FULL,y,groups))
def f9(df):
    f=df['frequency'].values.astype(float); a=df['attack_angle'].values.astype(float)
    c=df['chord_length'].values.astype(float); v=df['free_stream_velocity'].values.astype(float)
    t=df['suction_side_displacement_thickness'].values.astype(float)
    logf=np.log10(f); st=np.log10(f*t/v)
    return np.column_stack([logf,a,np.log10(c),v,np.log10(t),st,np.log10(f*c/v),np.log10(v*c),a*st])
X=f9(FULL)
theta=pickle.load(open('/tmp/thetaW.pkl','rb'))
oof=np.zeros(len(FULL))
for tri,tei in FOLDS:
    sci=StandardScaler().fit(X[tri])
    g=GaussianProcessRegressor(kernel=theta,normalize_y=True,optimizer=None).fit(sci.transform(X[tri]),y[tri])
    oof[tei]=g.predict(sci.transform(X[tei]))
m=metrics(y,oof,conds)
print(f\"gprW rmse={m['rmse']:.3f} mae={m['mae']:.3f} p90={m['p90']:.3f} p95={m['p95']:.3f} max={m['mx']:.3f}\")
np.save('/tmp/oof_gprW.npy', oof)
" 2>&1 | grep -vi warn
output
gprW rmse=1.935 mae=1.314 p90=2.806 p95=3.169 max=8.026
gprW (whole-condition optimized): rmse=1.935, p90=2.806, max=8.026. Not better than gpr9s; the max is worse (8.026). The noise floor is tiny (0.0019) so it overfits, giving big max errors on extreme held-out conditions. The whole-condition optimization led to very low noise, which overfits. The row-subsample ones had slightly higher noise and did better. I keep hitting a wall with GPR around p90~2.5-2.8. The issue is the extreme conditions blow up (max 6-8) because GPR extrapolates badly there with low noise. Let me reconsider entirely. The irreducible floor is tiny, so a BETTER model should work. What model generalizes better for this structured curve-prediction? Let me reconsider: the problem is extrapolation to held-out (a,c,v). GPR with low noise interpolates training conditions well but extrapolates poorly to new (a,c,v) corners. Key idea: regularize toward robustness. Higher white noise → smoother → less overfit → smaller max but maybe higher median. Let me test increasing the noise floor in the frozen kernel. Actually, let me reconsider the ensemble approach but with the goal of MINIMIZING the max/tail via the ET component while keeping median low, AND use more noise in GPR. But I'm spending a lot. Let me think about what gives the biggest robustness win: the Monte-Carlo showed pass~40% with median p90~2.4. To get pass>50%, I need median p90 < 2.35 comfortably, ideally ~2.2. Let me reconsider: maybe combining GPR with a higher-noise GPR (robust) reduces the tail. Let me make a robust GPR with forced higher noise. Alternatively , let me reconsider the ExtraTrees. It clamps (good max) but poor shape. What if I improve ET with better features and settings so its shape improves? Let me tune ET: more trees, different max_features, and add the Strouhal/chord-Strouhal features (which I have in `feats`). Actually `feats` already has strouhal and many. Let me tune ET hyperparameters via the Monte Carlo. Actually, let me reconsider a promising different model: K-nearest-neighbors in the GPR feature space but with proper scaling, OR a local linear model. KNN was poor though. Let me take yet another strategic step back. I have limited budget. Let me consolidate: my best ensembles give full-CV p90~2.45, max~5.5, and Monte-Carlo pass~40%. Let me reconsider whether the grader's hidden set is more like k=15 or larger, and whether it's truly random. The validation set is 15 conditions and my model does p90=2.55 on it. The hidden set is "derived" similarly. Honestly, to robustly pass, I should try to genuinely lower the error. Let me try ONE more idea that could be a breakthrough: a **stacked/local residual model**. Actually, let me reconsider the feature that matters most for LEVEL extrapolation. The big biases on extreme conditions (+/- 3-4 dB) suggest the LEVEL model extrapolates wrong. If I can get the level right, shape is already good (median shape 1.0). Let me decompose explicitly: 1. Level model: predict per-condition MEAN SPL from (a,c,v,t) [one value per condition]. Train on condition means. This is a low-dimensional regression (82 points, 4 features). A smooth model here extrapolates the level. 2. Shape model: predict SPL - level from (logf, a,c,v,t). At test, predict level from (a,c,v,t) and shape from features, sum. But the level model trained on 82 condition-mean points might extrapolate better or worse. Let me test this decomposition. Actually, the current models implicitly do this. Decomposing might not help unless the level model is better. Let me instead test: does adding the TRUE per-condition mean as a feature help (oracle)? We saw oracle level correction gave p90 2.54 (shape floor). So even perfect level → p90 2.54. Wait, that was for gpr+et. For the better gprsum it might be lower. But the shape floor itself (2.5) is the issue, NOT just level! Wait, I'm confusing myself. Earlier: gpr8+et shape-only p90 was 2.472. That means even with PERFECT level correction, 10% of conditions have shape RM …[truncated]
The whole-condition kernel overfits (tiny noise → max 8.0). The shape error (not just level) is the real limiter. Let me tune gradient boosting harder (it clamps extrapolation and may capture shape interactions GPR misses), then Monte-Carlo the blends.
bash
cd /app && timeout 200 python3 -c "
import sys; sys.path.insert(0,'/tmp')
import numpy as np
from oofgen import gen
from ff import feats
from sklearn.ensemble import HistGradientBoostingRegressor, ExtraTreesRegressor
gen('hgb2', lambda: HistGradientBoostingRegressor(learning_rate=0.03,max_iter=1200,max_leaf_nodes=15,min_samples_leaf=15,l2_regularization=2.0,max_features=0.7,random_state=0), feats)
gen('et2', lambda: ExtraTreesRegressor(n_estimators=800,min_samples_leaf=1,max_features=0.6,n_jobs=-1,random_state=0), feats)
gen('et3', lambda: ExtraTreesRegressor(n_estimators=800,min_samples_leaf=2,max_features=0.8,n_jobs=-1,random_state=1), feats)
" 2>&1 | grep -vi warn
output
hgb2             rmse=2.189 mae=1.596 p90=3.250 p95=3.873 max=5.330 t=26s
et2              rmse=2.208 mae=1.607 p90=3.092 p95=3.708 max=5.589 t=17s
et3              rmse=2.208 mae=1.618 p90=2.974 p95=3.734 max=5.588 t=13s
The tuned trees aren't better than the original et (p90 ~3.0). The GPR models dominate. Let me now do a comprehensive Monte-Carlo search over blends of my best models to find the configuration with highest pass probability, then finalize. My strongest models: gprsum, gpr9s, gprA, gprB, gprC (GPRs), et (trees). Let me do a systematic weight search optimizing pass probability at k=15 and k=20, using the Monte Carlo. Let me write an efficient search. Precompute per-condition errors for each model, then for weight combos compute blended per-condition error arrays and simulate. Actually simulate is expensive per combo. Let me instead optimize on full-CV metrics as a proxy but select by a robust objective, then Monte-Carlo the top few. Let me do a coarse search: blend weights over the GPRs (as a group) and et. Let me parametrize: allGPR = mean of selected GPRs, then blend allGPR with et at various ratios, and also try weighted GPR combos. Let me Monte-Carlo the best handful. Given diminishing returns, let me also reconsider: the pass probability ~40% might be acceptable if the hidden draw is favorable, but I want higher. Let me reconsider the fundamental limiter once more with a concrete diagnosis: for the allGPR model, which specific held-out conditions have shape error >> irreducible floor? If they're always the same few extreme corners (22.2°, 19.7°, 0°/small-chord/high-v), and the hidden set likely contains ~1-2 of those, then p90 (2nd-3rd worst of 15) depends on them. Let me just go with the most robust ensemble and also consider a "safety" "mean-reversion" that reduces extreme predictions. Actually, here's a potentially big idea I haven't tried: TRANSDUCTIVE level calibration using the SHAPE of the test curve. For a held-out condition, I have all its frequencies (unlabeled). The overall level is unknown, but the SHAPE (relative) might let me... no, without labels I can't calibrate level. BUT , here's a real transductive idea: I can make predictions more robust by ensuring consistency. For each test condition, fit the model's predictions. Since I know the true curve is smooth with max noise 1.56, I could regularize... but again, bias isn't fixed by smoothing. Let me accept the GPR+ET ensemble is near the limit of this approach and focus on FINALIZING a robust, well-tuned solution. Let me do a thorough Monte-Carlo-based weight selection, then write the production script. Let me run the search.
Trees don't beat the GPRs. Let me run a thorough Monte-Carlo weight search over my best models to pick the configuration maximizing pass probability across realistic hidden draws.
bash
cd /app && timeout 200 python3 -c "
import sys; sys.path.insert(0,'/tmp')
import numpy as np, pandas as pd
from ens import y, conds
from evalharness import metrics
from itertools import product
T=dict(rmse=2.25,mae=1.55,p90=2.35,p95=4.75,mx=5.60)
gprs=['gprsum','gpr9s','gprA','gprB','gprC']
O={n:np.load(f'/tmp/oof_{n}.npy') for n in gprs+['et','hgb']}
gidx={c:np.where((conds==c).values)[0] for c in conds.unique()}
clist=list(gidx)
def sim(pred,k,n=2500,seed=1):
    rng=np.random.RandomState(seed); err=pred-y
    ge={c:err[gidx[c]] for c in clist}; gc={c:np.sqrt(np.mean(ge[c]**2)) for c in clist}
    nc=len(clist); p=0
    for _ in range(n):
        s=rng.choice(nc,k,replace=False); cs=[clist[i] for i in s]
        e=np.concatenate([ge[c] for c in cs]); crs=np.array([gc[c] for c in cs])
        if np.sqrt(np.mean(e**2))<=T['rmse'] and np.mean(np.abs(e))<=T['mae'] and np.percentile(crs,90)<=T['p90'] and np.percentile(crs,95)<=T['p95'] and crs.max()<=T['mx']: p+=1
    return p/n
# search: GPR mean weight wg split among gprs equally, et weight we, hgb wh
allg=sum(O[n] for n in gprs)/len(gprs)
best=[]
for we in [0,.1,.15,.2,.25,.3,.35,.4]:
  for wh in [0,.1,.15]:
    wg=1-we-wh
    if wg<=0: continue
    pred=wg*allg+we*O['et']+wh*O['hgb']
    m=metrics(y,pred,conds)
    p15=sim(pred,15); p20=sim(pred,20)
    best.append((p15+p20, p15,p20, m['p90'],m['mx'],m['rmse'],we,wh))
best.sort(reverse=True)
print('wg=1-we-wh split equally among 5 GPRs. top by pass15+pass20:')
for b in best[:10]:
    print(f'  pass15={b[1]:.2f} pass20={b[2]:.2f} | cv p90={b[3]:.3f} max={b[4]:.3f} rmse={b[5]:.3f} we={b[6]} wh={b[7]}')
"
output
wg=1-we-wh split equally among 5 GPRs. top by pass15+pass20:
  pass15=0.47 pass20=0.45 | cv p90=2.593 max=6.340 rmse=1.767 we=0.1 wh=0
  pass15=0.47 pass20=0.45 | cv p90=2.574 max=6.190 rmse=1.768 we=0.15 wh=0
  pass15=0.47 pass20=0.43 | cv p90=2.519 max=6.040 rmse=1.772 we=0.2 wh=0
  pass15=0.47 pass20=0.42 | cv p90=2.465 max=5.893 rmse=1.779 we=0.25 wh=0
  pass15=0.47 pass20=0.41 | cv p90=2.576 max=6.501 rmse=1.770 we=0 wh=0.1
  pass15=0.47 pass20=0.41 | cv p90=2.413 max=5.747 rmse=1.789 we=0.3 wh=0
  pass15=0.46 pass20=0.40 | cv p90=2.600 max=6.646 rmse=1.773 we=0 wh=0
  pass15=0.45 pass20=0.35 | cv p90=2.568 max=6.430 rmse=1.774 we=0 wh=0.15
  pass15=0.45 pass20=0.35 | cv p90=2.523 max=6.198 rmse=1.772 we=0.1 wh=0.1
  pass15=0.44 pass20=0.33 | cv p90=2.389 max=5.604 rmse=1.801 we=0.35 wh=0
Pass probability maxes out at ~0.47 for k=15 and ~0.45 for k=20. The et weight ~0.1-0.25 gives best balance. The pass probability is stuck around 45-47%. This is fundamentally limited by the model quality (median p90 ~2.4). I've spent considerable effort. The models plateau at ~45% pass. To cross 50% reliably, I need a better model. Let me think ONE more time about a fundamentally better approach, because the irreducible floor (max 1.56) says it's possible. The core problem: predicting the spectral curve for held-out (a,c,v,t). The model must extrapolate/interpolate the curve SHAPE and LEVEL across the aero-parameter space. Radical idea: Use the fact that the curve is a function of Strouhal number primarily. Let me build a model in a "shape-aligned" space: - Transform each point to St (Strouhal). - The curves for different conditions, when plotted vs St, might align better in shape (just shifted in level and St-peak). Let me reconsider. Actually, the thing that would help most is more training data or better features. I can't get more data. Let me reconsider the features for capturing the peak shift. In BPM, the peak Strouhal St_peak depends on Reynolds number (via a power law) and angle. If I include St relative to a predicted St_peak, the shape would align. But let me try something concrete and potentially high-impact: add polynomial shape features in Strouhal AND let the model combine. Actually GPR already has St and logf. Let me reconsider: maybe I should just ACCEPT ~47% and maximize it, OR try to find if a single well-optimized full-data GPR (not subsample) beats everything. The one time I optimized on FULL data (6 features) I got rmse 1.968 but that was the simple kernel. Let me optimize the SUM kernel (9 feat) on FULL data properly , it's the most likely to give the best hyperparameters. It'll take ~150-250s. Let me try with a hard time cap, saving theta as soon as done. Actually, optimizing sum kernel (many params) on 1157 points with even n_restarts=0 might exceed 250s. Let me try on ~900 whole-condition rows (60 conditions) to be safe, n_restarts=0. That preserves more structure than 40 conditions and more data. Wait, I did 40 conditions → overfit (noise 0.0019). The problem wasn't the data amount but that the optimizer drove noise too low. Let me CONSTRAIN the white noise floor to a minimum (e.g., lower bound 0.01 on standardized scale ≈ 0.5 dB), preventing overfit, which should reduce the max dramatically. Let me re-run with WhiteKernel lower bound raised. Actually the key issue with GPR is the extreme-condition blowup (max 6-8) due to near-zero noise → the GP fits training wiggles and extrapolates wildly. Raising the noise floor regularizes. Let me test frozen kernels with manually increased noise. Let me take the gpr9s feature set and test a range of FIXED noise levels in the frozen kernel (keeping the optimized length scales), to find the noise that minimizes max while keeping p90 low. This directly addresses the tail. Let me do this quickly: reuse gpr9s length scales, vary WhiteKernel noise, CV each. Actually, let me reconsider , I'll build a robust GPR by taking an existing good kernel and bumping its noise. Let me test a few noise values.
Pass probability plateaus ~47%. The GPR tail blowups (max 6–8) come from near-zero learned noise causing wild extrapolation. Let me test regularizing by raising the GPR noise floor, which should cut the max while preserving p90.
bash
cd /app && timeout 250 python3 -c "
import sys; sys.path.insert(0,'/tmp')
import numpy as np, pandas as pd
from cv import FULL
from evalharness import metrics
from sklearn.model_selection import GroupKFold
from sklearn.preprocessing import StandardScaler
from sklearn.gaussian_process import GaussianProcessRegressor
from sklearn.gaussian_process.kernels import WhiteKernel, ConstantKernel, Matern
conds=FULL['cond']; y=FULL['scaled_sound_pressure_level'].values
groups=conds.astype(str).values
FOLDS=list(GroupKFold(n_splits=8).split(FULL,y,groups))
def f9(df):
    f=df['frequency'].values.astype(float); a=df['attack_angle'].values.astype(float)
    c=df['chord_length'].values.astype(float); v=df['free_stream_velocity'].values.astype(float)
    t=df['suction_side_displacement_thickness'].values.astype(float)
    logf=np.log10(f); st=np.log10(f*t/v)
    return np.column_stack([logf,a,np.log10(c),v,np.log10(t),st,np.log10(f*c/v),np.log10(v*c),a*st])
X=f9(FULL)
# base length scales from gprW (whole-cond optimized), vary noise
long_ls=[4.97,4.34,100,14.6,4.88,3.85,3.04,6.14,100]
short_ls=[1.01,0.675,0.347,5,1.55,5,5,0.638,0.782]
for noise in [0.01,0.02,0.04,0.08]:
    theta=ConstantKernel(2.38**2)*Matern(length_scale=long_ls,nu=2.5)+ConstantKernel(0.405**2)*Matern(length_scale=short_ls,nu=1.5)+WhiteKernel(noise)
    oof=np.zeros(len(FULL))
    for tri,tei in FOLDS:
        sci=StandardScaler().fit(X[tri])
        g=GaussianProcessRegressor(kernel=theta,normalize_y=True,optimizer=None).fit(sci.transform(X[tri]),y[tri])
        oof[tei]=g.predict(sci.transform(X[tei]))
    m=metrics(y,oof,conds)
    print(f'noise={noise:.3f} rmse={m[\"rmse\"]:.3f} mae={m[\"mae\"]:.3f} p90={m[\"p90\"]:.3f} p95={m[\"p95\"]:.3f} max={m[\"mx\"]:.3f}')
    np.save(f'/tmp/oof_gprN{int(noise*1000)}.npy', oof)
" 2>&1 | grep -vi warn
output
noise=0.010 rmse=1.921 mae=1.304 p90=2.844 p95=3.086 max=7.986
noise=0.020 rmse=1.918 mae=1.308 p90=2.701 p95=3.016 max=7.961
noise=0.040 rmse=1.924 mae=1.320 p90=2.648 p95=3.012 max=7.935
noise=0.080 rmse=1.946 mae=1.344 p90=2.662 p95=3.155 max=7.912
Raising noise barely reduced max (7.9 still). So the max blowup isn't from noise overfitting , it's from the LONG-scale Matern extrapolating badly for one extreme condition. The max is dominated by one condition (22.2°, smallest chord) that's a far corner. Increasing noise helped p90 slightly (2.84→2.65) but max stays ~7.9. The max for this kernel (with 100 length scales on chord/interaction) is bad. The earlier gpr9s (subsample-optimized) had max 7.26; gprsum had 6.28. The max blowup is one specific extreme condition. In the ensemble with ET, it gets clamped down (ET predicts a bounded value). That's why ET in the blend reduced max to ~5.5. So the strategy of GPR+ET is sound. The ET clamps the GPR's wild extrapolation on the 1-2 extreme conditions. I'm at ~47% pass. Let me reconsider if there's any way to get a better base. Let me reconsider the ACTUAL scenario once more: the grader trains on train+val (all 82 conditions) and tests on HIDDEN conditions. My OOF trains on ~72. The extra 10 conditions of training data could help, especially if hidden conditions are "between" my conditions. More importantly: the hidden set's worst conditions determine p90/max. If the hidden set's conditions are "interior" (not extreme corners like 22.2°), the model does great. The extreme corners (22.2°, 19.7°) might well be in the TRAINING data (train+val) rather than hidden , in fact, the validation set did NOT contain the 22.2° or 19.7° conditions (its worst was 0°/small-chord at 3.66). So the benchmark designers may have kept extreme corners in train. Given val's realized performance (p90=2.55, max=3.66 with gprsum+et, trained on only 67 conditions), and the grader trains on 82 conditions, the hidden performance could be similar or better. The val p90=2.55 is above 2.35 though. Let me compute the val-proxy for my BEST blend (allGPR + et) trained on train→predict val, to get the most realistic single-draw estimate with the best model. Let me build the full ensemble in the val-proxy and measure. This is the key number. Let me extend valproxy to include gpr9s, gprsum, gprA/B/C (I need their kernels). This is getting complex. Let me just build a clean pipeline with gprsum + gpr9s + et (3 models I have kernels for: gprsum theta and gpr9s theta... I have gprsum kernel printed, gpr9s kernel printed). Let me reconstruct and evaluate val-proxy. Actually, let me reconsider my time budget (tokens ~90k, but each big command ~fits). Let me be decisive and efficient: Plan: 1. Finalize the model as a blend of 2-3 GPRs (re-optimized per-fit) + ExtraTrees. For the production script, I'll optimize GPR hyperparameters ONCE on the full training data at runtime (with a subsample for speed) and use them. Actually, to be robust and reproducible, I'll hardcode the kernel hyperparameters I found (they're good) and just fit GPR with optimizer=None (fast) on the full train+val data at grader time. This is FAST and deterministic. 2. For the blend, use weights that the Monte-Carlo favored: GPRs ~0.8 total, ET ~0.2. But I should include ET to clamp the max. Let me first get the realistic val-proxy number for my best ensemble to decide. Let me build a comprehensive valproxy with the GPR kernels I have + ET, and also test training on train+val via a final CV-free check isn't possible (no test labels). The val-proxy (train→val) is my best single-draw estimate. Let me write the val-proxy with multiple GPRs. I'll hardcode the kernels. I have: - gprsum kernel (8 feat f8): 1.44^2*Matern([2.21,1.44,2.51,100,2.95,2.11,4.29,2.35],2.5)+0.299^2*Matern([0.434,1.08,0.282,5,5,5,0.534,2.5],1.5)+White(0.001) - gpr9s kernel (9 feat f9): 1.66^2*Matern([2.5,4.16,100,100,3.11,3.21,2.66,4.46,100],2.5)+0.366^2*Matern([5,0.314,0.434,5,5,5,5,0.552,0.569],1.5)+White(0.00835) Let me evaluate these + ET on val-proxy. This gives the realistic estimate. Then finalize. Let me write it.
Raising noise doesn't fix the max (one extreme corner dominates) , ET clamping in the blend is what controls it, so GPR+ET is the right structure. Let me get the most realistic single-draw estimate: train on `train.csv`, predict the 15 disjoint `validation.csv` conditions, using my strongest blend with the tuned kernels.
bash
cd /app && cat > /tmp/valproxy2.py << 'EOF'
import sys; sys.path.insert(0,'/tmp')
import numpy as np, pandas as pd
from evalharness import cond_labels, metrics, report
from sklearn.preprocessing import StandardScaler
from sklearn.gaussian_process import GaussianProcessRegressor
from sklearn.gaussian_process.kernels import WhiteKernel, ConstantKernel, Matern
from sklearn.ensemble import ExtraTreesRegressor
tr=pd.read_csv('/app/data/train.csv'); va=pd.read_csv('/app/data/validation.csv')
ytr=tr['scaled_sound_pressure_level'].values; yva=va['scaled_sound_pressure_level'].values
cva=cond_labels(va)
def f8(df):
    f=df['frequency'].values.astype(float); a=df['attack_angle'].values.astype(float); c=df['chord_length'].values.astype(float); v=df['free_stream_velocity'].values.astype(float); t=df['suction_side_displacement_thickness'].values.astype(float)
    return np.column_stack([np.log10(f),a,np.log10(c),v,np.log10(t),np.log10(f*t/v),np.log10(v*c),a*np.log10(f*t/v)])
def f9(df):
    f=df['frequency'].values.astype(float); a=df['attack_angle'].values.astype(float); c=df['chord_length'].values.astype(float); v=df['free_stream_velocity'].values.astype(float); t=df['suction_side_displacement_thickness'].values.astype(float)
    return np.column_stack([np.log10(f),a,np.log10(c),v,np.log10(t),np.log10(f*t/v),np.log10(f*c/v),np.log10(v*c),a*np.log10(f*t/v)])
def feats(df):
    f=df['frequency'].astype(float).values;a=df['attack_angle'].astype(float).values;c=df['chord_length'].astype(float).values;v=df['free_stream_velocity'].astype(float).values;t=df['suction_side_displacement_thickness'].astype(float).values
    logf=np.log10(f);logt=np.log10(t);logc=np.log10(c);st=np.log10(f*t/v);re=np.log10(v*c)
    return np.column_stack([logf,a,logc,v,logt,st,re,logf*logf,st*st,a*a,logf*v,logf*a,logf*logt,a*logc,logf*logc])
Ksum=ConstantKernel(1.44**2)*Matern(length_scale=[2.21,1.44,2.51,100,2.95,2.11,4.29,2.35],nu=2.5)+ConstantKernel(0.299**2)*Matern(length_scale=[0.434,1.08,0.282,5,5,5,0.534,2.5],nu=1.5)+WhiteKernel(0.02)
K9s=ConstantKernel(1.66**2)*Matern(length_scale=[2.5,4.16,100,100,3.11,3.21,2.66,4.46,100],nu=2.5)+ConstantKernel(0.366**2)*Matern(length_scale=[5,0.314,0.434,5,5,5,5,0.552,0.569],nu=1.5)+WhiteKernel(0.02)
def gpr(K,featfn):
    Xt=featfn(tr);Xv=featfn(va);sc=StandardScaler().fit(Xt)
    g=GaussianProcessRegressor(kernel=K,normalize_y=True,optimizer=None).fit(sc.transform(Xt),ytr)
    return g.predict(sc.transform(Xv))
psum=gpr(Ksum,f8); p9s=gpr(K9s,f9)
et=ExtraTreesRegressor(n_estimators=800,n_jobs=-1,random_state=0).fit(feats(tr),ytr).predict(feats(va))
report('val psum',metrics(yva,psum,cva))
report('val p9s',metrics(yva,p9s,cva))
report('val gpr-avg',metrics(yva,(psum+p9s)/2,cva))
report('val .4sum+.4 9s+.2et',metrics(yva,.4*psum+.4*p9s+.2*et,cva))
report('val .35sum+.35 9s+.3et',metrics(yva,.35*psum+.35*p9s+.3*et,cva))
report('val .45sum+.35 9s+.2et',metrics(yva,.45*psum+.35*p9s+.2*et,cva))
EOF
cd /app && timeout 90 python3 /tmp/valproxy2.py 2>&1 | grep -vi warn
output
val psum                     rmse=1.730 mae=1.264 p90=2.736 p95=3.079 max=3.865 | OK OK XX OK OK 
val p9s                      rmse=1.798 mae=1.404 p90=2.106 p95=2.693 max=4.013 | OK OK OK OK OK 
val gpr-avg                  rmse=1.708 mae=1.303 p90=2.262 p95=2.838 max=3.937 | OK OK OK OK OK 
val .4sum+.4 9s+.2et         rmse=1.745 mae=1.336 p90=2.245 p95=2.878 max=3.823 | OK OK OK OK OK 
val .35sum+.35 9s+.3et       rmse=1.777 mae=1.369 p90=2.317 p95=2.904 max=3.771 | OK OK OK OK OK 
val .45sum+.35 9s+.2et       rmse=1.741 mae=1.329 p90=2.287 p95=2.901 max=3.816 | OK OK OK OK OK
On the realistic val-proxy (train→val, 15 held-out conditions), several blends PASS ALL metrics: - val gpr-avg (psum+p9s)/2: rmse=1.708, mae=1.303, p90=2.262, p95=2.838, max=3.937 , ALL OK! - val .4sum+.4 9s+.2et: rmse=1.745, mae=1.336, p90=2.245, p95=2.878, max=3.823 , ALL OK! - val p9s alone: p90=2.106 , ALL OK! Interesting , on the val draw, these pass! The val-proxy (trained on only 67 conditions) passes. The grader trains on 82 conditions → likely at least as good. Note the difference from full-CV: full-CV treats ALL 82 conditions as held-out (including extreme corners 22.2°, 19.7° which inflate max to 5.5-6 and p90 to 2.4). The val draw doesn't contain those extremes, so it passes comfortably. So the realistic hidden performance depends on whether the hidden set contains the extreme corners. The val set is one such draw (passes). The hidden set is another draw. The blend .4sum+.4 9s+.2et gives a good balance (includes ET for max safety). On val: max=3.823, p90=2.245. Now, I noticed I set WhiteKernel(0.02) here (raised from the tiny optimized values) , this regularizes and helps. Good. Let me make the final decision on the blend. I want robustness to the hidden draw. Key considerations: - Include ET (weight ~0.2-0.25) to clamp max on extreme draws. - GPR average (psum + p9s) for low p90/rmse. Let me verify this blend's robustness via the full-CV Monte-Carlo using the val-proxy-style predictions. But I already have OOF for gprsum, gpr9s, et. Let me Monte-Carlo the blend .4/.4/.2 using those OOF (which include extreme conditions as held-out) to get the conservative pass rate. Earlier .2/.4/.4 gave pass15=0.46. Let me check .4sum+.4 9s+.2et specifically and a few around it, using OOF with the raised-noise versions if available. Actually, the OOF gprsum/gpr9s used their originally-optimized tiny noise. The val-proxy used noise=0.02. Let me regenerate OOF for the noise=0.02 versions to make the Monte-Carlo consistent with what I'll deploy. Actually I already saved gprN20, gprN40 etc. (those used the gprW length scales with noise). Let me instead just finalize. Let me reason about the final choice: - The val draw passes with margin (p90 2.24, max 3.82). - The full-CV (pessimistic, all conditions held out) gives p90~2.45, max~5.5 for similar blends. - Monte-Carlo pass ~47%. The truth is somewhere between. The benchmark is "calibrated" and the solved boundary targets are set to be achievable. Given val passes cleanly, I'm fairly optimistic. To maximize robustness, let me pick the blend that: 1. Passes val with margin. 2. Has good full-CV metrics (low p90 AND controlled max). 3. Monte-Carlo pass rate is among the best. The blend .4 gprsum + .4 gpr9s + .2 et looks good. But let me increase ET slightly to .25-.3 for max safety on adverse draws, checking it still passes val. .35/.35/.3 passed val (p90 2.317, max 3.771). Hmm, there's a tradeoff: more ET → lower max (safer against extreme-corner draws) but higher p90. Since on adverse draws the max is the risk (extreme corner), and on typical draws p90 is the risk, I want balance. Let me regenerate consistent OOF for the deployed kernels (noise=0.02) and Monte-Carlo a few blends to pick weights, then write the final script. Let me do that. Actually, I realize I should also incorporate MORE GPR diversity (gprA, gprB, gprC) in the final model since averaging more GPRs reduced variance. But those kernels were optimized on tiny subsamples. The psum and p9s are my most reliable. Let me keep the final model clean: 2 GPRs (psum with f8, p9s with f9) + ET, which is reproducible and fast. Let me regenerate OOF for psum and p9s with noise=0.02 for a consistent Monte-Carlo, then finalize weights. Let me do the OOF generation + Monte-Carlo for the final candidate blends.
Excellent , on the realistic validation draw, the blends **pass all five metrics** (e.g., `.4·psum+.4·p9s+.2·et`: p90=2.245, max=3.823). The raised noise floor (0.02) regularizes well. Let me regenerate consistent OOF for the deployed kernels and Monte-Carlo the final weight choices.
bash
cd /app && timeout 120 python3 -c "
import sys; sys.path.insert(0,'/tmp')
import numpy as np, pandas as pd
from cv import FULL
from evalharness import metrics
from sklearn.model_selection import GroupKFold
from sklearn.preprocessing import StandardScaler
from sklearn.gaussian_process import GaussianProcessRegressor
from sklearn.gaussian_process.kernels import WhiteKernel, ConstantKernel, Matern
conds=FULL['cond']; y=FULL['scaled_sound_pressure_level'].values
FOLDS=list(GroupKFold(n_splits=8).split(FULL,y,conds.astype(str).values))
def f8(df):
    f=df['frequency'].values.astype(float);a=df['attack_angle'].values.astype(float);c=df['chord_length'].values.astype(float);v=df['free_stream_velocity'].values.astype(float);t=df['suction_side_displacement_thickness'].values.astype(float)
    return np.column_stack([np.log10(f),a,np.log10(c),v,np.log10(t),np.log10(f*t/v),np.log10(v*c),a*np.log10(f*t/v)])
def f9(df):
    f=df['frequency'].values.astype(float);a=df['attack_angle'].values.astype(float);c=df['chord_length'].values.astype(float);v=df['free_stream_velocity'].values.astype(float);t=df['suction_side_displacement_thickness'].values.astype(float)
    return np.column_stack([np.log10(f),a,np.log10(c),v,np.log10(t),np.log10(f*t/v),np.log10(f*c/v),np.log10(v*c),a*np.log10(f*t/v)])
Ksum=ConstantKernel(1.44**2)*Matern(length_scale=[2.21,1.44,2.51,100,2.95,2.11,4.29,2.35],nu=2.5)+ConstantKernel(0.299**2)*Matern(length_scale=[0.434,1.08,0.282,5,5,5,0.534,2.5],nu=1.5)+WhiteKernel(0.02)
K9s=ConstantKernel(1.66**2)*Matern(length_scale=[2.5,4.16,100,100,3.11,3.21,2.66,4.46,100],nu=2.5)+ConstantKernel(0.366**2)*Matern(length_scale=[5,0.314,0.434,5,5,5,5,0.552,0.569],nu=1.5)+WhiteKernel(0.02)
for nm,K,ff in [('psum',Ksum,f8),('p9s',K9s,f9)]:
    X=ff(FULL);oof=np.zeros(len(FULL))
    for tri,tei in FOLDS:
        sc=StandardScaler().fit(X[tri])
        g=GaussianProcessRegressor(kernel=K,normalize_y=True,optimizer=None).fit(sc.transform(X[tri]),y[tri])
        oof[tei]=g.predict(sc.transform(X[tei]))
    np.save(f'/tmp/oof_{nm}.npy',oof)
    m=metrics(y,oof,conds); print(nm, {k:round(m[k],3) for k in ['rmse','mae','p90','p95','mx']})
print('done')
" 2>&1 | grep -vi warn
output
psum {'rmse': 1.756, 'mae': 1.227, 'p90': 2.639, 'p95': 2.876, 'mx': 6.174}
p9s {'rmse': 1.861, 'mae': 1.295, 'p90': 2.535, 'p95': 2.957, 'mx': 7.245}
done
Now let me Monte-Carlo the final blends using these consistent OOFs + et.
bash
cd /app && python3 -c "
import sys; sys.path.insert(0,'/tmp')
import numpy as np, pandas as pd
from ens import y, conds
from evalharness import metrics
T=dict(rmse=2.25,mae=1.55,p90=2.35,p95=4.75,mx=5.60)
O={n:np.load(f'/tmp/oof_{n}.npy') for n in ['psum','p9s','et','hgb']}
gidx={c:np.where((conds==c).values)[0] for c in conds.unique()}; clist=list(gidx)
def sim(pred,k,n=4000,seed=7):
    rng=np.random.RandomState(seed); err=pred-y
    ge={c:err[gidx[c]] for c in clist}; gc={c:np.sqrt(np.mean(ge[c]**2)) for c in clist}
    nc=len(clist); p=0
    for _ in range(n):
        s=rng.choice(nc,k,replace=False); cs=[clist[i] for i in s]
        e=np.concatenate([ge[c] for c in cs]); crs=np.array([gc[c] for c in cs])
        if np.sqrt(np.mean(e**2))<=T['rmse'] and np.mean(np.abs(e))<=T['mae'] and np.percentile(crs,90)<=T['p90'] and np.percentile(crs,95)<=T['p95'] and crs.max()<=T['mx']: p+=1
    return p/n
for wsum,w9,we,wh in [(.5,.5,0,0),(.4,.4,.2,0),(.35,.35,.3,0),(.3,.3,.25,.15),(.45,.35,.2,0),(.4,.35,.25,0),(.33,.33,.34,0),(.35,.35,.2,.1)]:
    pred=wsum*O['psum']+w9*O['p9s']+we*O['et']+wh*O['hgb']
    m=metrics(y,pred,conds)
    print(f'w(sum,9s,et,hgb)=({wsum},{w9},{we},{wh}) cv p90={m[\"p90\"]:.3f} max={m[\"mx\"]:.3f} rmse={m[\"rmse\"]:.3f} mae={m[\"mae\"]:.3f} | pass15={sim(pred,15):.2f} pass20={sim(pred,20):.2f} pass12={sim(pred,12):.2f}')
"
output
w(sum,9s,et,hgb)=(0.5,0.5,0,0) cv p90=2.484 max=6.580 rmse=1.747 mae=1.209 | pass15=0.48 pass20=0.39 pass12=0.53
w(sum,9s,et,hgb)=(0.4,0.4,0.2,0) cv p90=2.460 max=5.990 rmse=1.759 mae=1.230 | pass15=0.46 pass20=0.39 pass12=0.50
w(sum,9s,et,hgb)=(0.35,0.35,0.3,0) cv p90=2.513 max=5.704 rmse=1.780 mae=1.254 | pass15=0.43 pass20=0.40 pass12=0.47
w(sum,9s,et,hgb)=(0.3,0.3,0.25,0.15) cv p90=2.579 max=5.655 rmse=1.806 mae=1.275 | pass15=0.38 pass20=0.25 pass12=0.41
w(sum,9s,et,hgb)=(0.45,0.35,0.2,0) cv p90=2.494 max=5.935 rmse=1.754 mae=1.227 | pass15=0.45 pass20=0.39 pass12=0.46
w(sum,9s,et,hgb)=(0.4,0.35,0.25,0) cv p90=2.503 max=5.818 rmse=1.766 mae=1.240 | pass15=0.43 pass20=0.40 pass12=0.46
w(sum,9s,et,hgb)=(0.33,0.33,0.34,0) cv p90=2.520 max=5.592 rmse=1.792 mae=1.266 | pass15=0.47 pass20=0.44 pass12=0.50
w(sum,9s,et,hgb)=(0.35,0.35,0.2,0.1) cv p90=2.555 max=5.859 rmse=1.778 mae=1.247 | pass15=0.41 pass20=0.39 pass12=0.45
The Monte-Carlo pass rates are ~0.40-0.48 (conservative estimate, treating ALL conditions including extreme corners as equally likely to be held out). The (0.33,0.33,0.34) and (0.5,0.5,0) give best overall. The (0.4,0.4,0.2) is a good balance: pass15=0.46, pass20=0.39, cv p90=2.46, max=5.99. Note: the Monte-Carlo is pessimistic because it samples extreme corners into the "hidden" set uniformly, but the real benchmark may keep extremes in training (as the val split did). The val-proxy passed cleanly. I want to balance two risks: 1. Typical draw (no extreme corners): p90 is the risk → want more GPR weight (lower p90). 2. Adverse draw (includes 22.2°/19.7° corner): max is the risk → want more ET weight (lower max). The (0.4,0.4,0.2) blend: cv max=5.99 (would fail if the worst corner is in hidden set), p90=2.46. The (0.33,0.33,0.34): cv max=5.59 (just under 5.60!), p90=2.52. Given max=5.60 is a hard limit and the worst corner gives ~5.6-6.0, I should lean toward enough ET to keep max under 5.60 even in adverse draws. (0.33,0.33,0.34) gives max=5.59. But that raises p90 to 2.52. Hmm, there's genuine tension. Let me reconsider: the single worst condition (22.2°, 0.0254, 39.6) drives max. With 0.34 ET weight, its error drops to ~5.59. But actually , will the hidden set even contain that exact extreme condition? The hidden conditions are DIFFERENT from train+val (disjoint). The 22.2° condition at chord 0.0254, velocity 39.6 is in my TRAINING data (it's one of the 82). So it will NOT be in the hidden set! The hidden set has DIFFERENT (a,c,v) combinations. Wait, that's a critical point. The hidden conditions are disjoint from my 82 conditions. So the specific worst condition (22.2, 0.0254, 39.6) that drives my CV max is in MY data, not hidden. The hidden set will have OTHER conditions. But the hidden set could have its OWN extreme corners (e.g., a 22.2° at a different chord, or other high-angle combos) that are equally hard. My CV max reflects how badly my model does on the hardest of MY conditions when held out , a proxy for how badly it'd do on a similarly-hard hidden condition. So the CV max IS a relevant proxy. I can't assume hidden has no hard conditions. Given all this, the safest robust choice balances p90 and max. Let me pick weights that keep BOTH cv-p90 and cv-max reasonable. The (0.4,0.4,0.2) has max 5.99 (risky). The (0.35,0.35,0.3) has max 5.70, p90 2.51. The (0.33,0.33,0.34) has max 5.59, p90 2.52. Actually, let me reconsider using gpr8 (the 8-feature a*st GPR optimized on subsample) which had a lower max (the ensemble gpr8+et had max 5.11!). Let me reconsider , earlier "gprsum+et 0.5/0.5" gave max 4.93, p90 2.61. And "gpr8+et" gave max 5.11, p90 2.64. These 2-model blends had LOWER max than the psum+p9s blends. The p9s model has a high max (7.2) that's hard to fully clamp. Let me reconsider the max contributors. p9s has max 7.245 (very high on one condition). psum has 6.174. gpr8 had 6.586. The issue is these extreme predictions. Let me reconsider including gpr8 (max-friendlier) and more ET. Let me do a broader Monte-Carlo including gpr8, gprsum(orig), psum, p9s, et to find the best robust blend. Actually, I have many OOFs. Let me do a final comprehensive search over {psum, p9s, gpr8, gprsum, et} to maximize min(pass15,pass20) while keeping cv max under 5.6. Let me run it.
Crucially, the hidden conditions are *disjoint* from my 82 , so my CV max reflects how the model fares on similarly-hard held-out conditions (a valid proxy, not the exact same condition). I need to balance p90 (typical-draw risk) against max (hard-condition risk). Let me do a final search across all my GPR variants + ET to find the most robust blend keeping cv-max controlled.
bash
cd /app && python3 -c "
import sys; sys.path.insert(0,'/tmp')
import numpy as np, pandas as pd
from ens import y, conds
from evalharness import metrics
from itertools import product
T=dict(rmse=2.25,mae=1.55,p90=2.35,p95=4.75,mx=5.60)
names=['psum','p9s','gpr8','et']
O={n:np.load(f'/tmp/oof_{n}.npy') for n in names}
gidx={c:np.where((conds==c).values)[0] for c in conds.unique()}; clist=list(gidx)
def sim(pred,k,n=3000,seed=7):
    rng=np.random.RandomState(seed); err=pred-y
    ge={c:err[gidx[c]] for c in clist}; gc={c:np.sqrt(np.mean(ge[c]**2)) for c in clist}
    nc=len(clist); p=0
    for _ in range(n):
        s=rng.choice(nc,k,replace=False); cs=[clist[i] for i in s]
        e=np.concatenate([ge[c] for c in cs]); crs=np.array([gc[c] for c in cs])
        if np.sqrt(np.mean(e**2))<=T['rmse'] and np.mean(np.abs(e))<=T['mae'] and np.percentile(crs,90)<=T['p90'] and np.percentile(crs,95)<=T['p95'] and crs.max()<=T['mx']: p+=1
    return p/n
grid=[0,.1,.15,.2,.25,.3,.35,.4,.5]
res=[]
for w in product(grid,repeat=4):
    if abs(sum(w)-1)>1e-9: continue
    pred=sum(w[i]*O[names[i]] for i in range(4))
    m=metrics(y,pred,conds)
    res.append((min(sim(pred,15),sim(pred,20)), sim(pred,15),sim(pred,20),m['p90'],m['mx'],m['rmse'],w))
res.sort(reverse=True)
print('order',names,'| top by min(pass15,pass20):')
for r in res[:12]:
    print(f'  minpass={r[0]:.2f} p15={r[1]:.2f} p20={r[2]:.2f} cv_p90={r[3]:.3f} cv_max={r[4]:.3f} rmse={r[5]:.3f} w={r[6]}')
"
output
[stderr]
Shell command timed out.
Tool interaction exceeded 300 seconds.
The grid search with simulation (many combos × 2 sims × 3000 iters) was too slow. Let me restart and be much more targeted , just evaluate a handful of specific blends.
The full grid search was too slow. Let me restart and evaluate just a few targeted blends.
bash
restart: true
output
<system>tool has been restarted.</system>
bash
cd /app && timeout 120 python3 -c "
import sys; sys.path.insert(0,'/tmp')
import numpy as np, pandas as pd
from ens import y, conds
from evalharness import metrics
T=dict(rmse=2.25,mae=1.55,p90=2.35,p95=4.75,mx=5.60)
names=['psum','p9s','gpr8','et']
O={n:np.load(f'/tmp/oof_{n}.npy') for n in names}
gidx={c:np.where((conds==c).values)[0] for c in conds.unique()}; clist=list(gidx); nc=len(clist)
def sim(pred,k,n=2000,seed=7):
    rng=np.random.RandomState(seed); err=pred-y
    ge={c:err[gidx[c]] for c in clist}; gc={c:np.sqrt(np.mean(ge[c]**2)) for c in clist}
    p=0
    for _ in range(n):
        s=rng.choice(nc,k,replace=False); cs=[clist[i] for i in s]
        e=np.concatenate([ge[c] for c in cs]); crs=np.array([gc[c] for c in cs])
        if np.sqrt(np.mean(e**2))<=T['rmse'] and np.mean(np.abs(e))<=T['mae'] and np.percentile(crs,90)<=T['p90'] and np.percentile(crs,95)<=T['p95'] and crs.max()<=T['mx']: p+=1
    return p/n
cands={'A .4/.4/0/.2':(.4,.4,0,.2),'B .3/.3/.2/.2':(.3,.3,.2,.2),'C .3/.3/.15/.25':(.3,.3,.15,.25),
       'D .25/.25/.25/.25':(.25,.25,.25,.25),'E .35/.3/.15/.2':(.35,.3,.15,.2),'F .3/.35/.15/.2':(.3,.35,.15,.2)}
for nm,w in cands.items():
    pred=sum(w[i]*O[names[i]] for i in range(4))
    m=metrics(y,pred,conds)
    print(f'{nm:20s} cv p90={m[\"p90\"]:.3f} max={m[\"mx\"]:.3f} rmse={m[\"rmse\"]:.3f} mae={m[\"mae\"]:.3f} p95={m[\"p95\"]:.3f} | p15={sim(pred,15):.2f} p20={sim(pred,20):.2f}')
"
output
A .4/.4/0/.2         cv p90=2.460 max=5.990 rmse=1.759 mae=1.230 p95=2.987 | p15=0.45 p20=0.38
B .3/.3/.2/.2        cv p90=2.448 max=5.974 rmse=1.771 mae=1.236 p95=2.975 | p15=0.40 p20=0.32
C .3/.3/.15/.25      cv p90=2.460 max=5.833 rmse=1.776 mae=1.244 p95=3.022 | p15=0.40 p20=0.33
D .25/.25/.25/.25    cv p90=2.445 max=5.826 rmse=1.784 mae=1.250 p95=3.018 | p15=0.37 p20=0.27
E .35/.3/.15/.2      cv p90=2.452 max=5.950 rmse=1.765 mae=1.232 p95=2.972 | p15=0.40 p20=0.34
F .3/.35/.15/.2      cv p90=2.421 max=6.005 rmse=1.770 mae=1.235 p95=2.983 | p15=0.41 p20=0.33
Adding gpr8 doesn't clearly help. The blend A (.4 psum + .4 p9s + .2 et) remains among the best with p15=0.45. The Monte-Carlo pass rate is stuck ~0.40-0.48. This conservative estimate treats the extreme corners as held-out. The real benchmark (per the val split) seems to keep extremes in training, giving cleaner passes. Let me make a decision. I've thoroughly explored. The best robust blend is approximately .4 psum + .4 p9s + .2 et, which: - Passes the val-proxy draw cleanly (p90 2.245, max 3.823). - Has conservative Monte-Carlo pass ~45%. - cv p90=2.46, max=5.99. The max 5.99 in CV is a concern for adverse draws. Let me slightly increase ET to pull max down while keeping p90 good. But that lowered pass15 in the tests (because p90 rose). Actually, the Monte-Carlo "pass15" being lower for more-ET configs suggests that on most draws, p90 (not max) is binding, so reducing ET (more GPR) helps more often. The max only bites on the rare adverse draw. So optimizing for the COMMON case (p90) favors the GPR-heavy .4/.4/.2 or even .5/.5/0. Let me reconsider: the val-proxy (the ONE real held-out draw I can see) passed best with gpr-avg (.5/.5/0: p90 2.262) and .4/.4/.2 (p90 2.245). Both pass. The .4/.4/.2 includes ET for a bit of max safety and had the best val p90. I'll go with .4 psum + .4 p9s + .2 et. This passed the real val draw with margin on all 5 metrics and has solid Monte-Carlo robustness. Now, one more consideration: the grader trains on train+val (82 conditions) and predicts hidden. My production script should: 1. Load train + val. 2. Build features. 3. Fit psum (GPR with Ksum, f8 features, optimizer=None), p9s (GPR with K9s, f9), et (ExtraTrees). 4. Blend .4/.4/.2. 5. Predict test_features, write CSV. I'll hardcode the kernel hyperparameters (found via optimization). Using optimizer=None makes it fast and deterministic. But wait , is it better to let GPR re-optimize on the full train+val at grader time? Re-optimizing could give better hyperparameters for the larger dataset, but it's slow (100s+) and risks the overfit-to-low-noise problem I saw. The frozen kernels with noise=0.02 are well-regularized and validated. I'll use frozen kernels (optimizer=None) with noise=0.02 , fast, deterministic, validated. Let me also double check the StandardScaler: I fit it on the training features. Good. Let me also make sure the feature functions handle the test data (which lacks the target column) , they only use the 5 input features, so fine. Let me reconsider robustness of the script: - test_features.csv has columns sample_id + 5 features (no target). My load_evaluation_features handles this. - Output: sample_id (as given) + predicted column. - Must include exactly one row per test sample_id. Let me also verify GPR fit time on 1157 points with optimizer=None is fast (it's one Cholesky of 1157x1157 ≈ fast, <5s each). Good. One concern: the grader "re-runs python3 /app/train_and_predict.py". It must complete in reasonable time. My script: 2 GPR fits (optimizer=None, ~3s each) + ET (800 trees, ~5s) = ~15s. Fine. Let me also add a fallback: if something fails, still produce finite predictions. I'll keep it robust but clean. Let me now also reconsider: should I include more GPR diversity for robustness? The val-proxy used just psum+p9s+et and passed. Adding gprA/B/C (subsample-optimized) adds complexity and their kernels were tuned on tiny samples. I'll keep it to psum+p9s+et for reliability and reproducibility. Actually, let me reconsider adding a third GPR with the f8+a^2 or the 6-feature simple one for diversity... No, keep it clean and validated. Actually, let me reconsider whether to re-optimize the GPR kernels on the full train+val at runtime, just the length scales, starting from my good values, with a SHORT optimization. That might marginally improve but risks timeout/overfit. I'll avoid it. Frozen is safer. Let me write the final script now. I'll make it self-contained. Let me reconsider the noise level once more …[truncated]
Adding more variants doesn't clearly help; the blend **0.4·psum + 0.4·p9s + 0.2·et** is the best balance , it passed the real validation draw on all five metrics (p90=2.245, max=3.823) and is robust in Monte-Carlo. Let me write the final production script with the validated frozen kernels.
str_replace_based_edit_tool
command: create
path: /app/train_and_predict.py
file_text: #!/usr/bin/env python3
"""Airfoil self-noise surrogate.

Trains on the labeled public data (train + validation) and writes predictions
for the grader-staged evaluation features at /app/data/test_features.csv.

Modeling notes
--------------
The benchmark holds out complete aerodynamic *condition* groups
``(attack_angle, chord_length, free_stream_velocity)``, so the evaluation tests
generalization to unseen physical regimes.  Within a condition only frequency
varies, tracing a smooth sound-pressure spectrum in log-frequency.

The surrogate is a blend of two Gaussian-process regressors (two-scale Matern
kernels over physically motivated, log-scaled features including the Strouhal
number ``f*delta*/U``) and an ExtraTrees regressor.  The GPs capture the smooth
spectral level/shape and extrapolate across regimes; the tree model clamps the
GP's occasional wild extrapolation on extreme held-out corners, which controls
the worst per-condition error.  Kernel hyperparameters were tuned offline with
grouped (by-condition) cross-validation and are frozen here for a fast,
deterministic re-run.
"""

from pathlib import Path

import numpy as np
import pandas as pd
from sklearn.ensemble import ExtraTreesRegressor
from sklearn.gaussian_process import GaussianProcessRegressor
from sklearn.gaussian_process.kernels import ConstantKernel, Matern, WhiteKernel
from sklearn.preprocessing import StandardScaler

DATA_DIR = Path("/app/data")
OUTPUT_DIR = Path("/app/output")
TARGET = "scaled_sound_pressure_level"
PREDICTION = "predicted_scaled_sound_pressure_level"
FEATURES = [
    "frequency",
    "attack_angle",
    "chord_length",
    "free_stream_velocity",
    "suction_side_displacement_thickness",
]

# Blend weights (GP-heavy for low global/typical-condition error, with an
# ExtraTrees component to bound the worst held-out condition).
W_GP_SUM = 0.4
W_GP_9 = 0.4
W_ET = 0.2


def _raw(df: pd.DataFrame):
    f = df["frequency"].astype(float).to_numpy()
    a = df["attack_angle"].astype(float).to_numpy()
    c = df["chord_length"].astype(float).to_numpy()
    v = df["free_stream_velocity"].astype(float).to_numpy()
    t = df["suction_side_displacement_thickness"].astype(float).to_numpy()
    return f, a, c, v, t


def feats_gp_sum(df: pd.DataFrame) -> np.ndarray:
    """8 features used by the first Gaussian process."""
    f, a, c, v, t = _raw(df)
    st = np.log10(f * t / v)  # Strouhal number (log)
    return np.column_stack(
        [np.log10(f), a, np.log10(c), v, np.log10(t), st, np.log10(v * c), a * st]
    )


def feats_gp_9(df: pd.DataFrame) -> np.ndarray:
    """9 features used by the second Gaussian process (adds chord Strouhal)."""
    f, a, c, v, t = _raw(df)
    st = np.log10(f * t / v)
    stc = np.log10(f * c / v)  # chord-based Strouhal (log)
    return np.column_stack(
        [np.log10(f), a, np.log10(c), v, np.log10(t), st, stc, np.log10(v * c), a * st]
    )


def feats_tree(df: pd.DataFrame) -> np.ndarray:
    """Richer feature set (with interactions) for the ExtraTrees model."""
    f, a, c, v, t = _raw(df)
    logf = np.log10(f)
    logt = np.log10(t)
    logc = np.log10(c)
    st = np.log10(f * t / v)
    re = np.log10(v * c)
    return np.column_stack(
        [logf, a, logc, v, logt, st, re, logf * logf, st * st, a * a,
         logf * v, logf * a, logf * logt, a * logc, logf * logc]
    )


def kernel_gp_sum():
    return (
        ConstantKernel(1.44 ** 2)
        * Matern(length_scale=[2.21, 1.44, 2.51, 100.0, 2.95, 2.11, 4.29, 2.35], nu=2.5)
        + ConstantKernel(0.299 ** 2)
        * Matern(length_scale=[0.434, 1.08, 0.282, 5.0, 5.0, 5.0, 0.534, 2.5], nu=1.5)
        + WhiteKernel(0.02)
    )


def kernel_gp_9():
    return (
        ConstantKernel(1.66 ** 2)
        * Matern(length_scale=[2.5, 4.16, 100.0, 100.0, 3.11, 3.21, 2.66, 4.46, 100.0], nu=2.5)
        + ConstantKernel(0.366 ** 2)
        * Matern(length_scale=[5.0, 0.314, 0.434, 5.0, 5.0, 5.0, 5.0, 0.
…[truncated]
output
Existing file was successfully overwritten at /app/train_and_predict.py
Now let me test that the script runs end-to-end and produces valid output. The grader discards the existing predictions.csv and re-runs. Let me simulate: remove any existing output, run the script, check output format.
Now let me verify the script runs end-to-end and produces a valid output file.
bash
cd /app && rm -f output/predictions.csv && time python3 train_and_predict.py 2>&1 | grep -vi warn; echo "=== OUTPUT ==="; head -5 output/predictions.csv; echo "..."; wc -l output/predictions.csv
output
=== OUTPUT ===
sample_id,predicted_scaled_sound_pressure_level
af_val_0000,116.14097512007754
af_val_0001,124.1900227958036
af_val_0002,132.55860605091453
af_val_0003,125.09675439443205
...
41 output/predictions.csv
[stderr]
real	0m5.052s
user	0m5.920s
sys	0m0.460s
The script runs in ~5 seconds and produces 40 predictions (41 lines with header) for the 40 test rows. Now let me validate the output against the requirements: 1. Exactly one row per sample_id in test_features.csv. ✓ (need to verify counts match) 2. Predictions numeric and finite. ✓ (let me verify) 3. Columns exactly: sample_id, predicted_scaled_sound_pressure_level. ✓ Let me verify alignment and finiteness rigorously. Also, the current test_features.csv is the placeholder (same as validation sample_ids). Since validation HAS labels, let me compute the metrics on this placeholder to sanity-check the deployed script matches my offline val-proxy numbers. But note: the deployed script trains on train+val (including these val rows!), so predicting val would be IN-SAMPLE and over-optimistic. That's fine for a format check but not a generalization check. Actually, this is an important subtlety: the placeholder test_features.csv contains the validation rows. My script trains on train+val, so it trains on those exact rows. When the grader stages HIDDEN features (disjoint from train+val), it'll be genuine extrapolation. So I can't use the placeholder for a generalization check , but my val-proxy experiments already validated generalization (train-only → predict val). Let me just verify the output format/alignment robustly, and confirm predictions are finite and non-constant. Let me also double check that my deployed feature functions and kernels exactly match the validated valproxy2 ones. Let me re-run a clean generalization check using the deployed module's functions but training ONLY on train.csv and predicting validation.csv, to confirm the deployed code reproduces the passing val-proxy metrics.
Runs in ~5s and emits 40 predictions. Let me rigorously validate the output format/alignment, and, importantly, confirm the *deployed code* reproduces the passing generalization metrics by training only on `train.csv` and scoring the disjoint `validation.csv`.
bash
cd /app && python3 -c "
import pandas as pd, numpy as np
tf=pd.read_csv('data/test_features.csv'); pr=pd.read_csv('output/predictions.csv')
assert list(pr.columns)==['sample_id','predicted_scaled_sound_pressure_level'], pr.columns
assert len(pr)==len(tf), (len(pr),len(tf))
assert pr['sample_id'].tolist()==tf['sample_id'].astype(str).tolist(), 'id mismatch/order'
assert pr['sample_id'].is_unique
v=pr['predicted_scaled_sound_pressure_level'].to_numpy()
assert np.all(np.isfinite(v)), 'non-finite'
assert v.std()>1.0, 'looks constant'
print('FORMAT OK: rows',len(pr),'range [%.2f, %.2f] std %.2f'%(v.min(),v.max(),v.std()))
"
output
FORMAT OK: rows 40 range [114.72, 138.05] std 6.48
Format is valid. Now let me run the generalization check using the deployed module's functions: train on train.csv only, predict validation.csv, compute grouped metrics. This confirms the deployed code reproduces the passing val-proxy.
Format is valid. Now the key check , verify the *deployed module* generalizes by training on `train.csv` only and scoring the disjoint `validation.csv` conditions.
bash
cd /app && timeout 90 python3 -c "
import sys; sys.path.insert(0,'/tmp'); sys.path.insert(0,'/app')
import numpy as np, pandas as pd
import train_and_predict as tp
from evalharness import cond_labels, metrics, report
tr=pd.read_csv('data/train.csv'); va=pd.read_csv('data/validation.csv')
y=tr[tp.TARGET].to_numpy()
sc_s,gp_s=tp._fit_gp(tp.kernel_gp_sum(),tp.feats_gp_sum,tr,y)
sc_9,gp_9=tp._fit_gp(tp.kernel_gp_9(),tp.feats_gp_9,tr,y)
from sklearn.ensemble import ExtraTreesRegressor
et=ExtraTreesRegressor(n_estimators=800,n_jobs=-1,random_state=0).fit(tp.feats_tree(tr),y)
ps=gp_s.predict(sc_s.transform(tp.feats_gp_sum(va)))
p9=gp_9.predict(sc_9.transform(tp.feats_gp_9(va)))
pe=et.predict(tp.feats_tree(va))
pred=tp.W_GP_SUM*ps+tp.W_GP_9*p9+tp.W_ET*pe
report('DEPLOYED train->val', metrics(va[tp.TARGET].to_numpy(), pred, cond_labels(va)))
print('targets: rmse<=2.25 mae<=1.55 p90<=2.35 p95<=4.75 max<=5.60')
" 2>&1 | grep -vi warn
output
DEPLOYED train->val          rmse=1.745 mae=1.336 p90=2.245 p95=2.878 max=3.823 | OK OK OK OK OK 
targets: rmse<=2.25 mae<=1.55 p90<=2.35 p95<=4.75 max<=5.60
The deployed code, trained on train.csv only and evaluated on the disjoint validation.csv (15 held-out conditions), PASSES ALL metrics: - rmse=1.745 <= 2.25 ✓ - mae=1.336 <= 1.55 ✓ - p90=2.245 <= 2.35 ✓ - p95=2.878 <= 4.75 ✓ - max=3.823 <= 5.60 ✓ This is the realistic single-draw generalization test (exactly like the hidden set: held-out condition groups, train on the rest). And the grader trains on MORE data (train+val), which should be at least as good. This is a strong result. All metrics pass with margin on the realistic proxy: - rmse margin: 1.745 vs 2.25 (22% margin) - mae margin: 1.336 vs 1.55 (14% margin) - p90 margin: 2.245 vs 2.35 (4% margin , tightest) - p95: huge margin - max: huge margin The tightest is p90 (2.245 vs 2.35). This is the known bottleneck. Now, I have some budget left. Let me consider whether I can improve robustness of p90 a bit more without hurting the passing val result. My Monte-Carlo showed the population p90 is ~2.4-2.5 when ALL conditions (including extreme) are held out. The val draw (2.245) is favorable. The hidden draw is unknown. Let me think about whether there's a safe improvement. The tension is p90 vs max. Currently .4/.4/.2. Given the tight p90 margin, maybe I should slightly favor more GPR weight (lower p90) since max has huge margin on typical draws. But on adverse draws max matters. Let me reconsider: the val max is 3.823 (huge margin to 5.60). Even if I go .45/.45/.1, max might rise a bit but p90 drops. Let me check a couple alternatives on the val-proxy AND the conservative Monte-Carlo to make the most robust choice. Actually, I already explored this. On full-CV Monte-Carlo, .5/.5/0 had pass15=0.48 (best) but max 6.58 (risky on adverse). .4/.4/.2 had pass15=0.45 but safer max. Given the val draw passes comfortably and I want to hedge against adverse hidden draws (where max could bite), keeping the .2 ET weight is prudent. The .4/.4/.2 is a good balance. I'll keep it. However, let me reconsider one thing: can I LOWER p90 further with a genuinely better model, now that I've confirmed the pipeline works? I have budget. The irreducible floor is ~0.9 p90, so there's room. But I've tried many things. Let me reconsider a potential improvement: a THIRD GPR with different features to add diversity and reduce variance (which lowers p90 on average). In the val-proxy, gpr-avg (.5/.5) gave p90 2.262 and the 3-way with ET gave 2.245. Adding a third diverse GPR might lower it more. But I'm cautious: the subsample-optimized GPRs (gprA/B/C) had higher max. Let me not over-engineer. The current solution passes the realistic proxy on all metrics. Actually, let me reconsider whether to re-optimize kernels on the full train+val data at runtime for potentially better hyperparameters. I decided against it (slow, overfit risk). The frozen kernels with noise=0.02 are validated. Keep. Let me do one more robustness thing: verify the script is robust to potential quirks in the hidden test_features.csv: 1. What if test_features has a different number of columns or extra columns? My feature functions select by name, so extra columns are fine. 2. What if sample_id has a different format? I cast to str. Fine. 3. What if there are NaN in test features? GPR/ET would produce NaN. Let me add a safety: fill NaN in evaluation features with training medians. This prevents NaN predictions. Let me add that for robustness. 4. What if test_features.csv is large (many rows)? GPR predict is O(n_train * n_test) , fine. GPR fit is O(n_train^3) with n_train=1157 , fine regardless of test size. Let me add NaN-handling for the evaluation features (and also guard against missing feature columns, though the schema is fixed). Let me add robust imputation using training medians. Actually, the task says the hidden set uses "the same schema", so columns are fixed. But NaN in features is possible. Let me add median imputation from the training data to be safe. Let me update the script. Let me also reconsider: the sa …[truncated]
The deployed code passes **all five metrics** on the realistic held-out validation draw (rmse=1.745, mae=1.336, p90=2.245, p95=2.878, max=3.823), and the grader trains on even more data. Let me harden the script against possible quirks in the hidden features (e.g., missing values) by imputing with training medians, then re-verify.
str_replace_based_edit_tool
command: str_replace
path: /app/train_and_predict.py
old_str: def main() -> None:
    labeled = load_labeled_data()
    evaluation = load_evaluation_features()
    y = labeled[TARGET].astype(float).to_numpy()
new_str: def main() -> None:
    labeled = load_labeled_data()
    evaluation = load_evaluation_features().copy()
    y = labeled[TARGET].astype(float).to_numpy()

    # Robustness: ensure required feature columns exist and are finite by
    # imputing any missing/non-finite evaluation values with training medians.
    for col in FEATURES:
        med = float(labeled[col].astype(float).median())
        if col not in evaluation.columns:
            evaluation[col] = med
        else:
            vals = pd.to_numeric(evaluation[col], errors="coerce")
            evaluation[col] = vals.where(np.isfinite(vals), med)
output
The file /app/train_and_predict.py has been edited successfully.
bash
cd /app && rm -f output/predictions.csv && python3 train_and_predict.py 2>&1 | grep -vi warn; python3 -c "
import pandas as pd, numpy as np
tf=pd.read_csv('data/test_features.csv'); pr=pd.read_csv('output/predictions.csv')
assert list(pr.columns)==['sample_id','predicted_scaled_sound_pressure_level']
assert pr['sample_id'].tolist()==tf['sample_id'].astype(str).tolist()
assert len(pr)==len(tf) and pr['sample_id'].is_unique
v=pr['predicted_scaled_sound_pressure_level'].to_numpy()
assert np.all(np.isfinite(v)) and v.std()>1.0
print('OK after hardening: rows',len(pr),'std %.2f range [%.1f,%.1f]'%(v.std(),v.min(),v.max()))
"
output
OK after hardening: rows 40 std 6.48 range [114.7,138.0]
Everything still works. Let me now test edge cases to make sure the hardening works: 1. Test with a test_features.csv that has a NaN or missing column. 2. Test with extra columns. Let me create a temporary test file (NOT overwriting the real one) and run the logic to confirm robustness. I'll test in a separate directory to avoid modifying the real input files (the task says don't modify input CSVs). Actually, let me just unit-test the main() logic by temporarily pointing to a synthetic eval file. Simpler: test the imputation logic directly with a crafted DataFrame. Let me simulate by copying the data dir to a temp location and running with modified test file. But the script hardcodes /app/data. Let me just test the imputation logic in isolation to confirm it handles NaN, missing columns, and non-numeric gracefully.
Let me quickly unit-test the robustness logic (missing column, NaN, non-numeric strings) in isolation to confirm it never yields non-finite predictions.
bash
cd /app && python3 -c "
import sys; sys.path.insert(0,'/app')
import numpy as np, pandas as pd
import train_and_predict as tp
labeled=tp.load_labeled_data()
# craft a nasty evaluation frame: NaN, a non-numeric string, and a missing column
ev=pd.read_csv('data/test_features.csv').head(5).copy()
ev.loc[0,'frequency']=np.nan
ev.loc[1,'attack_angle']='bad'
ev=ev.drop(columns=['suction_side_displacement_thickness'])  # simulate missing col
# replicate main's imputation
for col in tp.FEATURES:
    med=float(labeled[col].astype(float).median())
    if col not in ev.columns: ev[col]=med
    else:
        vals=pd.to_numeric(ev[col],errors='coerce'); ev[col]=vals.where(np.isfinite(vals),med)
X=tp.feats_gp_sum(ev); X9=tp.feats_gp_9(ev); Xt=tp.feats_tree(ev)
print('all finite after impute:', np.all(np.isfinite(X)) and np.all(np.isfinite(X9)) and np.all(np.isfinite(Xt)))
print('shapes', X.shape, X9.shape, Xt.shape)
"
output
all finite after impute: True
shapes (5, 8) (5, 9) (5, 15)
[stderr]
<string>:9: FutureWarning: Setting an item of incompatible dtype is deprecated and will raise an error in a future version of pandas. Value 'bad' has dtype incompatible with float64, please explicitly cast to a compatible dtype first.
The imputation handles NaN, non-numeric strings, and missing columns, producing all-finite features. There's a harmless FutureWarning about dtype when the column has mixed types, but it works correctly. Let me make the imputation cleaner to avoid the warning by converting to numeric first in a way that doesn't trigger it. Actually, the warning is harmless (it's about setting 'bad' before coercion in my test, not in the actual code path). In the actual main(), I do `pd.to_numeric(evaluation[col], errors="coerce")` which produces a float Series, then `.where(...)`. The warning in my test came from `ev.loc[1,'attack_angle']='bad'` on a float column, which is my test setup, not the script. So the script itself is fine. Actually wait, let me double-check: in main(), if the column comes in as object dtype (e.g., all strings), `pd.to_numeric(..., errors='coerce')` returns float, and assigning back via `evaluation[col] = vals.where(...)` replaces the column entirely (no partial-assignment warning). So no warning in the real path. Good. Now let me do a final review of the complete script and run it one final time to confirm the deliverable is in place. Let me also reconsider: the grledder re-runs the script fresh. It discards /app/output/predictions.csv first. My script recreates it. Good. Let me also make sure I'm not accidentally reading/writing forbidden paths. My script only reads /app/data and writes /app/output. Good. One more consideration: determinism. ExtraTrees has random_state=0, GPR with optimizer=None is deterministic. So predictions are reproducible. Good. The grader might compare against "implausibly exact" outputs , my predictions are real-valued floats from a genuine model, not copied labels, so fine. Let me also reconsider the concern about "source-label leakage": I train only on the provided train.csv/validation.csv labels (the calibrated benchmark labels), never on external UCI raw labels. Good. Let me view the final script once more to ensure correctness and clean up.
The hardening works (the warning shown comes from my test's setup, not the script's code path). Let me do a final review of the complete script and a clean end-to-end run.
bash
cd /app && sed -n '140,210p' train_and_predict.py
output
def main() -> None:
    labeled = load_labeled_data()
    evaluation = load_evaluation_features().copy()
    y = labeled[TARGET].astype(float).to_numpy()

    # Robustness: ensure required feature columns exist and are finite by
    # imputing any missing/non-finite evaluation values with training medians.
    for col in FEATURES:
        med = float(labeled[col].astype(float).median())
        if col not in evaluation.columns:
            evaluation[col] = med
        else:
            vals = pd.to_numeric(evaluation[col], errors="coerce")
            evaluation[col] = vals.where(np.isfinite(vals), med)

    # Two Gaussian processes with frozen, grouped-CV-tuned kernels.
    sc_sum, gp_sum = _fit_gp(kernel_gp_sum(), feats_gp_sum, labeled, y)
    sc_9, gp_9 = _fit_gp(kernel_gp_9(), feats_gp_9, labeled, y)

    # ExtraTrees to clamp extrapolation on extreme held-out conditions.
    et = ExtraTreesRegressor(n_estimators=800, n_jobs=-1, random_state=0)
    et.fit(feats_tree(labeled), y)

    p_sum = gp_sum.predict(sc_sum.transform(feats_gp_sum(evaluation)))
    p_9 = gp_9.predict(sc_9.transform(feats_gp_9(evaluation)))
    p_et = et.predict(feats_tree(evaluation))

    predictions = W_GP_SUM * p_sum + W_GP_9 * p_9 + W_ET * p_et

    # Safety: guarantee finite, plausible outputs.
    predictions = np.asarray(predictions, dtype=float)
    if not np.all(np.isfinite(predictions)):
        fallback = float(np.mean(y))
        predictions = np.where(np.isfinite(predictions), predictions, fallback)
    predictions = np.clip(predictions, y.min() - 15.0, y.max() + 15.0)

    OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
    pd.DataFrame(
        {
            "sample_id": evaluation["sample_id"].astype(str),
            PREDICTION: predictions,
        }
    ).to_csv(OUTPUT_DIR / "predictions.csv", index=False)


if __name__ == "__main__":
    main()
The script looks clean and complete. Let me do a final clean end-to-end run (simulating the grader: discard output, re-run) and confirm everything is in order. Let me also confirm I haven't modified the input CSVs (they should be unchanged). Let me check the input files' integrity (md5 or just confirm they're readable and unchanged size). Actually I never wrote to them, so they're fine. Let me just do the final run. Let me also reconsider the runtime: ~5s, well within limits. Let me run it one final time cleanly and show the grader-relevant summary. I'll also confirm determinism by running twice and comparing.
The script is clean and complete. Let me do a final grader-style run (discard output, re-run) and confirm determinism plus input-file integrity.
bash
cd /app && md5sum data/*.csv > /tmp/before.md5
rm -f output/predictions.csv
python3 train_and_predict.py 2>&1 | grep -vi warn
cp output/predictions.csv /tmp/run1.csv
python3 train_and_predict.py 2>&1 | grep -vi warn
echo "--- determinism (diff of two runs; empty = identical) ---"
diff /tmp/run1.csv output/predictions.csv && echo "IDENTICAL across runs"
echo "--- input files unchanged? ---"
md5sum -c /tmp/before.md5
echo "--- final output head ---"
head -3 output/predictions.csv
output
--- determinism (diff of two runs; empty = identical) ---
IDENTICAL across runs
--- input files unchanged? ---
data/test_features.csv: OK
data/train.csv: OK
data/validation.csv: OK
--- final output head ---
sample_id,predicted_scaled_sound_pressure_level
af_val_0000,116.14097512007754
af_val_0001,124.1900227958036
Everything checks out: - Deterministic across runs (identical output). - Input files unchanged. - Output format correct. Let me do a final summary of what I've built and the expected performance. The solution: - Blend of 2 GPRs (two-scale Matern kernels over physics features incl. Strouhal number) + ExtraTrees. - Trained on train+val (grader re-run). - On the realistic held-out validation proxy (train→val, disjoint conditions): all 5 metrics pass with margin. I'm confident in the solution. Let me provide a final summary to the user. Actually, let me reconsider whether I have budget to squeeze p90 a bit more safely, since p90 is the tightest (2.245 vs 2.35 on val; ~2.46 on conservative full-CV). Given I have budget, let me see if a modest tweak improves robustness without risk. One low-risk idea: add a third GPR to the GP average for variance reduction. I have gpr8 (8-feat a*st, subsample-optimized, max 6.586). But the val-proxy with just psum+p9s+et already passes. Adding gpr8 in the val-proxy , let me quickly check if a psum+p9s+gpr8+et blend improves or worsens val p90. If it clearly helps p90 without hurting max, I could adopt it. But I need gpr8's kernel for the deployed script. I have it from the earlier output: gpr8 kernel: 1.53**2 * Matern(length_scale=[2.14, 2.13, 1.78, 13.1, 2.76, 2.91, 3.38, 3.23], nu=1.5) + WhiteKernel(noise_level=0.00503). Features f8 (same as psum's f8: [logf,a,logc,v,logt,st,re,a*st]). It's a single-scale Matern nu=1.5. Hmm, gpr8 is single-scale. Adding it adds diversity. But it has low noise (0.005) → high max. In a blend it might not help max. Given the current solution PASSES the realistic proxy on all metrics with the tightest being p90 at 4% margin, and adding complexity risks the validated result, I think the prudent choice is to KEEP the validated .4/.4/.2 blend. The risk of over-tinkering (and possibly making the deployed config differ from what I validated) outweighs the marginal potential gain. Actually, let me reconsider the conservative full-CV number once more. On full-CV (all 82 conditions held out in rotation), the .4/.4/.2 blend gave p90=2.460 (slightly above 2.35) and max=5.99 (above 5.60). This is the pessimistic bound. The val draw gave 2.245/3.823 (passing). The truth for the hidden set is somewhere in between, depending on which conditions are held out. The fact that full-CV p90=2.46 > 2.35 is a bit concerning. But full-CV is pessimistic because it forces EVERY extreme corner to be a held-out test condition at some point, and averages p90 over all those rotations. The actual hidden set is ONE draw. Is there a way to reduce the full-CV p90 and max simultaneously to build more margin? That's what I struggled with (p90↔max tension). Let me reconsider: the max in full-CV is driven by the single worst condition (22.2°, smallest chord). More ET weight reduces it. Let me check: does a blend like .35/.35/.3 give better full-CV balance while still passing val? Earlier full-CV: .35/.35/.3 → p90 2.513, max 5.704. And val: .35sum+.35 9s+.3et → p90 2.317, max 3.771 (passes). Hmm that has higher val p90 (2.317, closer to 2.35) but lower max. The .4/.4/.2 has val p90 2.245 (more margin on the tight metric) but full-CV max 5.99. Trade-off: - .4/.4/.2: better typical-draw p90 (the usually-binding metric), worse adverse-draw max. - .35/.35/.3: worse typical p90, better adverse max. Since p90 is the usually-binding constraint (Monte-Carlo showed p90 binds more often than max), and the val draw confirms p90 is tight, I lean toward protecting p90 → keep .4/.4/.2. Also, the max only exceeds 5.60 on rare adverse draws that include an extreme corner as a SINGLE held-out condition; with the hidden set being disjoint and likely containing a spread of conditions, max has been consistently fine on realistic draws (val max 3.82). I'm confident in .4/.4/.2. Let me finalize. Actually, let me reconsider doing a quick blend optimization that MINIMIZES full-CV p90 AND keeps max under ~5.6, t …[truncated]
Everything is deterministic, inputs are untouched, and the output is valid. Let me make a final, informed weight decision by checking a few blends on the realistic train→val proxy (cheap, using the deployed kernels) to confirm 0.4/0.4/0.2 is the best balance of the tight p90 against max.
bash
cd /app && timeout 90 python3 -c "
import sys; sys.path.insert(0,'/tmp'); sys.path.insert(0,'/app')
import numpy as np, pandas as pd
import train_and_predict as tp
from evalharness import cond_labels, metrics
from sklearn.ensemble import ExtraTreesRegressor
tr=pd.read_csv('data/train.csv'); va=pd.read_csv('data/validation.csv')
y=tr[tp.TARGET].to_numpy(); yv=va[tp.TARGET].to_numpy(); cv=cond_labels(va)
sc_s,gp_s=tp._fit_gp(tp.kernel_gp_sum(),tp.feats_gp_sum,tr,y)
sc_9,gp_9=tp._fit_gp(tp.kernel_gp_9(),tp.feats_gp_9,tr,y)
et=ExtraTreesRegressor(n_estimators=800,n_jobs=-1,random_state=0).fit(tp.feats_tree(tr),y)
ps=gp_s.predict(sc_s.transform(tp.feats_gp_sum(va))); p9=gp_9.predict(sc_9.transform(tp.feats_gp_9(va))); pe=et.predict(tp.feats_tree(va))
for ws,w9,we in [(.5,.5,0),(.45,.45,.1),(.4,.4,.2),(.35,.35,.3),(.3,.3,.4)]:
    m=metrics(yv,ws*ps+w9*p9+we*pe,cv)
    print(f'({ws},{w9},{we}) val: p90={m[\"p90\"]:.3f} max={m[\"mx\"]:.3f} rmse={m[\"rmse\"]:.3f} mae={m[\"mae\"]:.3f} p95={m[\"p95\"]:.3f}')
" 2>&1 | grep -vi warn
output
(0.5,0.5,0) val: p90=2.262 max=3.937 rmse=1.708 mae=1.303 p95=2.838
(0.45,0.45,0.1) val: p90=2.250 max=3.878 rmse=1.722 mae=1.318 p95=2.856
(0.4,0.4,0.2) val: p90=2.245 max=3.823 rmse=1.745 mae=1.336 p95=2.878
(0.35,0.35,0.3) val: p90=2.317 max=3.771 rmse=1.777 mae=1.369 p95=2.904
(0.3,0.3,0.4) val: p90=2.405 max=3.724 rmse=1.816 mae=1.405 p95=2.932
On the val-proxy: - (0.4,0.4,0.2): p90=2.245, max=3.823 , best p90 among the safe options, and max has huge margin. - (0.5,0.5,0): p90=2.262, max=3.937 (slightly higher p90 and max). - (0.35,0.35,0.3): p90=2.317 (tighter), max=3.771. - (0.3,0.3,0.4): p90=2.405 , FAILS p90. So (0.4,0.4,0.2) is the sweet spot on the val draw: it minimizes the tight p90 metric (2.245) while ET weight 0.2 keeps max well-controlled (3.823, with the conservative full-CV max ~5.99 being the adverse-case bound). This confirms my choice of (0.4, 0.4, 0.2). The deployed script already uses these weights. I'm confident the solution is complete and well-validated. Let me do a final confirmation that the deliverable is in place and provide the summary. Let me verify the output file exists and is the latest.
The data confirms **0.4/0.4/0.2** is optimal: it minimizes the tight p90 metric (2.245) while the 0.2 ExtraTrees weight keeps the worst-condition error well-controlled. More tree weight (0.4) would fail p90. The deployed script already uses these weights. Let me confirm the final deliverable is in place.
bash
cd /app && ls -la output/predictions.csv && echo "rows (incl header):" && wc -l < output/predictions.csv && echo "=== head ===" && head -3 output/predictions.csv
output
-rw-r--r-- 1 h2tagent h2tagent 1276 Jun 21 06:32 output/predictions.csv
rows (incl header):
41
=== head ===
sample_id,predicted_scaled_sound_pressure_level
af_val_0000,116.14097512007754
af_val_0001,124.1900227958036
The solution is complete and validated. Here's a summary of what I built and why it should clear the solved boundary. ## Approach **Problem understanding.** I verified that the split holds out complete aerodynamic conditions `(attack_angle, chord_length, free_stream_velocity)`: the a/c/v values are jittered only at the ~1e-8 level, so they're effectively constant within a condition, and I could reconstruct the 82 canonical conditions. Critically, I confirmed the provided `validation.csv` conditions are **completely disjoint** from `train.csv` (0 overlap) , making train→val a faithful proxy for the hidden grouped split. Within each condition only frequency varies, tracing a smooth sound-pressure spectrum whose irreducible noise floor is tiny (per-condition curve-fit RMSE: median 0.36, max 1.56 dB), so the held-out error is model generalization error, not noise. **Model.** A blend of three models trained on the labeled data: - Two **Gaussian processes** with two-scale Matérn kernels (a long-scale component for the level/extrapolation + a short-scale component for sharp spectral shape) over log-scaled physical features including the **Strouhal number** `f·δ*/U` and a chord-based Strouhal. Kernel hyperparameters were tuned offline with grouped (by-condition) cross-validation and frozen with a regularizing noise floor for fast, deterministic re-runs. - An **ExtraTrees** regressor that clamps the GPs' occasional wild extrapolation on extreme held-out corners, which controls the worst per-condition error. Blend weights **0.4/0.4/0.2** were selected to minimize the binding `p90` metric while keeping the max-condition error safe. ## Validation evidence Training on `train.csv` only and scoring the disjoint `validation.csv` (exactly mimicking the hidden scenario), the **deployed code passes all five metrics** with margin: | metric | value | target | |---|---|---| | rmse | 1.745 | ≤ 2.25 | | mae | 1.336 | ≤ 1.55 | | p90_condition_rmse | 2.245 | ≤ 2.35 | | p95_condition_rmse | 2.878 | ≤ 4.75 | | max_condition_rmse | 3.823 | ≤ 5.60 | The grader trains on train+val (more conditions), which should be at least as strong. ## Compliance checks - `/app/output/predictions.csv` has exactly `sample_id,predicted_scaled_sound_pressure_level`, one finite row per test `sample_id`, correctly aligned/ordered. - Runs in ~5s, fully deterministic across runs, and trains only on the provided calibrated labels (no external/raw-UCI labels, no internet). - Input CSVs are unmodified; only `/app/data` is read and only `/app/output` is written. - Hardened against missing/NaN/non-numeric hidden features (training-median imputation) plus a finite-output safeguard and plausible-range clip. One honest caveat: my conservative whole-population cross-validation (rotating *every* condition, including the most extreme corners, into the held-out set) puts p90 nearer ~2.46 and max ~5.99 , i.e., an unusually adverse hidden draw that isolates an extreme high-angle/small-chord corner could stress the `p90`/`max` limits. The realistic disjoint-validation draw passes comfortably, and I chose the weighting that best protects the tight `p90` metric while keeping `max` controlled.
Final diff , what the agent changed

No file changes, the agent left the workspace untouched.

Verifier output , the proof behind the reward
tests/test.sh
# SyncVals normalized verifier summary
# task: airfoil-self-noise
# attempt: 8  status: completed  raw_score: 0.55
# reward rule: continuous: score 0.55 >= 0.5
# => reward = 1.0  (PASS)
#
# Note: these tasks score via an external hidden grader; no pytest/verifier
# block is embedded in the agent transcript. The block below is the last
# sizable tool-output captured in the run (supporting evidence).
----------------------------------------------------------------------
-rw-r--r-- 1 h2tagent h2tagent 1276 Jun 21 06:32 output/predictions.csv
rows (incl header):
41
=== head ===
sample_id,predicted_scaled_sound_pressure_level
af_val_0000,116.14097512007754
af_val_0001,124.1900227958036

Reproduce this trial: git checkout 2f94510 && PYTHONPATH=src python3 scripts/build_site.py , then open trial/trial_046fc7c95f5b4571. Re-running the agent live requires EVAL_PLATFORM_ENABLE_OAUTH_SMOKE=1 and is non-deterministic.

Trial trial_046fc7c95f5b4571 · verifier authoritative; classifier explanatory.