|
35 | 35 |
|
36 | 36 | from plexe.integrations.base import WorkflowIntegration |
37 | 37 | from plexe.config import Config |
38 | | -from plexe.constants import DirNames, PhaseNames |
| 38 | +from plexe.constants import DirNames, PhaseNames, ScratchKeys |
39 | 39 | from plexe.models import BuildContext, Solution, Baseline, Hypothesis, DataLayout, EvaluationReport |
40 | 40 | from plexe.execution.training.runner import TrainingRunner |
41 | 41 | from plexe.checkpointing import save_checkpoint, load_checkpoint |
@@ -204,10 +204,13 @@ def build_model( |
204 | 204 | # Phase 2: Data Preparation |
205 | 205 | if start_phase <= 2: |
206 | 206 | 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 | + ) |
211 | 214 |
|
212 | 215 | # Sanitize test dataset too if provided (must match training data schema) |
213 | 216 | test_uri_to_use = test_dataset_uri |
@@ -574,6 +577,135 @@ def sanitize_dataset_column_names(spark: SparkSession, dataset_uri: str, context |
574 | 577 | return sanitized_uri |
575 | 578 |
|
576 | 579 |
|
| 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 | + |
577 | 709 | def analyze_data( |
578 | 710 | spark: SparkSession, |
579 | 711 | dataset_uri: str, |
@@ -642,6 +774,9 @@ def analyze_data( |
642 | 774 | save_report(context.work_dir, "02_task_analysis", task_analysis) |
643 | 775 | # Note: output_targets already set by task_agent.run() |
644 | 776 |
|
| 777 | + # Step 3b: Exclude problematic columns identified during analysis |
| 778 | + _exclude_problematic_columns(spark, sanitized_uri, context, config) |
| 779 | + |
645 | 780 | # Step 3: Metric Selection |
646 | 781 | metric_agent = MetricSelectorAgent(context, config) |
647 | 782 | metric = metric_agent.run() |
@@ -711,6 +846,16 @@ def prepare_data( |
711 | 846 |
|
712 | 847 | # Copy test dataset to DirNames.BUILD_DIR/data/ for consistency |
713 | 848 | 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}") |
714 | 859 | test_uri = str(context.work_dir / DirNames.BUILD_DIR / "data" / "test.parquet") |
715 | 860 | test_df.write.mode("overwrite").parquet(test_uri) |
716 | 861 | logger.info(f"Copied test dataset to: {test_uri}") |
|
0 commit comments