From 1c0bd1afe46d52aa136f3213e2cae4c4c21dc118 Mon Sep 17 00:00:00 2001 From: Gernot Maier Date: Thu, 27 Aug 2026 07:42:41 +0200 Subject: [PATCH 1/4] Reduced regression features --- README.md | 14 +++++++ src/eventdisplay_ml/config.py | 12 ++++++ src/eventdisplay_ml/features.py | 57 ++++++++++++++++++++++++++ src/eventdisplay_ml/models.py | 8 ++-- tests/test_config.py | 27 ++++++++++++ tests/test_features.py | 66 ++++++++++++++++++++++++++++++ tests/test_regression_contracts.py | 52 +++++++++++++++++++++++ 7 files changed, 233 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ed4f6b4..3e8ffd3 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,11 @@ The stereo regression training pipeline uses multi-target XGBoost to predict res **Targets:** `[Xoff_residual, Yoff_residual, E_residual]` (residuals on direction and energy as reconstruction by the BDT stereo reconstruction method) +By default, regression uses the extended feature set. An optional reduced set can +be selected with `--feature_profile reduced`; it contains the array-level geometry +and energy quantities plus `width_length`, `R_core`, and `loss` summaries for +telescope positions 0--3. The default is unchanged with `--feature_profile extended`. + **Key techniques:** - **Target standardization:** Targets are mean-centered and scaled to unit variance during training @@ -43,6 +48,15 @@ eventdisplay-ml-train-xgb-stereo \ --max_cores 8 ``` +For the reduced regression feature set: + +```bash +eventdisplay-ml-train-xgb-stereo \ + --input_file_list train_files.txt \ + --model_prefix models/stereo_model_reduced \ + --feature_profile reduced +``` + **Output:** Joblib model file containing: - XGBoost trained model object diff --git a/src/eventdisplay_ml/config.py b/src/eventdisplay_ml/config.py index ee8411e..3d4f325 100644 --- a/src/eventdisplay_ml/config.py +++ b/src/eventdisplay_ml/config.py @@ -38,6 +38,17 @@ def configure_training(analysis_type): parser.add_argument( "--input_file_list", help=f"List of input mscw files for {analysis_type}." ) + if analysis_type == "stereo_analysis": + parser.add_argument( + "--feature_profile", + choices=("extended", "reduced"), + default="extended", + help=( + "Regression feature set. 'extended' retains all non-target features; " + "'reduced' uses array-level quantities and width/length, R_core, " + "and loss summaries for telescope positions 0-3." + ), + ) if analysis_type == "classification": parser.add_argument("--input_signal_file_list", help="List of input signal mscw files.") parser.add_argument( @@ -191,6 +202,7 @@ def configure_training(analysis_type): _logger.info(f"Max telescopes per mirror area type: {model_configs['max_tel_per_type']}") if analysis_type == "stereo_analysis": _logger.info(f"Minimum images (DispNImages): {model_configs.get('min_images')}") + _logger.info(f"Regression feature profile: {model_configs.get('feature_profile')}") _logger.info( "Regression weighting: energy=inverse-sqrt(count), min_bin_events=%d, " "multiplicity=DispNImages**2, max_combined_weight=%.1f, " diff --git a/src/eventdisplay_ml/features.py b/src/eventdisplay_ml/features.py index a63d350..fe2fdab 100644 --- a/src/eventdisplay_ml/features.py +++ b/src/eventdisplay_ml/features.py @@ -90,6 +90,63 @@ def classification_feature_columns(columns, profile="extended", ignore_ze_bin=Fa return selected +def regression_feature_columns(columns, profile="extended"): + """Select stereo-regression features from a flattened data frame. + + Parameters + ---------- + columns : iterable[str] + Columns available in the flattened training or inference data. + profile : {"extended", "reduced"}, optional + ``extended`` preserves the historical behavior and keeps every + non-target column. ``reduced`` keeps only array-level reconstruction + quantities and the requested shape summaries for telescope positions + 0--3. + + Returns + ------- + list[str] + Selected columns in the requested, stable order. + + Raises + ------ + ValueError + If the profile is unknown or a required reduced-profile column is + unavailable. + """ + if profile not in {"extended", "reduced"}: + raise ValueError("regression feature profile must be 'extended' or 'reduced'") + + target_names = set(target_features("stereo_analysis")) + available = list(columns) + if profile == "extended": + return [name for name in available if name not in target_names] + + reduced = [ + "Xoff_weighted_bdt", + "Yoff_weighted_bdt", + "Xoff_intersect", + "Yoff_intersect", + "Diff_Xoff", + "Diff_Yoff", + "DispNImages", + "Erec", + "ErecS", + "EmissionHeight", + "Geomagnetic_Angle", + "array_footprint", + *[f"width_length_{i}" for i in range(4)], + *[f"R_core_{i}" for i in range(4)], + *[f"loss_{i}" for i in range(4)], + ] + missing = [name for name in reduced if name not in available] + if missing: + raise ValueError( + "Reduced regression feature profile is missing required columns: " + ", ".join(missing) + ) + return reduced + + def excluded_features(analysis_type, ntel): """ Features not to be used for training/prediction. diff --git a/src/eventdisplay_ml/models.py b/src/eventdisplay_ml/models.py index 4d93c37..7ec0364 100644 --- a/src/eventdisplay_ml/models.py +++ b/src/eventdisplay_ml/models.py @@ -847,9 +847,11 @@ def train_regression(df, model_configs): memory_profile = model_configs.get("memory_profile", False) utils.log_memory_checkpoint("train_regression:start", df, enabled=memory_profile) - # Exclude target residuals from features - excluded_cols = set(model_configs["targets"]) - x_cols = [col for col in df.columns if col not in excluded_cols] + # Exclude target residuals from features and optionally select a reduced + # regression profile. The default profile preserves the historical + # all-non-target feature set. + profile = model_configs.get("feature_profile", "extended") + x_cols = features.regression_feature_columns(df.columns, profile=profile) _logger.info(f"Features ({len(x_cols)}): {', '.join(list(x_cols))}") model_configs["features"] = list(x_cols) targets = model_configs["targets"] diff --git a/tests/test_config.py b/tests/test_config.py index 520bbda..e5d856c 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -90,6 +90,33 @@ def test_configure_training_stereo_enables_memory_profile(monkeypatch): assert result["memory_profile"] is True +def test_configure_training_stereo_parses_reduced_feature_profile(monkeypatch): + monkeypatch.setattr( + sys, + "argv", + [ + "prog", + "--model_prefix", + "model", + "--input_file_list", + "inputs.txt", + "--feature_profile", + "reduced", + ], + ) + monkeypatch.setattr( + config, + "hyper_parameters", + lambda *_: {"xgboost": {"hyper_parameters": {}}}, + ) + monkeypatch.setattr(config, "target_features", lambda *_: ["target_a"]) + monkeypatch.setattr(config, "pre_cuts_regression", lambda min_images: f"cut_{min_images}") + + result = config.configure_training("stereo_analysis") + + assert result["feature_profile"] == "reduced" + + def test_configure_training_classification_parses_tmva_style(monkeypatch, model_parameters_file): monkeypatch.setattr( sys, diff --git a/tests/test_features.py b/tests/test_features.py index 4cae9a1..743b4ce 100644 --- a/tests/test_features.py +++ b/tests/test_features.py @@ -7,6 +7,7 @@ excluded_features, features, features_tmva_style, + regression_feature_columns, target_features, telescope_features, ) @@ -71,6 +72,71 @@ def test_excluded_features_unknown_raises(): excluded_features("mystery", ntel=4) +def test_reduced_regression_feature_columns_are_stable_and_exact(): + columns = [ + "unrelated", + "Xoff_residual", + "Xoff_weighted_bdt", + "Yoff_weighted_bdt", + "Xoff_intersect", + "Yoff_intersect", + "Diff_Xoff", + "Diff_Yoff", + "DispNImages", + "Erec", + "ErecS", + "EmissionHeight", + "Geomagnetic_Angle", + "array_footprint", + *[f"width_length_{i}" for i in range(4)], + *[f"R_core_{i}" for i in range(4)], + *[f"loss_{i}" for i in range(4)], + "E_residual", + ] + + assert regression_feature_columns(columns, profile="reduced") == [ + "Xoff_weighted_bdt", + "Yoff_weighted_bdt", + "Xoff_intersect", + "Yoff_intersect", + "Diff_Xoff", + "Diff_Yoff", + "DispNImages", + "Erec", + "ErecS", + "EmissionHeight", + "Geomagnetic_Angle", + "array_footprint", + *[f"width_length_{i}" for i in range(4)], + *[f"R_core_{i}" for i in range(4)], + *[f"loss_{i}" for i in range(4)], + ] + + +def test_reduced_regression_feature_columns_require_all_requested_columns(): + with pytest.raises(ValueError, match="missing required columns: loss_3"): + regression_feature_columns( + [ + "Xoff_weighted_bdt", + "Yoff_weighted_bdt", + "Xoff_intersect", + "Yoff_intersect", + "Diff_Xoff", + "Diff_Yoff", + "DispNImages", + "Erec", + "ErecS", + "EmissionHeight", + "Geomagnetic_Angle", + "array_footprint", + *[f"width_length_{i}" for i in range(4)], + *[f"R_core_{i}" for i in range(4)], + *[f"loss_{i}" for i in range(3)], + ], + profile="reduced", + ) + + # --------------------------------------------------------------------------- # telescope_features # --------------------------------------------------------------------------- diff --git a/tests/test_regression_contracts.py b/tests/test_regression_contracts.py index 1217d6b..65194da 100644 --- a/tests/test_regression_contracts.py +++ b/tests/test_regression_contracts.py @@ -114,6 +114,58 @@ def test_regression_training_contract_excludes_targets_and_uses_train_only_scale assert result["target_std"] == pytest.approx(expected_std.to_dict(), abs=0.0) +def test_regression_training_reduced_profile_selects_requested_columns(monkeypatch): + n_events = 240 + row_number = np.arange(n_events, dtype=float) + reduced_columns = [ + "Xoff_weighted_bdt", + "Yoff_weighted_bdt", + "Xoff_intersect", + "Yoff_intersect", + "Diff_Xoff", + "Diff_Yoff", + "DispNImages", + "Erec", + "ErecS", + "EmissionHeight", + "Geomagnetic_Angle", + "array_footprint", + *[f"width_length_{i}" for i in range(4)], + *[f"R_core_{i}" for i in range(4)], + *[f"loss_{i}" for i in range(4)], + ] + data = {column: row_number + offset for offset, column in enumerate(reduced_columns)} + data.update( + { + "Xoff_residual": 1.0 + row_number, + "Yoff_residual": 2.0 + row_number, + "E_residual": 0.01 + 0.001 * row_number, + } + ) + data["ErecS"] = np.full(n_events, 3.0) + data["DispNImages"] = np.full(n_events, 2) + frame = pd.DataFrame(data) + captured_model = CapturingRegressor() + monkeypatch.setattr("xgboost.XGBRegressor", lambda **_: captured_model) + monkeypatch.setattr("eventdisplay_ml.models.evaluate_regression_model", lambda *_args: {}) + + result = models.train_regression( + frame, + { + "targets": ["Xoff_residual", "Yoff_residual", "E_residual"], + "feature_profile": "reduced", + "train_test_fraction": 0.5, + "random_state": 19, + "eval_max_events": 0, + "diagnostic_max_events": 0, + "models": {"xgboost": {"hyper_parameters": {}}}, + }, + ) + + assert result["features"] == reduced_columns + assert result["models"]["xgboost"]["features"] == reduced_columns + + def test_persisted_regression_model_preserves_feature_order_and_reconstructs_truth( tmp_path, monkeypatch ): From 883d7ce61e311a14d160aac4c3f9e7050fc30f7c Mon Sep 17 00:00:00 2001 From: Gernot Maier Date: Thu, 27 Aug 2026 08:21:28 +0200 Subject: [PATCH 2/4] more robust hyperparameters --- .../configs/default_hyperparameters_stereo.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/eventdisplay_ml/configs/default_hyperparameters_stereo.json b/src/eventdisplay_ml/configs/default_hyperparameters_stereo.json index 3876217..6c91671 100644 --- a/src/eventdisplay_ml/configs/default_hyperparameters_stereo.json +++ b/src/eventdisplay_ml/configs/default_hyperparameters_stereo.json @@ -6,8 +6,8 @@ "early_stopping_rounds": 50, "eval_metric": ["rmse"], "learning_rate": 0.02, - "max_depth": 7, - "min_child_weight": 10.0, + "max_depth": 5, + "min_child_weight": 20.0, "objective": "reg:squarederror", "n_jobs": 8, "random_state": null, From 1894633ea921f5075ee011a79b26de0df96d4016 Mon Sep 17 00:00:00 2001 From: Gernot Maier Date: Fri, 28 Aug 2026 11:31:01 +0200 Subject: [PATCH 3/4] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/eventdisplay_ml/models.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/eventdisplay_ml/models.py b/src/eventdisplay_ml/models.py index 7ec0364..b1e3511 100644 --- a/src/eventdisplay_ml/models.py +++ b/src/eventdisplay_ml/models.py @@ -852,6 +852,8 @@ def train_regression(df, model_configs): # all-non-target feature set. profile = model_configs.get("feature_profile", "extended") x_cols = features.regression_feature_columns(df.columns, profile=profile) + excluded_targets = set(model_configs["targets"]) + x_cols = [col for col in x_cols if col not in excluded_targets] _logger.info(f"Features ({len(x_cols)}): {', '.join(list(x_cols))}") model_configs["features"] = list(x_cols) targets = model_configs["targets"] From 6c1e3fa7c2d2469d8903a474e2b5fc878124713e Mon Sep 17 00:00:00 2001 From: GernotMaier Date: Fri, 28 Aug 2026 11:32:50 +0200 Subject: [PATCH 4/4] changelog --- docs/changes/82.feature.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 docs/changes/82.feature.md diff --git a/docs/changes/82.feature.md b/docs/changes/82.feature.md new file mode 100644 index 0000000..4430954 --- /dev/null +++ b/docs/changes/82.feature.md @@ -0,0 +1 @@ +Add regression traing with a reduced list of features.