Skip to content

Commit 6d2e29f

Browse files
committed
WIP
1 parent 687f961 commit 6d2e29f

6 files changed

Lines changed: 88 additions & 22 deletions

File tree

environment.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ dependencies:
1212
- pyogrio>=0.12.1,<0.13
1313
- pyarrow>=22.0.0,<23
1414
- proj-data>=1.24,<2
15+
- scikit-learn>=1.4.0,<2
1516
- scipy>=1.14.0,<2
1617
- tqdm>=4.67.1,<5
1718
- exactextract>=0.2.2,<1

pixi.lock

Lines changed: 3 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: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ dependencies = [
4343
"pydantic>=2.12.5,<3",
4444
"pyogrio>=0.12.1,<0.13",
4545
"rasterio>=1.4.4,<2",
46+
"scikit-learn>=1.4.0,<2",
4647
"scipy>=1.14.0,<2",
4748
"tqdm>=4.67.1,<5",
4849
]

reVeal/config/learn_weights.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@
33
"""
44
from typing import List, Literal, Optional
55

6-
from pydantic import model_validator, FilePath, Field
6+
from pydantic import FilePath, Field
77
from typing_extensions import Annotated
88

9-
from reVeal.config.config import BaseModelStrict, BaseGridConfig
9+
from reVeal.config.config import BaseGridConfig
1010

1111

1212
class LearnWeightsConfig(BaseGridConfig):

reVeal/learn_weights.py

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,7 @@
55
samples and auto-sampled background cells as unlabeled samples. Produces a
66
score-weighted configuration with feature importances normalized as weights.
77
"""
8-
import json
98
import logging
10-
from pathlib import Path
119

1210
import geopandas as gpd
1311
import numpy as np
@@ -24,7 +22,6 @@
2422
def prepare_pu_data(
2523
grid_df,
2624
labels_gdf,
27-
attributes,
2825
background_samples=10000,
2926
test_size=0.2,
3027
validation_size=0.1,
@@ -42,8 +39,6 @@ def prepare_pu_data(
4239
Normalized grid with score attributes and a 'gid' column.
4340
labels_gdf : gpd.GeoDataFrame
4441
Point geometries representing positive label locations.
45-
attributes : list of str
46-
Column names to use as features.
4742
background_samples : int
4843
Number of background (unlabeled) cells to sample.
4944
test_size : float
@@ -153,10 +148,28 @@ def train_pu_model(grid_df, data_splits, attributes, n_estimators=500,
153148
p_tr = g[g["gid"].isin(data_splits["train_gids"])][attributes].to_numpy()
154149
u_tr = g[g["gid"].isin(data_splits["background_gids"])][attributes].to_numpy()
155150

151+
if len(p_tr) == 0:
152+
raise ValueError(
153+
"No positive training samples found. Ensure that the labels "
154+
"spatially intersect the grid."
155+
)
156+
if len(u_tr) == 0:
157+
raise ValueError(
158+
"No background (unlabeled) training samples found. Ensure that "
159+
"the grid has cells that do not intersect the labels."
160+
)
161+
156162
if class_prior is None:
157163
class_prior = len(p_tr) / (len(p_tr) + len(u_tr))
158164
logger.info(f"Auto-computed class_prior: {class_prior:.4f}")
159165

166+
if not (0 < class_prior < 1):
167+
raise ValueError(
168+
f"class_prior must be between 0 and 1 (exclusive), got {class_prior}. "
169+
"This can happen when all grid cells are labeled as positive or "
170+
"no positive samples are found."
171+
)
172+
160173
# Train model
161174
model = PUExtraTrees(
162175
n_estimators=n_estimators,
@@ -185,7 +198,8 @@ def train_pu_model(grid_df, data_splits, attributes, n_estimators=500,
185198
np.zeros(len(u_test), dtype=int),
186199
))
187200
y_pred = (model.predict(x_test) == 1).astype(int)
188-
metrics["auc"] = float(roc_auc_score(y_test, y_pred))
201+
y_score = model.predict_proba(x_test)
202+
metrics["auc"] = float(roc_auc_score(y_test, y_score))
189203
tpr = y_pred[y_test == 1].sum() / y_test.sum()
190204
metrics["tpr"] = float(tpr)
191205
logger.info(f"Test metrics: AUC={metrics['auc']:.4f}, TPR={metrics['tpr']:.4f}")
@@ -254,7 +268,8 @@ def _compute_metric(grid_df, data_splits, attributes, class_prior, n_estimators,
254268
y_pred = (model.predict(x_val) == 1).astype(int)
255269

256270
if metric == "auc":
257-
return float(roc_auc_score(y_val, y_pred))
271+
y_score = model.predict_proba(x_val)
272+
return float(roc_auc_score(y_val, y_score))
258273
elif metric == "tpr":
259274
return float(y_pred[y_val == 1].sum() / y_val.sum())
260275
else:
@@ -442,7 +457,6 @@ def run_learn_weights(config):
442457
data_splits = prepare_pu_data(
443458
grid_df=grid_df,
444459
labels_gdf=labels_gdf,
445-
attributes=attributes,
446460
background_samples=config.background_samples,
447461
test_size=config.test_size,
448462
validation_size=config.validation_size,

tests/test_learn_weights.py

Lines changed: 59 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
"""
33
Tests for learn_weights module.
44
"""
5-
import json
65
import numpy as np
76
import geopandas as gpd
87
import pytest
@@ -54,11 +53,9 @@ class TestPrepareData:
5453

5554
def test_basic_splits(self, synthetic_grid, synthetic_labels):
5655
"""Test that data splits are created with correct sizes."""
57-
attributes = ["feature_a_score", "feature_b_score", "feature_c_score"]
5856
splits = prepare_pu_data(
5957
grid_df=synthetic_grid,
6058
labels_gdf=synthetic_labels,
61-
attributes=attributes,
6259
background_samples=50,
6360
test_size=0.2,
6461
validation_size=0.1,
@@ -77,11 +74,9 @@ def test_basic_splits(self, synthetic_grid, synthetic_labels):
7774

7875
def test_background_size_capped(self, synthetic_grid, synthetic_labels):
7976
"""Test that background sampling respects available cells."""
80-
attributes = ["feature_a_score", "feature_b_score", "feature_c_score"]
8177
splits = prepare_pu_data(
8278
grid_df=synthetic_grid,
8379
labels_gdf=synthetic_labels,
84-
attributes=attributes,
8580
background_samples=1000, # More than available
8681
random_state=42,
8782
)
@@ -98,7 +93,6 @@ def test_train_produces_importances(self, synthetic_grid, synthetic_labels):
9893
splits = prepare_pu_data(
9994
grid_df=synthetic_grid,
10095
labels_gdf=synthetic_labels,
101-
attributes=attributes,
10296
background_samples=50,
10397
random_state=42,
10498
)
@@ -115,6 +109,65 @@ def test_train_produces_importances(self, synthetic_grid, synthetic_labels):
115109
assert len(results["feature_importances"]) == 3
116110
assert results["model"] is not None
117111

112+
def test_empty_positives_raises(self, synthetic_grid):
113+
"""Test that empty positive training set raises ValueError."""
114+
attributes = ["feature_a_score", "feature_b_score", "feature_c_score"]
115+
splits = {
116+
"train_gids": np.array([]),
117+
"validation_gids": np.array([]),
118+
"test_gids": np.array([]),
119+
"background_gids": np.array([0, 1, 2, 3, 4]),
120+
"background_test_gids": np.array([]),
121+
"background_val_gids": np.array([]),
122+
}
123+
with pytest.raises(ValueError, match="No positive training samples"):
124+
train_pu_model(
125+
grid_df=synthetic_grid,
126+
data_splits=splits,
127+
attributes=attributes,
128+
n_estimators=10,
129+
random_state=42,
130+
)
131+
132+
def test_empty_background_raises(self, synthetic_grid):
133+
"""Test that empty background training set raises ValueError."""
134+
attributes = ["feature_a_score", "feature_b_score", "feature_c_score"]
135+
splits = {
136+
"train_gids": np.array([0, 1, 2, 3, 4]),
137+
"validation_gids": np.array([]),
138+
"test_gids": np.array([]),
139+
"background_gids": np.array([]),
140+
"background_test_gids": np.array([]),
141+
"background_val_gids": np.array([]),
142+
}
143+
with pytest.raises(ValueError, match="No background"):
144+
train_pu_model(
145+
grid_df=synthetic_grid,
146+
data_splits=splits,
147+
attributes=attributes,
148+
n_estimators=10,
149+
random_state=42,
150+
)
151+
152+
def test_invalid_class_prior_raises(self, synthetic_grid, synthetic_labels):
153+
"""Test that class_prior outside (0,1) raises ValueError."""
154+
attributes = ["feature_a_score", "feature_b_score", "feature_c_score"]
155+
splits = prepare_pu_data(
156+
grid_df=synthetic_grid,
157+
labels_gdf=synthetic_labels,
158+
background_samples=50,
159+
random_state=42,
160+
)
161+
with pytest.raises(ValueError, match="class_prior must be between"):
162+
train_pu_model(
163+
grid_df=synthetic_grid,
164+
data_splits=splits,
165+
attributes=attributes,
166+
n_estimators=10,
167+
class_prior=1.0,
168+
random_state=42,
169+
)
170+
118171

119172
class TestImportancesToWeights:
120173
"""Tests for importances_to_weights."""
@@ -195,7 +248,6 @@ def test_full_pipeline(self, synthetic_grid, synthetic_labels):
195248
splits = prepare_pu_data(
196249
grid_df=synthetic_grid,
197250
labels_gdf=synthetic_labels,
198-
attributes=attributes,
199251
background_samples=50,
200252
random_state=42,
201253
)
@@ -235,7 +287,6 @@ def test_returns_best_prior(self, synthetic_grid, synthetic_labels):
235287
splits = prepare_pu_data(
236288
grid_df=synthetic_grid,
237289
labels_gdf=synthetic_labels,
238-
attributes=attributes,
239290
background_samples=50,
240291
random_state=42,
241292
)
@@ -262,7 +313,6 @@ def test_tpr_metric(self, synthetic_grid, synthetic_labels):
262313
splits = prepare_pu_data(
263314
grid_df=synthetic_grid,
264315
labels_gdf=synthetic_labels,
265-
attributes=attributes,
266316
background_samples=50,
267317
random_state=42,
268318
)
@@ -286,7 +336,6 @@ def test_tuned_prior_used_in_training(self, synthetic_grid, synthetic_labels):
286336
splits = prepare_pu_data(
287337
grid_df=synthetic_grid,
288338
labels_gdf=synthetic_labels,
289-
attributes=attributes,
290339
background_samples=50,
291340
random_state=42,
292341
)

0 commit comments

Comments
 (0)