Skip to content

Commit b028446

Browse files
feat: add probability-aware metric support across search and evaluation (#187)
* feat: add probability-aware metric support across search and evaluation * chore: bump version to 1.4.3 * fix: move baseline predict_proba requirement into critical guidance * fix: handle lower-metric early stopping without baseline * fix: harden probability normalization shape checks * fix: harden split validation and keras probability handling * fix: clarify multiclass probability shape mismatch errors * fix: handle legacy predictor metadata in predict_proba * fix: allow pytorch proba inference without metadata * fix: validate direction assignment and legacy keras proba * fix: reject non-finite keras probability outputs * refactor: simplify keras logits heuristic
1 parent 81c6b48 commit b028446

32 files changed

Lines changed: 1514 additions & 78 deletions

Makefile

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ help:
4545
@echo ""
4646
@echo "📊 Example Datasets:"
4747
@echo " make run-titanic Run on Titanic dataset (medium)"
48+
@echo " make run-titanic-proba Run Titanic with probability-focused intent"
4849
@echo " make run-house-prices Run on House Prices dataset (regression)"
4950
@echo ""
5051
@echo "🏗️ Building:"
@@ -331,6 +332,32 @@ run-titanic: build
331332
--spark-mode local \
332333
--enable-final-evaluation
333334

335+
# Spaceship Titanic dataset with probability-focused objective
336+
.PHONY: run-titanic-proba
337+
run-titanic-proba: build
338+
@echo "📊 Running on Spaceship Titanic dataset (probability-focused)..."
339+
$(eval TIMESTAMP := $(shell date +%Y%m%d_%H%M%S))
340+
docker run --rm \
341+
--add-host=host.docker.internal:host-gateway \
342+
$(CONFIG_MOUNT) \
343+
$(CONFIG_ENV) \
344+
-v $(PWD)/examples/datasets:/data:ro \
345+
-v $(PWD)/workdir:/workdir \
346+
-e OPENAI_API_KEY=$(OPENAI_API_KEY) \
347+
-e ANTHROPIC_API_KEY=$(ANTHROPIC_API_KEY) \
348+
-e SPARK_LOCAL_CORES=4 \
349+
-e SPARK_DRIVER_MEMORY=4g \
350+
plexe:py$(PYTHON_VERSION) \
351+
python -m plexe.main \
352+
--train-dataset-uri /data/spaceship-titanic/train.parquet \
353+
--user-id dev_user \
354+
--intent "predict each passenger's probability of being transported; optimize probability quality and ranking" \
355+
--experiment-id titanic_proba \
356+
--max-iterations 5 \
357+
--work-dir /workdir/titanic_proba/$(TIMESTAMP) \
358+
--spark-mode local \
359+
--enable-final-evaluation
360+
334361
# House Prices dataset (regression)
335362
.PHONY: run-house-prices
336363
run-house-prices: build

plexe/CODE_INDEX.md

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Code Index: plexe
22

3-
> Generated on 2026-03-03 00:06:47
3+
> Generated on 2026-03-03 05:08:33
44
55
Code structure and public interface documentation for the **plexe** package.
66

@@ -222,6 +222,8 @@ Helper functions for workflow.
222222

223223
**Functions:**
224224
- `select_viable_model_types(data_layout: DataLayout, selected_frameworks: list[str] | None) -> list[str]` - Select viable model types using three-tier filtering.
225+
- `metric_requires_probabilities(metric_name: str) -> bool` - Return True when a metric requires probability scores instead of hard labels.
226+
- `normalize_probability_predictions(y_true: np.ndarray, y_pred_proba: Any, metric_name: str) -> np.ndarray` - Normalize probability predictions for sklearn metric compatibility.
225227
- `evaluate_on_sample(spark: SparkSession, sample_uri: str, model_artifacts_path: Path, model_type: str, metric: str, target_columns: list[str], group_column: str | None, train_sample_uri: str | None) -> tuple[float, float | None]` - Evaluate model on validation sample, optionally also on training sample.
226228
- `compute_metric_hardcoded(y_true, y_pred, metric_name: str) -> float` - Compute metric using hardcoded sklearn implementations.
227229
- `compute_metric(y_true, y_pred, metric_name: str, group_ids) -> float` - Compute metric value.
@@ -393,7 +395,12 @@ Insight store for accumulating learnings from search.
393395
Search journal for tracking model search tree.
394396

395397
**`SearchJournal`** - Tracks solution search tree.
396-
- `__init__(self, baseline: Baseline | None)`
398+
- `__init__(self, baseline: Baseline | None, optimization_direction: str)`
399+
- `optimization_direction(self) -> str` - Metric optimization direction, constrained to {'higher', 'lower'}.
400+
- `optimization_direction(self, value: str) -> None` - Validate and set optimization direction.
401+
- `selection_score(self, value: float) -> float` - Normalize a metric value so larger always means better.
402+
- `is_better(self, candidate: float, reference: float | None) -> bool` - Compare two metric values using the configured optimization direction.
403+
- `sort_key(self, node: Solution) -> float` - Direction-aware sort key for solution nodes.
397404
- `add_node(self, node: Solution) -> None` - Add a solution to the journal.
398405
- `draft_nodes(self) -> list[Solution]` - Get all root nodes (bootstrap solutions without parents).
399406
- `buggy_nodes(self) -> list[Solution]` - Get all buggy nodes that could be debugged.
@@ -447,6 +454,7 @@ Standard CatBoost predictor - NO Plexe dependencies.
447454
**`CatBoostPredictor`** - Standalone CatBoost predictor.
448455
- `__init__(self, model_dir: str)`
449456
- `predict(self, x: pd.DataFrame) -> pd.DataFrame` - Make predictions on input DataFrame.
457+
- `predict_proba(self, x: pd.DataFrame) -> pd.DataFrame` - Predict per-class probabilities on input DataFrame.
450458

451459
---
452460
## `templates/inference/keras_predictor.py`
@@ -464,6 +472,7 @@ Standard LightGBM predictor - NO Plexe dependencies.
464472
**`LightGBMPredictor`** - Standalone LightGBM predictor.
465473
- `__init__(self, model_dir: str)`
466474
- `predict(self, x: pd.DataFrame) -> pd.DataFrame` - Make predictions on input DataFrame.
475+
- `predict_proba(self, x: pd.DataFrame) -> pd.DataFrame` - Predict per-class probabilities on input DataFrame.
467476

468477
---
469478
## `templates/inference/pytorch_predictor.py`
@@ -481,6 +490,7 @@ Standard XGBoost predictor - NO Plexe dependencies.
481490
**`XGBoostPredictor`** - Standalone XGBoost predictor.
482491
- `__init__(self, model_dir: str)`
483492
- `predict(self, x: pd.DataFrame) -> pd.DataFrame` - Make predictions on input DataFrame.
493+
- `predict_proba(self, x: pd.DataFrame) -> pd.DataFrame` - Predict per-class probabilities on input DataFrame.
484494

485495
---
486496
## `templates/packaging/model_card_template.py`
@@ -539,7 +549,7 @@ Submission tools for agents.
539549
- `get_register_statistical_profile_tool(context: BuildContext)` - Factory: Returns statistical profile submission tool.
540550
- `get_register_layout_tool(context: BuildContext)` - Factory: Returns layout detection submission tool.
541551
- `get_register_eda_report_tool(context: BuildContext)` - Factory: Returns EDA report submission tool.
542-
- `get_save_split_uris_tool(context: BuildContext)` - Factory: Returns split URI submission tool.
552+
- `get_save_split_uris_tool(context: BuildContext, spark: Any | None, expected_ratios: dict[str, float] | None)` - Factory: Returns split URI submission tool.
543553
- `get_save_sample_uris_tool(context: BuildContext)` - Factory: Returns sample URIs submission tool.
544554
- `get_save_metric_implementation_fn(context: BuildContext)` - Factory: Returns metric implementation submission function.
545555
- `get_validate_baseline_predictor_tool(context: BuildContext, val_sample_df)` - Factory: Returns baseline predictor validation tool.
@@ -694,6 +704,7 @@ OpenTelemetry tracing decorators for agents and tools.
694704
Validation functions for pipelines, models, and other agent outputs.
695705

696706
**Functions:**
707+
- `canonicalize_split_ratios(split_ratios: dict[str, float] | None) -> dict[str, float]` - Normalize split ratio key aliases to canonical names.
697708
- `validate_sklearn_pipeline(pipeline: Pipeline, sample_df: pd.DataFrame, target_columns: list[str]) -> tuple[bool, str]` - Validate that an sklearn Pipeline is well-formed and functional.
698709
- `validate_pipeline_consistency(pipeline: Pipeline, train_sample: pd.DataFrame, val_sample: pd.DataFrame, target_columns: list[str]) -> tuple[bool, str]` - Validate pipeline produces consistent output shape on train/val samples.
699710
- `validate_xgboost_params(params: dict[str, Any]) -> tuple[bool, str]` - Validate XGBoost hyperparameters.

plexe/agents/baseline_builder.py

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
from plexe.config import Config, get_routing_for_model
1919
from plexe.constants import DirNames
20+
from plexe.helpers import compute_metric, metric_requires_probabilities, normalize_probability_predictions
2021
from plexe.models import BuildContext, Baseline
2122
from plexe.utils.tracing import agent_span
2223
from plexe.tools.submission import (
@@ -54,6 +55,13 @@ def _build_agent(self, val_sample_df) -> CodeAgent:
5455
from plexe.agents.utils import format_user_feedback_for_prompt
5556

5657
feedback_section = format_user_feedback_for_prompt(self.context.scratch.get("_user_feedback"))
58+
requires_proba = metric_requires_probabilities(self.context.metric.name)
59+
proba_requirement = (
60+
"- Because the selected primary metric requires probabilities, your class MUST also implement\n"
61+
" `predict_proba(self, x: pd.DataFrame) -> np.ndarray | pd.DataFrame` that returns per-sample scores.\n"
62+
if requires_proba
63+
else ""
64+
)
5765

5866
# Get routing configuration for this agent's model
5967
api_base, headers = get_routing_for_model(self.config.routing_config, self.llm_model)
@@ -128,6 +136,7 @@ def _build_agent(self, val_sample_df) -> CodeAgent:
128136
"## CRITICAL:\n"
129137
"- Use task_analysis['output_targets'] to identify target column(s)\n"
130138
"- Predictor must have standard .predict(X) -> array interface\n"
139+
f"{proba_requirement}"
131140
),
132141
model=PlexeLiteLLMModel(
133142
model_id=self.llm_model,
@@ -164,13 +173,19 @@ def run(self) -> Baseline:
164173
task_type = self.context.task_analysis.get("task_type", "unknown")
165174
target_columns = self.context.output_targets
166175
metric_name = self.context.metric.name
176+
proba_note = (
177+
"Primary metric requires probabilities, so baseline must implement predict_proba(X)."
178+
if metric_requires_probabilities(metric_name)
179+
else "Primary metric uses label/value predictions."
180+
)
167181

168182
task = (
169183
f"Create a simple baseline predictor for this ML task.\n\n"
170184
f"**ML TASK**: {self.context.intent}\n\n"
171185
f"Task Type: {task_type}\n"
172186
f"Target Column(s): {target_columns}\n"
173187
f"Metric: {metric_name}\n\n"
188+
f"{proba_note}\n\n"
174189
f"Build ONE simple baseline (heuristics preferred) that makes sense for this ML task. "
175190
f"Register it and evaluate performance using the tools provided."
176191
)
@@ -224,20 +239,28 @@ def _evaluate_performance(self, val_sample_df) -> float:
224239
Returns:
225240
Performance metric value
226241
"""
227-
from plexe.helpers import compute_metric
228-
229242
# Separate features from target
230243
target_cols = self.context.output_targets
231244
feature_cols = [col for col in val_sample_df.columns if col not in target_cols]
232245

233246
X_val = val_sample_df[feature_cols]
234247
y_val = val_sample_df[target_cols[0]]
235248

236-
# Make predictions
237-
y_pred = self.context.baseline_predictor.predict(X_val)
249+
if metric_requires_probabilities(self.context.metric.name):
250+
if not hasattr(self.context.baseline_predictor, "predict_proba") or not callable(
251+
self.context.baseline_predictor.predict_proba
252+
):
253+
raise ValueError(
254+
f"Metric '{self.context.metric.name}' requires probabilities but baseline predictor "
255+
"does not implement predict_proba()."
256+
)
257+
raw_proba = self.context.baseline_predictor.predict_proba(X_val)
258+
y_pred_input = normalize_probability_predictions(y_val.values, raw_proba, self.context.metric.name)
259+
else:
260+
y_pred_input = self.context.baseline_predictor.predict(X_val)
238261

239262
# Compute metric
240-
performance = compute_metric(y_true=y_val.values, y_pred=y_pred, metric_name=self.context.metric.name)
263+
performance = compute_metric(y_true=y_val.values, y_pred=y_pred_input, metric_name=self.context.metric.name)
241264

242265
logger.info(f"Baseline performance: {self.context.metric.name}={performance:.4f}")
243266

plexe/agents/dataset_splitter.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,12 @@ def __init__(self, spark: SparkSession, dataset_uri: str, context: BuildContext,
5353
self.config = config
5454
self.llm_model = config.dataset_splitting_llm
5555

56-
def _build_agent(self) -> CodeAgent:
56+
def _build_agent(self, split_ratios: dict[str, float]) -> CodeAgent:
5757
"""Build CodeAgent with splitting tool."""
5858
# Get routing configuration for this agent's model
5959
api_base, headers = get_routing_for_model(self.config.routing_config, self.llm_model)
60+
# TODO(splitter-prompts): Make split instructions conditional on requested split mode.
61+
# 2-way modes should not instruct writing test.parquet or passing test_uri.
6062

6163
return CodeAgent(
6264
name="DatasetSplitter",
@@ -142,7 +144,7 @@ def _build_agent(self) -> CodeAgent:
142144
extra_headers=headers,
143145
),
144146
verbosity_level=self.config.agent_verbosity_level,
145-
tools=[get_save_split_uris_tool(self.context)],
147+
tools=[get_save_split_uris_tool(self.context, self.spark, split_ratios)],
146148
add_base_tools=False,
147149
additional_authorized_imports=self.config.allowed_base_imports
148150
+ [
@@ -189,7 +191,7 @@ def run(self, split_ratios: dict[str, float], output_dir: str | Path) -> tuple[s
189191
output_dir_str = str(output_dir)
190192

191193
# Build agent
192-
agent = self._build_agent()
194+
agent = self._build_agent(split_ratios)
193195

194196
# Build task prompt (use string version of output_dir)
195197
task = self._build_task_prompt(split_ratios, output_dir_str)
@@ -263,6 +265,8 @@ def _build_task_prompt(self, split_ratios: dict[str, float], output_dir: str) ->
263265

264266
prompt += (
265267
"\n"
268+
# TODO(splitter-prompts): Make this task prompt explicitly 2-way vs 3-way.
269+
# Current wording always asks for train/val/test outputs, which can induce accidental holdouts.
266270
"Based on the task type and data characteristics, choose the appropriate splitting strategy:\n"
267271
"- Classification → Stratified split (preserve class balance)\n"
268272
"- Forecasting future events/values → Chronological split (train on past, test on future)\n"

plexe/agents/model_evaluator.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,11 @@ def _build_agent(self, phase_name: str, phase_prompt: str, tools: list) -> CodeA
9595
"- primary_metric_name: Name of the primary optimization metric (string)\\n"
9696
"- output_targets: list[str] (target column names to exclude from features)\\n\\n"
9797
"PREDICTOR INTERFACE:\\n"
98-
"The predictor's predict() function takes a pandas DataFrame (features only, no target)\\n"
99-
"and returns a pandas DataFrame with a 'prediction' column.\\n\\n"
98+
"- predict(X): input is features-only pandas DataFrame (no target columns), returns DataFrame with 'prediction'\\n"
99+
"- predict_proba(X): input is features-only pandas DataFrame; classification only; returns per-class probabilities\\n\\n"
100+
"CRITICAL METRIC RULE:\\n"
101+
"If a metric is probability-based (roc_auc, roc_auc_ovr, roc_auc_ovo, log_loss),\\n"
102+
"you MUST compute it from predict_proba() outputs, not from thresholded labels.\\n\\n"
100103
"Example usage:\\n"
101104
"```python\\n"
102105
"# Prepare features (drop target columns using output_targets)\\n"
@@ -106,6 +109,7 @@ def _build_agent(self, phase_name: str, phase_prompt: str, tools: list) -> CodeA
106109
"# Generate predictions (returns DataFrame with 'prediction' column)\\n"
107110
"predictions_df = predictor.predict(X_test)\\n"
108111
"y_pred = predictions_df['prediction'].values\\n"
112+
"# For probability metrics, use predictor.predict_proba(X_test)\\n"
109113
"```\\n\\n"
110114
"## YOUR MISSION:\\n"
111115
f"{phase_prompt}\\n\\n"
@@ -329,6 +333,10 @@ def _get_phase_1_prompt(task: str, primary_metric_name: str) -> str:
329333
f"3. Compute primary metric + 4-6 additional relevant metrics\\n"
330334
f"4. ENCOURAGED: Compute 95% confidence intervals using bootstrap (1000 samples)\\n"
331335
f"5. Interpret results - what do these numbers tell us?\\n\\n"
336+
f"If computing probability-based metrics (roc_auc, roc_auc_ovr, roc_auc_ovo, log_loss):\\n"
337+
f"- Use predictor.predict_proba(X_test), never thresholded class labels\\n"
338+
f"- Binary: use positive-class scores\\n"
339+
f"- Multiclass: use full per-class probability matrix\\n\\n"
332340
f"Register using:\\n"
333341
f"register_core_metrics_report(\\n"
334342
f" task_type='...', # your detected type\\n"

plexe/helpers.py

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@
2828

2929
logger = logging.getLogger(__name__)
3030

31+
PROBABILITY_METRICS = {
32+
StandardMetric.ROC_AUC.value,
33+
StandardMetric.ROC_AUC_OVR.value,
34+
StandardMetric.ROC_AUC_OVO.value,
35+
StandardMetric.LOG_LOSS.value,
36+
}
37+
3138

3239
def select_viable_model_types(data_layout: DataLayout, selected_frameworks: list[str] | None = None) -> list[str]:
3340
"""
@@ -85,6 +92,52 @@ def select_viable_model_types(data_layout: DataLayout, selected_frameworks: list
8592
return viable
8693

8794

95+
def metric_requires_probabilities(metric_name: str) -> bool:
96+
"""Return True when a metric requires probability scores instead of hard labels."""
97+
return metric_name.lower().strip() in PROBABILITY_METRICS
98+
99+
100+
def normalize_probability_predictions(y_true: np.ndarray, y_pred_proba: Any, metric_name: str) -> np.ndarray:
101+
"""
102+
Normalize probability predictions for sklearn metric compatibility.
103+
104+
- Binary metrics use positive-class scores (1D array).
105+
- Multiclass metrics use full per-class probability matrix (2D array).
106+
"""
107+
probabilities = y_pred_proba.values if hasattr(y_pred_proba, "values") else np.asarray(y_pred_proba)
108+
metric = metric_name.lower().strip()
109+
n_classes = len(np.unique(y_true))
110+
111+
if probabilities.ndim == 1:
112+
if n_classes > 2:
113+
raise ValueError(f"Metric '{metric_name}' requires per-class probabilities for multiclass tasks.")
114+
return probabilities
115+
116+
if probabilities.ndim != 2:
117+
raise ValueError(f"Expected probability outputs to be 1D or 2D, got shape {probabilities.shape}")
118+
119+
original_n_cols = probabilities.shape[1]
120+
if probabilities.shape[1] == 1:
121+
probabilities = np.column_stack([1 - probabilities[:, 0], probabilities[:, 0]])
122+
123+
is_multiclass = probabilities.shape[1] > 2 or n_classes > 2
124+
if not is_multiclass:
125+
return probabilities[:, 1]
126+
127+
if probabilities.shape[1] != n_classes and metric in PROBABILITY_METRICS:
128+
reported_n_cols = (
129+
original_n_cols if original_n_cols == 1 and probabilities.shape[1] == 2 else probabilities.shape[1]
130+
)
131+
column_label = "column" if reported_n_cols == 1 else "columns"
132+
raise ValueError(
133+
f"Probability outputs have {reported_n_cols} {column_label} but validation labels contain {n_classes} "
134+
f"distinct classes for metric '{metric_name}'. For multiclass tasks, predictor.predict_proba "
135+
f"must return one probability column per class."
136+
)
137+
138+
return probabilities
139+
140+
88141
def evaluate_on_sample(
89142
spark: SparkSession,
90143
sample_uri: str,
@@ -170,8 +223,18 @@ def _evaluate_predictor(
170223

171224
X = df.drop(columns=columns_to_drop)
172225
y = df[target_columns[0]]
173-
predictions = predictor.predict(X)["prediction"].values
174-
return compute_metric(y, predictions, metric, group_ids=group_ids)
226+
if metric_requires_probabilities(metric):
227+
if not hasattr(predictor, "predict_proba") or not callable(predictor.predict_proba):
228+
raise ValueError(
229+
f"Metric '{metric}' requires probability scores, but predictor {type(predictor).__name__} "
230+
"does not implement predict_proba()."
231+
)
232+
raw_probabilities = predictor.predict_proba(X)
233+
predictions = normalize_probability_predictions(y.values, raw_probabilities, metric)
234+
else:
235+
predictions = predictor.predict(X)["prediction"].values
236+
237+
return compute_metric(y.values, predictions, metric, group_ids=group_ids)
175238

176239

177240
def compute_metric_hardcoded(y_true, y_pred, metric_name: str) -> float:

0 commit comments

Comments
 (0)