Skip to content

Commit 687f961

Browse files
committed
Add learn-weights CLI step with PU learning and Optuna tuning
Introduces a new `reVeal learn-weights` pipeline step that trains a PUExtraTrees model on a normalized grid using point labels as positive samples. Derives feature importance weights for use with `score-weighted`. - Add reVeal/pu/ — PUExtraTree/PUExtraTrees adapted from https://github.com/jonathanwilton/PUExtraTrees, with modifications from /projects/largeload/.../models/PUExtraTrees/ (deterministic seeding, joblib parallelism) - Add reVeal/learn_weights.py — data preparation logic adapted from /projects/largeload/.../models/prepare_data.py (DataHandler), training and metrics from /projects/largeload/.../models/models.py (ModelTrainer) - Add Optuna-based hyperparameter tuning for class_prior — adapted from ModelTrainer.tune_hyperparameters() and ModelTrainer.objective() in /projects/largeload/.../models/models.py - Add CLI command (reVeal/cli/learn_weights.py), config (reVeal/config/learn_weights.py), and tests — new code following existing reVeal CLI patterns (score_weighted, normalize) - Add joblib, scipy, optuna to pyproject.toml and environment.yml
1 parent dc210e6 commit 687f961

11 files changed

Lines changed: 2458 additions & 3 deletions

File tree

environment.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,14 @@ dependencies:
88
- geopandas>=1.1.1,<2
99
- rasterio>=1.4.4,<2
1010
- gdal>=3.12.1,<4
11+
- joblib>=1.4.0,<2
1112
- pyogrio>=0.12.1,<0.13
1213
- pyarrow>=22.0.0,<23
1314
- proj-data>=1.24,<2
15+
- scipy>=1.14.0,<2
1416
- tqdm>=4.67.1,<5
1517
- exactextract>=0.2.2,<1
18+
- optuna>=4.0.0,<5
1619
- pydantic>=2.12.5,<3
1720
- libpysal>=4.13.0,<5
1821
- pip>=25.2

pixi.lock

Lines changed: 400 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,17 @@ dependencies = [
3333
"exactextract>=0.3.0,<0.4",
3434
"geopandas>=1.1.1,<2",
3535
"gdal>=3.10.3,<4",
36+
"joblib>=1.4.0,<2",
3637
"libpysal>=4.13.0,<5",
3738
"NLR-GAPs>=0.9.1,<1",
3839
"NLR-rex>=0.5.0,<1",
40+
"optuna>=4.0.0,<5",
3941
"pandas>=2.3.3,<3",
4042
"pyarrow>=22.0.0,<23",
4143
"pydantic>=2.12.5,<3",
4244
"pyogrio>=0.12.1,<0.13",
4345
"rasterio>=1.4.4,<2",
46+
"scipy>=1.14.0,<2",
4447
"tqdm>=4.67.1,<5",
4548
]
4649

reVeal/cli/cli.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@
99
from reVeal.cli.normalize import normalize_cmd
1010
from reVeal.cli.score_weighted import score_weighted_cmd
1111
from reVeal.cli.downscale import downscale_cmd
12+
from reVeal.cli.learn_weights import learn_weights_cmd
1213

1314
logger = logging.getLogger(__name__)
1415

15-
commands = [characterize_cmd, normalize_cmd, score_weighted_cmd, downscale_cmd]
16+
commands = [characterize_cmd, normalize_cmd, score_weighted_cmd, downscale_cmd,
17+
learn_weights_cmd]
1618
main = make_cli(commands, info={"name": "reVeal", "version": __version__})
1719

1820
# export GAPs commands to namespace for documentation

reVeal/cli/learn_weights.py

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
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

reVeal/config/learn_weights.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"""
2+
config.learn_weights module - Configuration for learn-weights command.
3+
"""
4+
from typing import List, Literal, Optional
5+
6+
from pydantic import model_validator, FilePath, Field
7+
from typing_extensions import Annotated
8+
9+
from reVeal.config.config import BaseModelStrict, BaseGridConfig
10+
11+
12+
class LearnWeightsConfig(BaseGridConfig):
13+
"""
14+
Configuration for the learn-weights command.
15+
16+
Defines inputs for training a PU (Positive-Unlabeled) ExtraTrees model
17+
on a normalized grid to derive feature importance weights.
18+
"""
19+
20+
labels: FilePath
21+
attributes: Optional[List[str]] = None
22+
n_estimators: Annotated[int, Field(ge=10, le=10000)] = 500
23+
class_prior: Optional[Annotated[float, Field(gt=0, lt=1)]] = None
24+
background_samples: Annotated[int, Field(ge=100)] = 10000
25+
test_size: Annotated[float, Field(gt=0, lt=1)] = 0.2
26+
validation_size: Annotated[float, Field(gt=0, lt=1)] = 0.1
27+
n_jobs: Annotated[int, Field(ge=1)] = 1
28+
random_state: int = 42
29+
score_name: str = "suitability_score"
30+
crs: str = "EPSG:5070"
31+
tune: bool = False
32+
n_trials: Annotated[int, Field(ge=1, le=1000)] = 20
33+
tuning_metric: Literal["auc", "tpr"] = "auc"

0 commit comments

Comments
 (0)