|
| 1 | +# -*- coding: utf-8 -*- |
| 2 | +""" |
| 3 | +cli.learn_weights module - Sets up learn-weights command for use with NLR-GAPs CLI. |
| 4 | +""" |
| 5 | +import json |
| 6 | +import logging |
| 7 | +from pathlib import Path |
| 8 | + |
| 9 | +from pydantic import ValidationError |
| 10 | +from gaps.cli import as_click_command, CLICommandFromFunction |
| 11 | + |
| 12 | +from reVeal.config.learn_weights import LearnWeightsConfig |
| 13 | +from reVeal.log import get_logger, remove_streamhandlers |
| 14 | +from reVeal.learn_weights import run_learn_weights |
| 15 | + |
| 16 | +LOGGER = logging.getLogger(__name__) |
| 17 | + |
| 18 | + |
| 19 | +def _log_inputs(config): |
| 20 | + """ |
| 21 | + Emit log messages summarizing user inputs. |
| 22 | +
|
| 23 | + Parameters |
| 24 | + ---------- |
| 25 | + config : dict |
| 26 | + Configuration dictionary. |
| 27 | + """ |
| 28 | + LOGGER.info(f"Inputs config: {json.dumps(config, indent=4, default=str)}") |
| 29 | + |
| 30 | + |
| 31 | +def _preprocessor(config, job_name, log_directory, verbose): |
| 32 | + """ |
| 33 | + Preprocess user-input configuration. |
| 34 | +
|
| 35 | + Parameters |
| 36 | + ---------- |
| 37 | + config : dict |
| 38 | + User configuration file input as (nested) dict. |
| 39 | + job_name : str |
| 40 | + Name of job being run. |
| 41 | + verbose : bool |
| 42 | + Flag to signal DEBUG verbosity. |
| 43 | +
|
| 44 | + Returns |
| 45 | + ------- |
| 46 | + dict |
| 47 | + Configuration dictionary modified to include additional parameters. |
| 48 | + """ |
| 49 | + if verbose: |
| 50 | + log_level = "DEBUG" |
| 51 | + else: |
| 52 | + log_level = "INFO" |
| 53 | + get_logger( |
| 54 | + __name__, log_level=log_level, out_path=log_directory / f"{job_name}.log" |
| 55 | + ) |
| 56 | + |
| 57 | + LOGGER.info("Validating input configuration file") |
| 58 | + try: |
| 59 | + learn_config = { |
| 60 | + k: config.get(k) |
| 61 | + for k in LearnWeightsConfig.model_fields.keys() |
| 62 | + if k in config |
| 63 | + } |
| 64 | + LearnWeightsConfig(**learn_config) |
| 65 | + except ValidationError as e: |
| 66 | + LOGGER.error( |
| 67 | + "Configuration did not pass validation. " |
| 68 | + f"The following issues were identified:\n{e}" |
| 69 | + ) |
| 70 | + raise e |
| 71 | + LOGGER.info("Input configuration file is valid.") |
| 72 | + |
| 73 | + config["_local"] = ( |
| 74 | + config.get("execution_control", {}).get("option", "local") == "local" |
| 75 | + ) |
| 76 | + _log_inputs(config) |
| 77 | + |
| 78 | + return config |
| 79 | + |
| 80 | + |
| 81 | +def run( |
| 82 | + grid, |
| 83 | + labels, |
| 84 | + out_dir, |
| 85 | + attributes=None, |
| 86 | + n_estimators=500, |
| 87 | + class_prior=None, |
| 88 | + background_samples=10000, |
| 89 | + test_size=0.2, |
| 90 | + validation_size=0.1, |
| 91 | + n_jobs=1, |
| 92 | + random_state=42, |
| 93 | + score_name="suitability_score", |
| 94 | + crs="EPSG:5070", |
| 95 | + tune=False, |
| 96 | + n_trials=20, |
| 97 | + tuning_metric="auc", |
| 98 | + _local=True, |
| 99 | +): |
| 100 | + """ |
| 101 | + Learn feature weights from labeled data using PU (Positive-Unlabeled) learning. |
| 102 | +
|
| 103 | + Trains a PUExtraTrees model on a normalized grid using point labels as positive |
| 104 | + samples and auto-sampled background cells as unlabeled samples. Outputs a |
| 105 | + score-weighted configuration JSON with feature importances normalized as weights |
| 106 | + (summing to 1.0). |
| 107 | +
|
| 108 | + Parameters |
| 109 | + ---------- |
| 110 | + grid : str |
| 111 | + Path to the normalized grid (output of ``reVeal normalize``). Must be a |
| 112 | + vector dataset readable by pyogrio with numeric ``*_score`` columns. |
| 113 | + labels : str |
| 114 | + Path to a point geometry dataset (GeoPackage, shapefile, etc.) containing |
| 115 | + positive sample locations. These represent known sites (e.g., data center |
| 116 | + locations). |
| 117 | + out_dir : str |
| 118 | + Output directory. Results will be saved as ``config_score_weighted.json`` |
| 119 | + and ``learn_weights_metrics.json``. |
| 120 | + attributes : list of str, optional |
| 121 | + List of column names from the grid to use as features. If not specified, |
| 122 | + all columns ending with ``_score`` are used automatically. |
| 123 | + n_estimators : int, optional |
| 124 | + Number of trees in the PUExtraTrees forest. Default is 500. |
| 125 | + class_prior : float, optional |
| 126 | + Prior probability that a sample is positive. If not specified, computed |
| 127 | + automatically as ``n_positive / (n_positive + n_background)``. |
| 128 | + background_samples : int, optional |
| 129 | + Number of background (unlabeled) cells to sample from non-intersecting |
| 130 | + grid cells. Default is 10000. |
| 131 | + test_size : float, optional |
| 132 | + Fraction of positive cells to hold out for model evaluation. Default is 0.2. |
| 133 | + validation_size : float, optional |
| 134 | + Fraction of training positives for validation. Default is 0.1. |
| 135 | + n_jobs : int, optional |
| 136 | + Number of parallel jobs for model training. Default is 1. |
| 137 | + random_state : int, optional |
| 138 | + Random seed for reproducibility. Default is 42. |
| 139 | + score_name : str, optional |
| 140 | + Name of the output score column in the generated config. Default is |
| 141 | + ``suitability_score``. |
| 142 | + crs : str, optional |
| 143 | + Coordinate reference system for spatial operations. Default is ``EPSG:5070``. |
| 144 | + tune : bool, optional |
| 145 | + If True and class_prior is not set, use Optuna to find the optimal |
| 146 | + class_prior by maximizing the tuning_metric on the validation set. |
| 147 | + Default is False. |
| 148 | + n_trials : int, optional |
| 149 | + Number of Optuna trials for hyperparameter tuning. Default is 20. |
| 150 | + tuning_metric : str, optional |
| 151 | + Metric to maximize during tuning: 'auc' or 'tpr'. Default is 'auc'. |
| 152 | + _local : bool |
| 153 | + Flag indicating local vs HPC execution. Not user-provided. |
| 154 | + """ |
| 155 | + # pylint: disable=unused-argument |
| 156 | + |
| 157 | + if _local: |
| 158 | + remove_streamhandlers(LOGGER.parent) |
| 159 | + |
| 160 | + config = LearnWeightsConfig( |
| 161 | + grid=grid, |
| 162 | + labels=labels, |
| 163 | + attributes=attributes, |
| 164 | + n_estimators=n_estimators, |
| 165 | + class_prior=class_prior, |
| 166 | + background_samples=background_samples, |
| 167 | + test_size=test_size, |
| 168 | + validation_size=validation_size, |
| 169 | + n_jobs=n_jobs, |
| 170 | + random_state=random_state, |
| 171 | + score_name=score_name, |
| 172 | + crs=crs, |
| 173 | + tune=tune, |
| 174 | + n_trials=n_trials, |
| 175 | + tuning_metric=tuning_metric, |
| 176 | + ) |
| 177 | + |
| 178 | + LOGGER.info("Running learn-weights pipeline...") |
| 179 | + results = run_learn_weights(config) |
| 180 | + |
| 181 | + out_path = Path(out_dir) |
| 182 | + out_path.mkdir(parents=True, exist_ok=True) |
| 183 | + |
| 184 | + # Save score-weighted config |
| 185 | + config_out = out_path / "config_score_weighted.json" |
| 186 | + LOGGER.info(f"Saving score-weighted config to {config_out}...") |
| 187 | + with open(config_out, "w") as f: |
| 188 | + json.dump(results["config"], f, indent=2) |
| 189 | + |
| 190 | + # Save metrics |
| 191 | + if results["metrics"]: |
| 192 | + metrics_out = out_path / "learn_weights_metrics.json" |
| 193 | + LOGGER.info(f"Saving metrics to {metrics_out}...") |
| 194 | + with open(metrics_out, "w") as f: |
| 195 | + json.dump(results["metrics"], f, indent=2) |
| 196 | + |
| 197 | + # Save tuning results |
| 198 | + if results.get("tuning"): |
| 199 | + tuning_out = out_path / "learn_weights_tuning.json" |
| 200 | + LOGGER.info(f"Saving tuning results to {tuning_out}...") |
| 201 | + with open(tuning_out, "w") as f: |
| 202 | + json.dump(results["tuning"], f, indent=2) |
| 203 | + |
| 204 | + LOGGER.info("learn-weights complete.") |
| 205 | + |
| 206 | + |
| 207 | +learn_weights_cmd = CLICommandFromFunction( |
| 208 | + function=run, |
| 209 | + name="learn-weights", |
| 210 | + add_collect=False, |
| 211 | + config_preprocessor=_preprocessor, |
| 212 | +) |
| 213 | + |
| 214 | +main = as_click_command(learn_weights_cmd) |
| 215 | + |
| 216 | + |
| 217 | +if __name__ == "__main__": |
| 218 | + try: |
| 219 | + main(obj={}) |
| 220 | + except Exception: |
| 221 | + LOGGER.exception("Error running reVeal learn-weights command.") |
| 222 | + raise |
0 commit comments