Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
6 changes: 3 additions & 3 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@
repos:
# Ruff
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.16
rev: v0.16.1
hooks:
- id: ruff
args: ["--fix"]
- id: ruff-format
# https://pycqa.github.io/isort/docs/configuration/black_compatibility.html#integration-with-pre-commit
- repo: https://github.com/pycqa/isort
rev: 9.0.0a3
rev: 9.0.0b1
hooks:
- id: isort
args: ["--profile", "black", "--filter-files"]
Expand All @@ -34,7 +34,7 @@ repos:
# - id: actionlint
# codespell
- repo: https://github.com/codespell-project/codespell
rev: v2.4.2
rev: v2.4.3
hooks:
- id: codespell
args: [
Expand Down
1 change: 1 addition & 0 deletions docs/changes/80.maintenance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add strong testing for regression path.
4 changes: 1 addition & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ indent-width = 4
exclude = [
"__init__.py",
"pyproject.toml",
"tests/",
]

format.indent-style = "space"
Expand Down Expand Up @@ -132,9 +133,6 @@ lint.ignore = [

lint.pydocstyle.convention = "numpy"

[tool.ruff.lint.per-file-ignores]
"tests/**.py" = ["D103"]

[tool.codespell]
ignore-words-list = "chec,arrang,livetime"

Expand Down
4 changes: 3 additions & 1 deletion src/eventdisplay_ml/data_processing.py
Original file line number Diff line number Diff line change
Expand Up @@ -824,7 +824,9 @@ def _flatten_training_chunk(
disp_erec = df_flat["ErecS"].values

# Compute log energies (ErecS already filtered > 0)
mc_e0_log = np.where(mc_e0 > 0, np.log10(mc_e0), np.nan)
mc_e0_log = np.full_like(mc_e0, np.nan, dtype=np.float32)
valid_mc_energy = mc_e0 > 0
mc_e0_log[valid_mc_energy] = np.log10(mc_e0[valid_mc_energy])
disp_erec_log = np.log10(disp_erec) # Safe since already filtered > 0

new_cols = {
Expand Down
4 changes: 2 additions & 2 deletions src/eventdisplay_ml/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@ def _validate_energy_bin_metadata(energy_bin, model_file):
Validated metadata containing ``E_min`` and ``E_max`` keys.
"""
if not isinstance(energy_bin, dict):
raise ValueError(
raise TypeError(
"Classification model file "
f"'{model_file}' has invalid 'energy_bins_log10_tev' metadata: "
"expected a dict with keys 'E_min' and 'E_max'."
Expand Down Expand Up @@ -601,7 +601,7 @@ def process_file_chunked(analysis_type, model_configs):
{
eff
for e_bin_models in model_configs["models"].values()
for eff in (e_bin_models.get("thresholds") or {}).keys()
for eff in (e_bin_models.get("thresholds") or {})
}
)

Expand Down
4 changes: 2 additions & 2 deletions src/eventdisplay_ml/scripts/diagnostic_shap_summary.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,8 +156,8 @@ def main():
output_file = utils.joblib_basename(model_path)
try:
process_model_file(model_path, output_dir, output_file)
except Exception as e:
_logger.exception(f"Skipping {model_path}: failed to process model ({e})")
except Exception:
_logger.exception(f"Skipping {model_path}: failed to process model")
continue

_logger.info(f"\nPlots saved to {output_dir}")
Expand Down
2 changes: 1 addition & 1 deletion src/eventdisplay_ml/scripts/optimize_classification.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ def _extract_tgraph2d(graph):
try:
x, y, z = graph.values()
return np.asarray(x), np.asarray(y), np.asarray(z)
except Exception:
except (AttributeError, ValueError, TypeError):
x = np.asarray(graph.member("fX"))
y = np.asarray(graph.member("fY"))
z = np.asarray(graph.member("fZ"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ def get_containment_data(directory):
_logger.info(f"Percentiles p70: {p70}, p95: {p95}")
else:
_logger.warning("Nan percentiles")
except Exception as e:
except (OSError, ValueError, KeyError) as e:
_logger.error(f"Failed reading {filename}: {e}")

return pd.DataFrame(results)
Expand Down
6 changes: 3 additions & 3 deletions src/eventdisplay_ml/scripts/plot_training_evaluation.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ def main():
type=str,
help=(
"Directory containing multiple joblib model files. "
"All *.joblib files will be processed.",
"All *.joblib files will be processed."
),
)
parser.add_argument(
Expand Down Expand Up @@ -218,8 +218,8 @@ def main():
)
plot_training_curves(evals_result, output_file)
_logger.info(f"Saved plot for {model_path.name} to {output_file}")
except Exception as e:
_logger.exception(f"Skipping {model_path}: failed to process model ({e})")
except Exception:
_logger.exception(f"Skipping {model_path}: failed to process model")
continue

_logger.info("Batch plotting completed.")
Expand Down
6 changes: 5 additions & 1 deletion src/eventdisplay_ml/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,11 @@ def discover_joblib_files(model_dir):
if not model_dir.exists() or not model_dir.is_dir():
raise FileNotFoundError(f"Model directory not found: {model_dir}")

discovered_files = sorted(set(model_dir.glob("*.joblib")).union(model_dir.glob("*.joblib.gz")))
discovered_files = sorted(
path
for path in set(model_dir.glob("*.joblib")).union(model_dir.glob("*.joblib.gz"))
if path.is_file()
)
files_by_name = {}
for model_path in discovered_files:
key = joblib_basename(model_path)
Expand Down
87 changes: 87 additions & 0 deletions tests/scripts/test_stereo_entrypoints.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"""Unit tests for production stereo-regression console-script wiring."""

from unittest.mock import MagicMock

import pandas as pd
import pytest

from eventdisplay_ml.scripts import apply_xgb_stereo, train_xgb_stereo


def test_train_stereo_entrypoint_runs_the_complete_regression_pipeline(monkeypatch, caplog):
"""The training CLI must connect configuration, loading, training, and saving unchanged."""
caplog.set_level("INFO")
configured = {"input_file_list": "gamma_inputs.txt", "model_prefix": "stereo_model"}
loaded_data = pd.DataFrame({"feature": [1.0]})
trained = {"model_prefix": "stereo_model", "models": {"xgboost": {"model": object()}}}
configure = MagicMock(return_value=configured)
load_data = MagicMock(return_value=loaded_data)
train = MagicMock(return_value=trained)
save = MagicMock()
monkeypatch.setattr(train_xgb_stereo, "configure_training", configure)
monkeypatch.setattr(train_xgb_stereo, "load_training_data", load_data)
monkeypatch.setattr(train_xgb_stereo, "train_regression", train)
monkeypatch.setattr(train_xgb_stereo, "save_models", save)

train_xgb_stereo.main()

configure.assert_called_once_with("stereo_analysis")
load_data.assert_called_once_with(configured, "gamma_inputs.txt", "stereo_analysis")
train.assert_called_once_with(loaded_data, configured)
save.assert_called_once_with(trained)
assert "stereo_analysis model trained successfully" in caplog.text


def test_train_stereo_entrypoint_does_not_save_when_regression_training_fails(monkeypatch):
"""A failed regression fit must propagate and never create a partial artifact."""
configured = {"input_file_list": "gamma_inputs.txt"}
save = MagicMock()
monkeypatch.setattr(train_xgb_stereo, "configure_training", lambda *_args: configured)
monkeypatch.setattr(
train_xgb_stereo, "load_training_data", lambda *_args: pd.DataFrame({"feature": [1.0]})
)
monkeypatch.setattr(
train_xgb_stereo,
"train_regression",
lambda *_args: (_ for _ in ()).throw(RuntimeError("fit failed")),
)
monkeypatch.setattr(train_xgb_stereo, "save_models", save)

with pytest.raises(RuntimeError, match="fit failed"):
train_xgb_stereo.main()

save.assert_not_called()


def test_apply_stereo_entrypoint_passes_the_loaded_configuration_to_streaming(monkeypatch):
"""The apply CLI must select stereo analysis and preserve loaded model metadata."""
configured = {
"models": {"xgboost": {"model": object()}},
"target_mean": {"Xoff_residual": 0.0},
"target_std": {"Xoff_residual": 1.0},
}
configure = MagicMock(return_value=configured)
process = MagicMock()
monkeypatch.setattr(apply_xgb_stereo, "configure_apply", configure)
monkeypatch.setattr(apply_xgb_stereo, "process_file_chunked", process)

apply_xgb_stereo.main()

configure.assert_called_once_with("stereo_analysis")
process.assert_called_once_with("stereo_analysis", configured)


def test_apply_stereo_entrypoint_does_not_stream_when_configuration_fails(monkeypatch):
"""Invalid model configuration must stop before input ROOT data are processed."""
process = MagicMock()
monkeypatch.setattr(
apply_xgb_stereo,
"configure_apply",
lambda *_args: (_ for _ in ()).throw(ValueError("missing target_std")),
)
monkeypatch.setattr(apply_xgb_stereo, "process_file_chunked", process)

with pytest.raises(ValueError, match="missing target_std"):
apply_xgb_stereo.main()

process.assert_not_called()
18 changes: 11 additions & 7 deletions tests/test_models_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,9 @@ def test_load_classification_models_rejects_missing_energy_bin_metadata(tmp_path
tmp_path / "model_ebin0.joblib",
)

with pytest.raises(ValueError, match=r"model_ebin0\.joblib.*energy_bins_log10_tev"):
with pytest.raises(
(ValueError, TypeError), match=r"model_ebin0\.joblib.*energy_bins_log10_tev"
):
models.load_classification_models(str(prefix), "xgboost")


Expand Down Expand Up @@ -367,12 +369,14 @@ def test_process_file_chunked_uses_tmva_style_features_when_flag_set():
def fake_open(path):
raise RuntimeError("uproot not needed for this assertion")

with patch("eventdisplay_ml.models.uproot.open", side_effect=fake_open):
with pytest.raises(RuntimeError, match="uproot not needed"):
models.process_file_chunked(
"classification",
{"tmva_style": True, "input_file": "dummy.root"},
)
with (
patch("eventdisplay_ml.models.uproot.open", side_effect=fake_open),
pytest.raises(RuntimeError, match="uproot not needed"),
):
models.process_file_chunked(
"classification",
{"tmva_style": True, "input_file": "dummy.root"},
)

# Verify that tmva_style features differ from regular features
assert set(expected) != set(regular_features)
Expand Down
Loading