Skip to content

Commit 89f8083

Browse files
feat: add column exclusion pipeline (#181)
* feat: add column exclusion pipeline * fix: address review feedback * chore: bump version to 1.3.4
1 parent cc66625 commit 89f8083

9 files changed

Lines changed: 305 additions & 11 deletions

File tree

plexe/CODE_INDEX.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Code Index: plexe
22

3-
> Generated on 2026-02-27 15:11:29
3+
> Generated on 2026-03-02 12:38:31
44
55
Code structure and public interface documentation for the **plexe** package.
66

plexe/agents/ml_task_analyser.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,12 @@ def _build_agent(self) -> CodeAgent:
7979
"5. **Data Challenges**: Issues specific to this data type (e.g., path validity, text length variance, missing values)\n"
8080
"6. **Preprocessing Recommendations**: High-level guidance on preparing data for modeling\n"
8181
"7. **Feature Engineering** (if applicable): Suggested transformations, interactions, encodings\n"
82-
"8. **Split Strategy** (in recommended_split):\n"
82+
"8. **Problematic Columns**: Identify columns to exclude and categorize them as one of: leakage, constant, identifier, irrelevant.\n"
83+
" - leakage: correlation > 0.95 with target or post-hoc derivatives of target\n"
84+
" - constant: near-zero variance or single unique value\n"
85+
" - identifier: IDs, keys, hashes, or unique identifiers\n"
86+
" - irrelevant: columns unrelated to the task or likely to add noise\n"
87+
"9. **Split Strategy** (in recommended_split):\n"
8388
" - temporal_reasoning: Chronological split only if predicting FUTURE time periods. Timestamp as metadata → random OK.\n"
8489
" - stratification_reasoning: Stratify if classification with class imbalance.\n"
8590
"\n"

plexe/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ class ScratchKeys:
2121
SAVED_MODEL = "_saved_model"
2222
STATISTICAL_PROFILE = "_statistical_profile"
2323
EDA_REPORT = "_eda_report"
24+
PROBLEMATIC_COLUMNS = "_problematic_columns"
2425

2526
# Dataset URIs
2627
TRAIN_URI = "_train_uri"

plexe/models.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ class BuildContext:
8686
compute_metric: Any | None = None # Function for computing metric (callable)
8787
output_targets: list[str] = field(default_factory=list) # Target column(s) identified by MLTaskAnalyser
8888
group_column: str | None = None # For ranking: query_id, session_id, user_id (group identifier)
89+
excluded_columns: list[dict] = field(default_factory=list) # Columns removed before downstream phases
8990

9091
# Data preparation phase
9192
train_uri: str | None = None
@@ -156,6 +157,7 @@ def to_dict(self) -> dict:
156157
),
157158
"output_targets": self.output_targets,
158159
"group_column": self.group_column,
160+
"excluded_columns": self.excluded_columns,
159161
# Phase 2 fields
160162
"train_uri": self.train_uri,
161163
"val_uri": self.val_uri,
@@ -207,6 +209,7 @@ def from_dict(d: dict) -> "BuildContext":
207209
compute_metric=compute_metric,
208210
output_targets=d.get("output_targets", []),
209211
group_column=d.get("group_column"),
212+
excluded_columns=d.get("excluded_columns", []),
210213
train_uri=d.get("train_uri"),
211214
val_uri=d.get("val_uri"),
212215
test_uri=d.get("test_uri"),

plexe/tools/submission.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from sklearn.pipeline import Pipeline
1414
from smolagents import tool
1515

16-
from plexe.constants import DirNames
16+
from plexe.constants import DirNames, ScratchKeys
1717
from plexe.models import BuildContext, Metric, Hypothesis, UnifiedPlan
1818
from plexe.search.insight_store import InsightStore
1919
from plexe.utils.tracing import tool_span
@@ -668,6 +668,7 @@ def save_eda_report(
668668
key_insights: list[str],
669669
recommended_split: dict[str, Any],
670670
feature_relationships: dict[str, Any] | None = None,
671+
problematic_columns: list[dict] | None = None,
671672
group_column: str | None = None,
672673
) -> str:
673674
"""
@@ -685,6 +686,7 @@ def save_eda_report(
685686
key_insights: Important ML findings relevant to the task
686687
recommended_split: Split strategy dict with: ratios (dict), temporal_reasoning (str explaining if/why chronological split needed), stratification_reasoning (str explaining if/why stratified split needed)
687688
feature_relationships: Optional dict with feature-target relationships (only when computable, e.g., correlations for tabular data)
689+
problematic_columns: Optional list of dicts with {"column": str, "reason": str, "category": str} where category in {"leakage", "constant", "identifier", "irrelevant"}
688690
group_column: Optional group/query ID column for ranking tasks (e.g., "session_id", "query_id", "user_id")
689691
690692
Example (Tabular):
@@ -697,7 +699,8 @@ def save_eda_report(
697699
preprocessing_recommendations=["Impute missing values", "Encode categorical features"],
698700
key_insights=["CryoSleep is highly predictive based on correlation"],
699701
recommended_split={"ratios": {"train": 0.7, "val": 0.15, "test": 0.15}, "temporal_reasoning": "No chronological split needed - this is cross-sectional classification of records, not forecasting future events", "stratification_reasoning": "Stratified split recommended due to class imbalance to maintain balance across splits"},
700-
feature_relationships={"correlations_with_target": {"CryoSleep": 0.42, "Age": -0.15}}
702+
feature_relationships={"correlations_with_target": {"CryoSleep": 0.42, "Age": -0.15}},
703+
problematic_columns=[{"column": "leak_col", "reason": "Correlation > 0.95 with target", "category": "leakage"}]
701704
)
702705
703706
Example (Image):
@@ -744,6 +747,9 @@ def save_eda_report(
744747
Confirmation message
745748
"""
746749

750+
if problematic_columns is None:
751+
problematic_columns = []
752+
747753
# Build structured report
748754
report = {
749755
"task_type": task_type,
@@ -755,11 +761,13 @@ def save_eda_report(
755761
"key_insights": key_insights,
756762
"recommended_split": recommended_split,
757763
"feature_relationships": feature_relationships,
764+
"problematic_columns": problematic_columns,
758765
"group_column": group_column,
759766
}
760767

761768
# Save to context
762769
context.scratch["_eda_report"] = report
770+
context.scratch[ScratchKeys.PROBLEMATIC_COLUMNS] = problematic_columns
763771

764772
# Also save group_column directly to context for ranking tasks
765773
if group_column:

plexe/workflow.py

Lines changed: 150 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535

3636
from plexe.integrations.base import WorkflowIntegration
3737
from plexe.config import Config
38-
from plexe.constants import DirNames, PhaseNames
38+
from plexe.constants import DirNames, PhaseNames, ScratchKeys
3939
from plexe.models import BuildContext, Solution, Baseline, Hypothesis, DataLayout, EvaluationReport
4040
from plexe.execution.training.runner import TrainingRunner
4141
from plexe.checkpointing import save_checkpoint, load_checkpoint
@@ -204,10 +204,13 @@ def build_model(
204204
# Phase 2: Data Preparation
205205
if start_phase <= 2:
206206
with tracer.start_as_current_span("Phase 2: Data Preparation"):
207-
# Use sanitized dataset if available (from Phase 1 column name cleaning)
208-
# If resuming from checkpoint and sanitized URI not in context, use original
209-
# (Phase 1 checkpoint should have stored the sanitized URI)
210-
train_uri_to_use = context.scratch.get("_sanitized_dataset_uri", train_dataset_uri)
207+
# Use filtered dataset if available. Note: filtering is applied on top of the
208+
# sanitized dataset, so choosing filtered implicitly includes any sanitization.
209+
# If no exclusions were applied, filtered == sanitized. If resuming from
210+
# checkpoint and URIs not in context, fall back to original.
211+
train_uri_to_use = context.scratch.get("_filtered_dataset_uri") or context.scratch.get(
212+
"_sanitized_dataset_uri", train_dataset_uri
213+
)
211214

212215
# Sanitize test dataset too if provided (must match training data schema)
213216
test_uri_to_use = test_dataset_uri
@@ -574,6 +577,135 @@ def sanitize_dataset_column_names(spark: SparkSession, dataset_uri: str, context
574577
return sanitized_uri
575578

576579

580+
def _set_noop_filtered_dataset_uri(context: BuildContext, dataset_uri: str) -> str:
581+
"""Store no-op filtering result in context and return original URI."""
582+
context.excluded_columns = []
583+
context.scratch["_filtered_dataset_uri"] = dataset_uri
584+
return dataset_uri
585+
586+
587+
def _get_problematic_columns_payload(context: BuildContext) -> list[object] | None:
588+
"""Read and validate problematic columns payload from scratch."""
589+
problematic_columns = context.scratch.get(ScratchKeys.PROBLEMATIC_COLUMNS, [])
590+
if not problematic_columns:
591+
logger.info("No problematic columns flagged - skipping exclusion step")
592+
return None
593+
if not isinstance(problematic_columns, list):
594+
logger.warning("Problematic columns payload is not a list - skipping exclusion step")
595+
return None
596+
return problematic_columns
597+
598+
599+
def _build_protected_columns_set(context: BuildContext) -> set[str]:
600+
"""Build set of columns that can never be excluded."""
601+
protected_columns = set(context.output_targets or [])
602+
if context.group_column:
603+
protected_columns.add(context.group_column)
604+
if context.primary_input_column:
605+
protected_columns.add(context.primary_input_column)
606+
return protected_columns
607+
608+
609+
def _normalize_exclusion_reason(raw_reason: object) -> str:
610+
"""Normalize exclusion reason to a non-empty string."""
611+
if isinstance(raw_reason, str) and raw_reason.strip():
612+
return raw_reason
613+
if not raw_reason:
614+
return "unspecified"
615+
return str(raw_reason)
616+
617+
618+
def _filter_valid_exclusions(
619+
problematic_columns: list[object], available_columns: set[str], protected_columns: set[str]
620+
) -> tuple[list[str], list[dict], list[str], list[str], int]:
621+
"""Validate exclusion entries and return drop candidates + diagnostics."""
622+
columns_to_drop: list[str] = []
623+
excluded_entries: list[dict] = []
624+
skipped_protected: list[str] = []
625+
skipped_missing: list[str] = []
626+
invalid_entries = 0
627+
seen: set[str] = set()
628+
629+
for entry in problematic_columns:
630+
if not isinstance(entry, dict):
631+
invalid_entries += 1
632+
continue
633+
column = entry.get("column")
634+
if not isinstance(column, str) or not column.strip():
635+
invalid_entries += 1
636+
continue
637+
if column in seen:
638+
continue
639+
seen.add(column)
640+
if column in protected_columns:
641+
skipped_protected.append(column)
642+
continue
643+
if column not in available_columns:
644+
skipped_missing.append(column)
645+
continue
646+
columns_to_drop.append(column)
647+
excluded_entries.append({"column": column, "reason": _normalize_exclusion_reason(entry.get("reason"))})
648+
649+
return columns_to_drop, excluded_entries, skipped_protected, skipped_missing, invalid_entries
650+
651+
652+
def _exclude_problematic_columns(
653+
spark: SparkSession,
654+
dataset_uri: str,
655+
context: BuildContext,
656+
config: Config | None,
657+
) -> str:
658+
"""
659+
Drop problematic columns identified during Phase 1 analysis.
660+
661+
Args:
662+
spark: SparkSession
663+
dataset_uri: Dataset URI to filter (already sanitized)
664+
context: Build context with problematic columns and targets
665+
config: Configuration (reserved for future use)
666+
667+
Returns:
668+
URI of filtered dataset (or original if no exclusions needed)
669+
"""
670+
_ = config
671+
problematic_columns = _get_problematic_columns_payload(context)
672+
if problematic_columns is None:
673+
return _set_noop_filtered_dataset_uri(context, dataset_uri)
674+
675+
df = spark.read.parquet(dataset_uri)
676+
columns_to_drop, excluded_entries, skipped_protected, skipped_missing, invalid_entries = _filter_valid_exclusions(
677+
problematic_columns=problematic_columns,
678+
available_columns=set(df.columns),
679+
protected_columns=_build_protected_columns_set(context),
680+
)
681+
682+
if skipped_protected:
683+
logger.warning(
684+
"Problematic columns include protected columns; skipping exclusions for: "
685+
+ ", ".join(sorted(skipped_protected))
686+
)
687+
if skipped_missing:
688+
logger.warning(
689+
"Problematic columns not found in dataset; skipping exclusions for: " + ", ".join(sorted(skipped_missing))
690+
)
691+
if invalid_entries:
692+
logger.warning(f"Skipped {invalid_entries} malformed problematic column entries")
693+
if not columns_to_drop:
694+
logger.info("No valid problematic columns to exclude after validation")
695+
return _set_noop_filtered_dataset_uri(context, dataset_uri)
696+
697+
logger.info(f"Excluding {len(columns_to_drop)} problematic columns from dataset")
698+
for entry in excluded_entries:
699+
logger.info(f" drop '{entry['column']}': {entry['reason']}")
700+
701+
filtered_uri = f"{context.work_dir}/{DirNames.BUILD_DIR}/data/dataset_filtered.parquet"
702+
df.drop(*columns_to_drop).write.mode("overwrite").parquet(filtered_uri)
703+
logger.info(f"✓ Filtered dataset saved: {filtered_uri}")
704+
context.excluded_columns = excluded_entries
705+
context.scratch["_filtered_dataset_uri"] = filtered_uri
706+
return filtered_uri
707+
708+
577709
def analyze_data(
578710
spark: SparkSession,
579711
dataset_uri: str,
@@ -642,6 +774,9 @@ def analyze_data(
642774
save_report(context.work_dir, "02_task_analysis", task_analysis)
643775
# Note: output_targets already set by task_agent.run()
644776

777+
# Step 3b: Exclude problematic columns identified during analysis
778+
_exclude_problematic_columns(spark, sanitized_uri, context, config)
779+
645780
# Step 3: Metric Selection
646781
metric_agent = MetricSelectorAgent(context, config)
647782
metric = metric_agent.run()
@@ -711,6 +846,16 @@ def prepare_data(
711846

712847
# Copy test dataset to DirNames.BUILD_DIR/data/ for consistency
713848
test_df = spark.read.parquet(test_dataset_uri)
849+
if context.excluded_columns:
850+
excluded_column_names = [
851+
entry.get("column")
852+
for entry in context.excluded_columns
853+
if isinstance(entry, dict) and entry.get("column")
854+
]
855+
columns_to_drop = [col for col in excluded_column_names if col in test_df.columns]
856+
if columns_to_drop:
857+
test_df = test_df.drop(*columns_to_drop)
858+
logger.info(f"Excluded {len(columns_to_drop)} columns from test dataset: {columns_to_drop}")
714859
test_uri = str(context.work_dir / DirNames.BUILD_DIR / "data" / "test.parquet")
715860
test_df.write.mode("overwrite").parquet(test_uri)
716861
logger.info(f"Copied test dataset to: {test_uri}")

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "plexe"
3-
version = "1.3.3"
3+
version = "1.3.4"
44
description = "An agentic framework for building ML models from natural language"
55
authors = [
66
"Marcello De Bernardi <mdebernardi@plexe.ai>",

tests/CODE_INDEX.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Code Index: tests
22

3-
> Generated on 2026-02-27 15:11:29
3+
> Generated on 2026-03-02 12:38:31
44
55
Test suite structure and test case documentation.
66

@@ -169,6 +169,18 @@ Unit tests for validation functions.
169169
- `test_validate_metric_function_object_success()` - Callable with correct signature should pass.
170170
- `test_validate_metric_function_object_bad_signature()` - Callable with wrong arg names should fail.
171171

172+
---
173+
## `unit/workflow/test_column_exclusion.py`
174+
Tests for column exclusion pipeline.
175+
176+
**Functions:**
177+
- `spark()` - No description
178+
- `test_exclude_problematic_columns_drops_columns_and_returns_new_uri(spark, tmp_path)` - No description
179+
- `test_exclude_problematic_columns_noop_when_empty(spark, tmp_path)` - No description
180+
- `test_build_context_round_trip_with_excluded_columns(tmp_path)` - No description
181+
- `test_exclude_problematic_columns_never_drops_target(spark, tmp_path)` - No description
182+
- `test_exclude_problematic_columns_never_drops_primary_input(spark, tmp_path)` - No description
183+
172184
---
173185
## `unit/workflow/test_model_card.py`
174186
Unit tests for model card generation.

0 commit comments

Comments
 (0)