From 3f5667a0160cfb398491d7a6542f96a64312bf8d Mon Sep 17 00:00:00 2001 From: Owen Queen Date: Mon, 22 Jun 2026 15:12:31 -0700 Subject: [PATCH] Submitting owenqueen PZ data challenge entry: Task Set 1 photo-z via per-quantile HistGradientBoostingRegressor on LSST+Roman magnitudes and colors. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/submit_owenqueen.yaml | 38 ++++ requirements_owenqueen.txt | 3 + tests/test_owenqueen.py | 221 ++++++++++++++++++++++++ 3 files changed, 262 insertions(+) create mode 100644 .github/workflows/submit_owenqueen.yaml create mode 100644 requirements_owenqueen.txt create mode 100644 tests/test_owenqueen.py diff --git a/.github/workflows/submit_owenqueen.yaml b/.github/workflows/submit_owenqueen.yaml new file mode 100644 index 0000000..283dba3 --- /dev/null +++ b/.github/workflows/submit_owenqueen.yaml @@ -0,0 +1,38 @@ +--- +# This workflow will install Python dependencies and run tests + +name: Unit test and code coverage + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.13'] + submission: ['owenqueen'] + + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + sudo apt-get update + sudo apt install libbz2-dev + python -m pip install --upgrade pip + pip install wheel + pip install . + pip install .[dev] + if [ -f requirements_${{ matrix.submission }}.txt ]; then pip install -r requirements_${{ matrix.submission }}.txt; fi + - name: Run unit tests with pytest + run: | + python -m pytest tests/test_${{ matrix.submission }}.py diff --git a/requirements_owenqueen.txt b/requirements_owenqueen.txt new file mode 100644 index 0000000..b8f4fb6 --- /dev/null +++ b/requirements_owenqueen.txt @@ -0,0 +1,3 @@ +scikit-learn +numpy +pandas diff --git a/tests/test_owenqueen.py b/tests/test_owenqueen.py new file mode 100644 index 0000000..bdbab21 --- /dev/null +++ b/tests/test_owenqueen.py @@ -0,0 +1,221 @@ +import os +import pickle +from pathlib import Path + +import numpy as np +import qp +import tables_io +import pytest +from sklearn.ensemble import HistGradientBoostingRegressor + +# These are used by test scripts +from pz_data_challenge.taskset_1 import run_taskset_1 + +from pz_data_challenge import submit_utils + +# Change these to match the name of the submission +# and a URL to download the submission data files +# and needed model files +SUBMISSION_NAME: str = "owenqueen" +SUBMISSION_URL: str = "https://github.com/owencqueen/pz_data_challenge/releases/download/submit-owenqueen/submit_owenqueen.tgz" + +# don't change these +SUBMIT_DIR: str = f"submissions/{SUBMISSION_NAME}" +PUBLIC_AREA: str = "tests/public" + +# --------------------------------------------------------------------------- +# Photo-z model: quantile regression on magnitudes + colors. +# +# HistGradientBoostingRegressor handles NaN (non-detections) natively, so we +# can feed magnitudes/colors directly without imputation. We fit one regressor +# per target quantile of redshift; the per-object predicted quantiles define a +# full p(z) via qp's quantile parameterization. The median quantile is used as +# the `zmode` point estimate. +# --------------------------------------------------------------------------- + +# Bands ordered by effective wavelength (blue -> red): LSST ugrizy + Roman YJH. +_BAND_ORDER = ["u", "g", "r", "i", "z", "y", "Y", "J", "H"] +_LSST_BANDS = {"u", "g", "r", "i", "z", "y"} + +# CDF levels at which we predict redshift quantiles. +_QUANTILES = np.array( + [0.02, 0.05, 0.10, 0.16, 0.25, 0.40, 0.50, 0.60, 0.75, 0.84, 0.90, 0.95, 0.98] +) + + +def _band_col(b: str) -> str: + return f"mag_{b}_lsst" if b in _LSST_BANDS else f"mag_{b}_roman" + + +def _load_df(path: str | Path): + return tables_io.convert(tables_io.read(str(path)), tables_io.types.PD_DATAFRAME) + + +def _build_features(df) -> tuple[np.ndarray, list[str]]: + """Magnitudes + adjacent colors for whatever bands are present.""" + present = [b for b in _BAND_ORDER if _band_col(b) in df.columns] + mags = {b: df[_band_col(b)].to_numpy(dtype=float) for b in present} + + feats: list[np.ndarray] = [] + names: list[str] = [] + for b in present: + feats.append(mags[b]) + names.append(f"mag_{b}") + for b1, b2 in zip(present[:-1], present[1:]): + feats.append(mags[b1] - mags[b2]) + names.append(f"col_{b1}_{b2}") + + return np.column_stack(feats), names + + +def _train_model(train_file: str | Path) -> dict: + """Fit one quantile regressor per CDF level; return a picklable dict.""" + df = _load_df(train_file) + X, feat_names = _build_features(df) + y = df["redshift"].to_numpy(dtype=float) + + regressors = [] + for q in _QUANTILES: + reg = HistGradientBoostingRegressor( + loss="quantile", + quantile=float(q), + max_iter=300, + learning_rate=0.08, + max_leaf_nodes=63, + min_samples_leaf=60, + l2_regularization=1.0, + early_stopping=False, + random_state=42, + ) + reg.fit(X, y) + regressors.append(reg) + + return { + "quantiles": _QUANTILES, + "regressors": regressors, + "feat_names": feat_names, + "z_max": float(y.max()), + } + + +def _estimate(model: dict, test_file: str | Path, output_file: str | Path) -> None: + """Predict per-object redshift quantiles and write a qp.quant ensemble.""" + df = _load_df(test_file) + X, _ = _build_features(df) + + quants = model["quantiles"] + # (n_obj, n_quant) matrix of predicted z at each CDF level. + preds = np.column_stack([reg.predict(X) for reg in model["regressors"]]) + # Enforce monotonic, non-negative quantiles (fix any quantile crossing). + preds = np.clip(preds, 0.0, None) + preds = np.maximum.accumulate(preds, axis=1) + + median_idx = int(np.argmin(np.abs(quants - 0.5))) + zmode = preds[:, median_idx] + + ens = qp.Ensemble(qp.quant, data=dict(quants=quants, locs=preds)) + ens.set_ancil( + { + "object_id": df["object_id"].astype("int64").to_numpy(), + "zmode": zmode, + } + ) + + os.makedirs(os.path.dirname(os.path.abspath(str(output_file))), exist_ok=True) + ens.write_to(str(output_file)) + + +@pytest.fixture(name="setup_submit_area", scope="module") +def setup_submit_area(request: pytest.FixtureRequest) -> int: + """ + A pytest fixture to download the submission data + + If all the submission data are in a tar file with the + proper structure you should not need to change this function. + """ + + if not os.path.exists(SUBMIT_DIR): + if not SUBMISSION_URL: + raise ValueError(f"SUBMISSION_URL in tests/test_{SUBMISSION_NAME}.py has not been set") + submit_utils.download_and_extract_tar(SUBMISSION_URL, SUBMIT_DIR) + + def teardown_submit_area() -> None: + if not os.environ.get("NO_TEARDOWN"): + os.system(f"\\rm -rf {SUBMIT_DIR}") + + try: + os.makedirs(os.path.join(SUBMIT_DIR, "outputs_2")) + except Exception: + pass + + try: + os.makedirs(os.path.join(SUBMIT_DIR, "outputs_3")) + except Exception: + pass + + request.addfinalizer(teardown_submit_area) + + return 0 + + +# --------------------------------------------------------------------------- +# Subtask 2: estimation only -- load a pre-trained model and estimate p(z). +# --------------------------------------------------------------------------- +def run_taskset_1_estimation_only( + model_file: str | Path, + test_file: str | Path, + output_file: str | Path, +) -> None: + """ + User supplied function to run estimation for task set 1 + + This function should use a model stored in model_file, which + is downloaded as part of the submission tar file. + + This function should write output data to output_file in qp + format. + """ + with open(model_file, "rb") as fin: + model = pickle.load(fin) + _estimate(model, test_file, output_file) + + +# --------------------------------------------------------------------------- +# Subtask 3: train a model on train_file, then estimate p(z) on test_file. +# --------------------------------------------------------------------------- +def run_taskset_1_training_and_estimation( + train_file: str | Path, + test_file: str | Path, + output_file: str | Path, +) -> None: + """ + User supplied function to run training and estimation for task set 1 + + This function should train a model and use it. + + This function should write output data to output_file in qp + format. + """ + model = _train_model(train_file) + _estimate(model, test_file, output_file) + + +def test_example_taskset_1( + setup_public_area: int, + setup_submit_area: int, +) -> None: + """ + Test function to validate a submission for Taskset 1 + + You should not need to change this function + """ + + assert setup_public_area == 0 + assert setup_submit_area == 0 + + run_taskset_1( + PUBLIC_AREA, + SUBMISSION_NAME, + run_taskset_1_estimation_only, + run_taskset_1_training_and_estimation, + )