Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
12 changes: 12 additions & 0 deletions src/eventdisplay_ml/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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, "
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
57 changes: 57 additions & 0 deletions src/eventdisplay_ml/features.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 5 additions & 3 deletions src/eventdisplay_ml/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
Copilot marked this conversation as resolved.
targets = model_configs["targets"]
Expand Down
27 changes: 27 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
66 changes: 66 additions & 0 deletions tests/test_features.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
excluded_features,
features,
features_tmva_style,
regression_feature_columns,
target_features,
telescope_features,
)
Expand Down Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down
52 changes: 52 additions & 0 deletions tests/test_regression_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
):
Expand Down