featurizer
1# coding: utf-8 2 3from .featurizer import Featurizer 4from .imputation import ( 5 MISSING_INDICATOR_SUFFIX, 6 impute_arrow, 7 impute_features, 8) 9from .sharding import ( 10 DEFAULT_MAX_COLUMNS_PER_GROUP, 11 ColumnGroupSharder, 12 GroupedQueries, 13) 14from .validation import ( 15 ValidationError, 16 ValidationResult, 17 ValidationWarning, 18 validate_config, 19) 20from .viz import FeaturizerViz 21 22__all__ = [ 23 "Featurizer", 24 "FeaturizerViz", 25 "impute_features", 26 "impute_arrow", 27 "MISSING_INDICATOR_SUFFIX", 28 "ColumnGroupSharder", 29 "GroupedQueries", 30 "DEFAULT_MAX_COLUMNS_PER_GROUP", 31 "validate_config", 32 "ValidationResult", 33 "ValidationError", 34 "ValidationWarning", 35]
72class Featurizer: 73 """ 74 PostgreSQL implementation of the DFS algorithm (adapted for temporal data sets). 75 76 Coordinates configuration loading, feature planning, SQL rendering, and optional 77 database execution. 78 """ 79 80 def __init__( 81 self, 82 config_file: str, 83 *, 84 debug: bool = False, 85 validate: bool = True, 86 materialize_threshold: int | None = None, 87 connection: Any = None, 88 ) -> None: 89 """Initialize Featurizer from a YAML configuration file. 90 91 Args: 92 config_file: Path to YAML configuration file 93 debug: Enable debug logging with icecream. Can also be set via FEATURIZER_DEBUG env var. 94 validate: Run enhanced validation checks (default: True) 95 materialize_threshold: Column width above which an oversized 96 non-target child CTE is materialized into TEMP-table shards 97 (issue #7). Defaults to PostgreSQL's hard 1664-entry limit; lower 98 it to materialize earlier (advanced / testing). 99 connection: Optional psycopg connection used only to resolve a 100 ``role: categorical`` direct variable's vocabulary from its 101 PostgreSQL ``ENUM`` labels when no ``vocabulary`` is declared in 102 the config. When omitted, one is opened from ``DATABASE_URL`` / 103 ``PG*`` if any categorical actually needs it; a declared 104 vocabulary needs no database (``query`` / ``--show-sql`` stay 105 DB-free). The connection is never used to scan data. 106 107 Raises: 108 FileNotFoundError: If config file doesn't exist 109 ValueError: If config is missing required keys or has invalid values, 110 or a ``role: categorical`` variable has neither a declared 111 ``vocabulary`` nor an introspectable PostgreSQL ``ENUM``. 112 """ 113 config = self._load_config(config_file, validate=validate) 114 self._materialize_threshold = materialize_threshold 115 116 self._debug_enabled: bool = debug or self._env_debug_enabled() 117 if self._debug_enabled: 118 ic.configureOutput(prefix="[Featurizer] ", includeContext=True) 119 120 self.max_depth: int = config["max_depth"] 121 self.intervals: List[str] = config["intervals"] 122 # Point-in-time boundary mode: ``inclusive`` (default, ``<=``) keeps an 123 # event dated exactly on the as_of_date knowable; ``exclusive`` (``<``) 124 # treats it as not-yet-knowable. Validated in ConfigValidator. 125 self.as_of_boundary: AsOfBoundary = config.get( 126 "as_of_boundary", DEFAULT_BOUNDARY 127 ) 128 129 # Paired cohort (issue #10): the column of the caller's as_of_dates 130 # table that holds the target id. Absent -> the dense cohort, every 131 # target row under every as-of date, rendered exactly as before. 132 self.cohort_id_column: Optional[str] = (config.get("as_of_dates") or {}).get( 133 "id_column" 134 ) 135 136 self.graph: ERGraph = ERGraph( 137 config["entities"], 138 config["relationships"], 139 config.get("spatial_relationships"), 140 config.get("graph_relationships"), 141 ) 142 self.target: Entity = self._get_entity(config["target"]) 143 144 # Resolve fixed vocabularies for the target's role: categorical direct 145 # variables (declared list, else introspected ENUM) before planning so 146 # the planner stays DB-free. Fails loud if a categorical can be neither. 147 self._resolve_categorical_vocabularies(connection) 148 149 # Primitive selection: config may override the active set; an absent or 150 # null key applies the curated module defaults. An explicit empty list 151 # suppresses that layer instead: `aggregations: []` builds zero 152 # aggregation features, and `transformations: []` passes features 153 # through unchanged — spelled as identity because the transform CTE 154 # also feeds child aggregations, so a truly empty transformer set 155 # would drop every feature column. Unknown names raise in get_* (and 156 # are caught earlier with suggestions by the validator when 157 # validate=True). 158 agg_names = config.get("aggregations") 159 if agg_names is None: 160 agg_names = DEFAULT_AGGREGATIONS 161 tx_names = config.get("transformations") 162 if tx_names is None: 163 tx_names = DEFAULT_TRANSFORMATIONS 164 elif not tx_names: 165 tx_names = ("identity",) 166 self.aggregations: AggregationRegistry = get_aggregations(agg_names) 167 self.transformations: TransformationRegistry = get_transformers(tx_names) 168 169 planner = FeaturePlanner( 170 graph=self.graph, 171 target_alias=self.target.alias, 172 max_depth=self.max_depth, 173 intervals=self.intervals, 174 aggregations=self.aggregations, 175 transformations=self.transformations, 176 boundary=self.as_of_boundary, 177 debug=self._debug_enabled, 178 cohort_id_column=self.cohort_id_column, 179 ) 180 self._plan: PlannerResult = planner.plan() 181 182 self.features: Dict[str, Set[Feature]] = { 183 alias: set(features) for alias, features in self._plan.features.items() 184 } 185 self.ctes: List[str] = list(self._plan.ctes) 186 self.joins: Dict[str, List[str]] = { 187 alias: list(joins) for alias, joins in self._plan.joins.items() 188 } 189 190 self._renderer: SQLRenderer = SQLRenderer() 191 self._executor: QueryExecutor = QueryExecutor() 192 193 def _resolve_categorical_vocabularies(self, connection: Any) -> None: 194 """Bake a fixed vocabulary onto each target ``role: categorical`` variable. 195 196 Declared vocabularies need no database. If any categorical lacks one, a 197 connection is required to read its PostgreSQL ``ENUM`` labels: the passed 198 ``connection`` is used, else one is opened from ``DATABASE_URL`` / ``PG*``; 199 if neither resolves, :func:`resolve_vocabulary` raises a loud, actionable 200 error. The data is never scanned for distinct values (split-blind). 201 """ 202 pending = [ 203 feature 204 for feature in self.target.features 205 if isinstance(feature, Variable) and feature.role == ROLE_CATEGORICAL 206 ] 207 if not pending: 208 return 209 210 needs_db = any(not var.vocabulary for var in pending) 211 conn = connection 212 own_connection: Any = None 213 if needs_db and conn is None: 214 own_connection = conn = self._maybe_env_connection() 215 try: 216 for var in pending: 217 var.vocabulary = resolve_vocabulary(var, self.target, conn) 218 finally: 219 if own_connection is not None: 220 own_connection.close() 221 222 @staticmethod 223 def _maybe_env_connection() -> Any: 224 """A psycopg connection from the environment, or ``None`` if unconfigured. 225 226 Mirrors the connection sourcing used by the Arrow/Parquet output paths 227 (``DATABASE_URL`` / ``PG*``). Returns ``None`` rather than raising when no 228 database is configured, so the caller can produce the precise 229 declare-vocabulary-or-ENUM error instead of a generic connection error. 230 """ 231 from .arrow import default_connection 232 233 try: 234 return default_connection() 235 except RuntimeError: 236 return None 237 238 # ------------------------------------------------------------------ # 239 # Public API 240 # ------------------------------------------------------------------ # 241 242 @property 243 def entities(self) -> Iterable[Entity]: 244 """Return all entities in the graph.""" 245 return self.graph.entities.values() 246 247 @property 248 def relationships(self) -> List[Any]: 249 """Return all relationships in the graph.""" 250 return self.graph.relationships 251 252 @property 253 def feature_manifest(self) -> "List[ManifestEntry]": 254 """Map every output column to its full, untruncated intended name. 255 256 One row per output feature column (in output order), each carrying the 257 rendered ``column`` name, the human-readable ``label`` (recovered even 258 when the 63-byte identifier cap truncated the column name), a 259 ``truncated`` flag, the ``kind`` (``one_hot`` | ``variable`` | 260 ``derived``), the owning ``entity``, and — for one-hot columns — the 261 ``source_column`` and ``value`` they encode. Useful for human/partner 262 labels, plot legends, and joining readable names back onto the matrix. 263 """ 264 from .manifest import build_feature_manifest 265 266 return build_feature_manifest(self._plan.target_output_features) 267 268 def manifest_dataframe(self) -> "pd.DataFrame": 269 """The feature manifest as a pandas DataFrame (table / plots / joins).""" 270 from .manifest import manifest_dataframe 271 272 return manifest_dataframe(self.feature_manifest) 273 274 def manifest_matching( 275 self, pattern: str, *, allow_empty: bool = False 276 ) -> "List[ManifestEntry]": 277 """Manifest entries whose full ``label`` matches a shell-style glob. 278 279 Match against the **label**, never the physical column name: names 280 longer than PostgreSQL's 63-byte identifier limit are hash-truncated 281 from the tail, so a pattern targeting an inner fragment 282 (``*(inspections.kw_*``) misses every truncated column. The label is 283 the only lossless surface — ``definition`` inherits the parent's 284 truncation and drops the interval, so windowed variants share one. 285 286 Args: 287 pattern: Glob matched against ``label``. Case-sensitive. ``_`` is a 288 literal here (unlike SQL ``LIKE`` — see 289 :func:`~featurizer.manifest.glob_to_like` if you are querying 290 the persisted manifest table by hand). 291 allow_empty: Return ``[]`` instead of raising when nothing matches. 292 293 Returns: 294 Matching entries in output order. 295 296 Raises: 297 LookupError: When nothing matches and ``allow_empty`` is False — 298 a silently empty selection is the failure this helper exists to 299 prevent. The message suggests near-miss labels. 300 301 Example: 302 >>> f.manifest_matching("*kw_rodent*")[0].column 303 'CUM_SUM(facilities.MEAN(inspections.ABS(inspections.kw_rod~978dcf98' 304 """ 305 from .manifest import filter_manifest 306 307 return filter_manifest(self.feature_manifest, pattern, allow_empty=allow_empty) 308 309 def columns_matching(self, pattern: str, *, allow_empty: bool = False) -> List[str]: 310 """Physical column names whose full ``label`` matches a glob, in output order. 311 312 The projection of :meth:`manifest_matching` onto the rendered column 313 name — what you put in a ``select`` list, a feature-group definition, or 314 a DataFrame projection. 315 316 Building a triage ``feature_groups.definitions`` entry from physical 317 names directly will abort the run on every truncated column ("matches no 318 feature_groups.definitions glob"); resolve it here first:: 319 320 definitions = {"cardiac": f.columns_matching("*frecuencia_cardiaca*")} 321 322 Args: 323 pattern: Glob matched against ``label``. Case-sensitive. 324 allow_empty: Return ``[]`` instead of raising when nothing matches. 325 326 Returns: 327 The rendered (possibly truncated) column names, in output order. 328 329 Raises: 330 LookupError: When nothing matches and ``allow_empty`` is False. 331 """ 332 return [ 333 entry.column 334 for entry in self.manifest_matching(pattern, allow_empty=allow_empty) 335 ] 336 337 @property 338 def query(self) -> str: 339 """Generate the single SQL query for this featurizer configuration. 340 341 Raises: 342 ValueError: If the configuration is too wide to render as one valid 343 query — the ``<target>_transform`` CTE (or an intermediate CTE) 344 would exceed PostgreSQL's 1664-entry target-list limit. Use 345 :attr:`query_groups` / :meth:`to_parquet` / :meth:`to_arrow` 346 (column-group sharding, issue #7) instead. The matrix is never 347 silently truncated. 348 """ 349 sharder = self._sharder() 350 if not sharder.fits_single_group: 351 n_groups = sharder.n_groups 352 raise ValueError( 353 f"Feature matrix for target '{self.target.alias}' is too wide for a " 354 "single query: it exceeds PostgreSQL's 1664-entry target-list limit " 355 f"and partitions into {n_groups} column groups. Use " 356 "`.query_groups` (group_id -> SQL), `.to_parquet(dir)` (one Parquet " 357 "per group), or `.to_arrow()` (list of tables); all groups re-join " 358 "on (as_of_date, id). `.to_arrow`/`.to_parquet`/`.to_dataframe` also " 359 "materialize any oversized child CTE automatically (issue #7). See " 360 "docs/adr/0005-column-group-sharding.md." 361 ) 362 return self._renderer.render(self._plan) 363 364 def _sharder( 365 self, *, max_columns_per_group: int | None = None 366 ) -> "ColumnGroupSharder": 367 """A ColumnGroupSharder for this plan honouring ``materialize_threshold``. 368 369 ``max_columns_per_group`` overrides the default per-group column cap — 370 used by :meth:`to_tables` to downshift the partition when the default 371 cap would produce a heap row over PostgreSQL's ~8160-byte page limit. 372 """ 373 from .sharding import ( 374 DEFAULT_MAX_COLUMNS_PER_GROUP, 375 PG_MAX_TARGET_LIST, 376 ColumnGroupSharder, 377 ) 378 379 threshold = ( 380 self._materialize_threshold 381 if self._materialize_threshold is not None 382 else PG_MAX_TARGET_LIST 383 ) 384 return ColumnGroupSharder( 385 self._plan, 386 max_columns_per_group=( 387 max_columns_per_group 388 if max_columns_per_group is not None 389 else DEFAULT_MAX_COLUMNS_PER_GROUP 390 ), 391 materialize_threshold=threshold, 392 ) 393 394 def _grouped(self) -> "GroupedQueries": 395 """The grouped queries + any temp-table materialization preamble. 396 397 A config that fits one valid query short-circuits to a single 398 ``group_000`` equal to :attr:`query` (no preamble). A wide or 399 oversized-child config returns the partitioned/rewritten group queries 400 and, when a child CTE had to be materialized, the preamble on 401 ``GroupedQueries.materialization`` (issue #7). 402 """ 403 from collections import OrderedDict 404 405 from .sharding import GroupedQueries 406 407 sharder = self._sharder() 408 sharder.warn_oversized() 409 sharder.warn_plan_size() # pre-flight PG planner-blowup prediction 410 if sharder.fits_single_group: 411 return GroupedQueries( 412 queries=OrderedDict([("group_000", self._renderer.render(self._plan))]), 413 key_columns=sharder.key_columns, 414 fits_single=True, 415 materialization=None, 416 ) 417 return sharder.build() 418 419 def _grouped_for_tables( 420 self, 421 ) -> "Tuple[GroupedQueries, OrderedDict[str, List[str]]]": 422 """Grouped queries for :meth:`to_tables` + the matching column→group map. 423 424 The CTAS path differs from the SELECT/fetch paths in one way: each 425 group becomes a heap *table*, and a heap tuple must fit one 8 KiB page 426 (~8160 bytes). A group that is a perfectly valid query (≤1664 output 427 columns) can still fail ``create table … as`` with ``row is too big`` 428 once ~1000+ mostly fixed-width feature columns land on a page. 429 Pre-flight: estimate every group's row width 430 (:func:`~featurizer.sharding.estimate_heap_row_width` — 8 bytes per 431 column + header + null bitmap); when any group exceeds 432 :data:`~featurizer.sharding.HEAP_ROW_BUDGET_BYTES`, re-partition with a 433 per-group cap sized to the budget instead of letting PostgreSQL fail. 434 435 The returned mapping is the exact partition the queries were rendered 436 from, so the manifest's ``feature_group`` tags always name a 437 really-persisted table (see :meth:`_write_manifest_table`). 438 """ 439 from collections import OrderedDict 440 441 from .sharding import ( 442 HEAP_ROW_BUDGET_BYTES, 443 GroupedQueries, 444 estimate_heap_row_width, 445 max_heap_safe_columns, 446 ) 447 448 sharder = self._sharder() 449 sharder.warn_oversized() 450 sharder.warn_plan_size() 451 n_keys = len(sharder.key_columns) 452 groups = sharder.column_groups() 453 oversized = { 454 gid: estimate_heap_row_width(len(cols) + n_keys) 455 for gid, cols in groups.items() 456 if estimate_heap_row_width(len(cols) + n_keys) > HEAP_ROW_BUDGET_BYTES 457 } 458 if oversized: 459 cap = max_heap_safe_columns(n_keys) 460 worst_gid, worst_width = max(oversized.items(), key=lambda kv: kv[1]) 461 logger.warning( 462 "to_tables heap-row pre-flight: {} of {} column group(s) " 463 "estimate over the {}-byte page budget (worst: {} ≈ {} bytes; " 464 "PostgreSQL rejects heap rows over ~8160 bytes with 'row is " 465 "too big'). Re-partitioning with max {} feature columns per " 466 "group — more, narrower tables; SELECT/fetch paths are " 467 "unaffected.", 468 len(oversized), 469 len(groups), 470 HEAP_ROW_BUDGET_BYTES, 471 worst_gid, 472 worst_width, 473 cap, 474 ) 475 sharder = self._sharder(max_columns_per_group=cap) 476 groups = sharder.column_groups() 477 478 if sharder.fits_single_group and len(groups) == 1: 479 # Same short-circuit as _grouped(): one group, the plain query. 480 grouped = GroupedQueries( 481 queries=OrderedDict([("group_000", self._renderer.render(self._plan))]), 482 key_columns=sharder.key_columns, 483 fits_single=True, 484 materialization=None, 485 ) 486 else: 487 # NOTE: also taken when the config fits a single *query* but 488 # partitions into >1 groups (e.g. >cap output columns under 1664, 489 # or the heap downshift above) — the persisted tables must follow 490 # the partition or the manifest's feature_group tags would name 491 # tables that were never written. 492 grouped = sharder.build() 493 return grouped, groups 494 495 @property 496 def query_groups(self) -> "OrderedDict[str, str]": 497 """SQL for each column group: ``group_<NNN>`` -> self-contained query. 498 499 Partitions the (possibly very wide) feature matrix into ordered column 500 groups, each a valid query whose every CTE tuple is under PostgreSQL's 501 1664-entry limit (issue #7). A config that fits in one query returns a 502 single ``group_000`` entry equal to :attr:`query`. Every group leads 503 with ``(as_of_date, <target id>)`` so the groups re-join into the full 504 matrix. 505 506 When an oversized non-target child CTE had to be materialized, these 507 queries reference TEMP-table shards and **presuppose** 508 :attr:`materialization_ddl` was executed first on the same session; 509 :meth:`to_arrow` / :meth:`to_parquet` do that automatically. 510 """ 511 return self._grouped().queries 512 513 @property 514 def materialization_ddl(self) -> List[str]: 515 """The ``CREATE TEMP TABLE`` preamble (issue #7) that :attr:`query_groups` 516 presupposes, or ``[]`` when no oversized child CTE needs materializing. 517 518 Run these statements on the same connection/session before executing the 519 grouped queries. :meth:`to_arrow` / :meth:`to_parquet` / :meth:`to_dataframe` 520 run them for you. 521 """ 522 mplan = self._grouped().materialization 523 return list(mplan.ddl) if mplan is not None else [] 524 525 def to_dataframe( 526 self, *, connection: Any = None, impute: bool = False, **impute_kwargs: Any 527 ) -> pd.DataFrame: 528 """Execute the query and return results as a DataFrame. 529 530 Args: 531 connection: An open psycopg connection to run on (required when the 532 query references session ``TEMP`` tables — the integration 533 harness). When ``None``, the fast single-query path uses 534 ``records`` and the grouped/materialized path builds its own 535 connection from the environment and closes it afterwards. 536 impute: When True, run the opt-in imputation pass (count-like 537 features → 0, measures left NULL unless ``measure_strategy`` is 538 given, with ``<feature>__missing`` indicator columns). The 539 default keeps the raw NULLs, since missingness is signal. 540 **impute_kwargs: Forwarded to 541 :func:`featurizer.imputation.impute_features`. 542 543 A config that fits one valid query uses the fast (single-query) path; a 544 wide or oversized-child config (issue #7) runs the column-group queries — 545 and any TEMP-table materialization preamble — on one connection and 546 re-joins them on ``(as_of_date, target_id)`` into the same indexed frame. 547 Passing ``connection`` forces the one-connection path (so it can see 548 session TEMP tables) regardless of width. 549 550 Returns: 551 DataFrame indexed by ['as_of_date', target_id] 552 553 Raises: 554 ValueError: If target entity doesn't define a primary ID 555 """ 556 if self.target.id is None: 557 raise ValueError( 558 f"Target entity '{self.target.alias}' does not define a primary id." 559 ) 560 grouped = self._grouped() 561 if grouped.fits_single and connection is None: 562 df = self._executor.to_dataframe(self.query, self.target.id.name) 563 else: 564 df = self._executor.to_dataframe_materialized( 565 preamble_ddl=( 566 grouped.materialization.ddl 567 if grouped.materialization is not None 568 else [] 569 ), 570 group_queries=grouped.queries, 571 target_id=self.target.id.name, 572 connection=connection, 573 key_columns=grouped.key_columns, 574 ) 575 if impute: 576 from .imputation import guard_full_matrix_fit, impute_features 577 578 # Engine path: this fits over the whole returned matrix, so gate the 579 # leaky measure strategies (ADR-0001). The pure impute_features helper 580 # stays ungated for callers that pre-split their own data. 581 guard_full_matrix_fit( 582 impute_kwargs.get("measure_strategy", "none"), 583 allow_full_matrix_fit=bool( 584 impute_kwargs.pop("allow_full_matrix_fit", False) 585 ), 586 caller="to_dataframe", 587 ) 588 df = impute_features(df, **impute_kwargs) 589 return df 590 591 def to_arrow( 592 self, 593 *, 594 connection: Any = None, 595 numeric_as_float: bool = True, 596 impute: bool = False, 597 **impute_kwargs: Any, 598 ) -> "Any": 599 """Execute the query and return Arrow output, no pandas hop. 600 601 Streams the result out of PostgreSQL with binary ``COPY`` and decodes it 602 column-by-column into Arrow, so SQL NULLs are preserved as Arrow nulls 603 (never coerced to ``NaN``) and the full result set never materializes as 604 a pandas frame. ``as_of_date`` and the target id are ordinary leading 605 columns (no index), unlike :meth:`to_dataframe`. 606 607 Sharding (issue #7): when the matrix fits in one query a single 608 :class:`pyarrow.Table` is returned. When it is too wide for a single 609 valid query (over PostgreSQL's 1664-entry target-list limit), an 610 ``OrderedDict[str, pyarrow.Table]`` of column groups is returned instead 611 — ``group_<NNN>`` -> table. Every group table leads with 612 ``(as_of_date, <target id>)`` and the groups re-join on those keys to 613 reconstruct the full matrix. 614 615 Args: 616 connection: An open psycopg connection to run ``COPY`` on. Required 617 when the rendered query references session ``TEMP`` tables (the 618 integration harness). When ``None``, a connection is built from 619 ``DATABASE_URL`` / ``PG*`` and closed afterwards. A single 620 connection is reused across all groups. 621 numeric_as_float: Cast PostgreSQL ``numeric`` aggregate columns to 622 ``float64`` (ML-ready, ``to_dataframe``-comparable). Set ``False`` 623 to keep exact ``decimal128``. NULLs are preserved either way. 624 impute: When True, apply the Arrow-native imputation pass 625 (:func:`featurizer.imputation.impute_arrow`) to each group: 626 count-like features → 0, measures left null unless 627 ``measure_strategy`` is given, with stable ``<feature>__missing`` 628 indicator columns. ``as_of_date`` and the target id are passed as 629 ``key_columns`` and left untouched. 630 **impute_kwargs: Forwarded to ``impute_arrow``. ``measure_strategy`` in 631 ``{"mean","median"}`` additionally requires 632 ``allow_full_matrix_fit=True`` (ADR-0001 leakage gate). 633 634 Returns: 635 A ``pyarrow.Table`` for a single-group config, otherwise an 636 ``OrderedDict[str, pyarrow.Table]`` keyed by group id. 637 638 Raises: 639 ImportError: If pyarrow (the ``[parquet]`` extra) is not installed. 640 ValueError: If the target entity does not define a primary id, or a 641 leaky measure strategy is requested without the opt-in. 642 """ 643 if self.target.id is None: 644 raise ValueError( 645 f"Target entity '{self.target.alias}' does not define a primary id." 646 ) 647 groups = self._arrow_groups( 648 connection=connection, 649 numeric_as_float=numeric_as_float, 650 impute=impute, 651 **impute_kwargs, 652 ) 653 if len(groups) == 1: 654 # Single-group config: preserve the original single-Table contract. 655 return next(iter(groups.values())) 656 return groups 657 658 def to_parquet( 659 self, 660 path: str, 661 *, 662 connection: Any = None, 663 numeric_as_float: bool = True, 664 impute: bool = False, 665 **impute_kwargs: Any, 666 ) -> None: 667 """Execute the query and write the result to Parquet. 668 669 Thin wrapper over the Arrow path plus ``pyarrow.parquet.write_table``; 670 all arguments (including the imputation contract and its ADR-0001 leakage 671 gate) behave exactly as in :meth:`to_arrow`. NULLs are written as Parquet 672 nulls. 673 674 Sharding (issue #7): when the matrix fits in one query, a single Parquet 675 file is written at ``path``. When it is too wide for one valid query, 676 ``path`` is treated as a **directory** and one Parquet file per column 677 group is written under it as ``group_<NNN>.parquet``. All group files 678 re-join on ``(as_of_date, <target id>)`` to reconstruct the full matrix. 679 680 Args: 681 path: Destination ``.parquet`` file (single group) or output 682 directory (multiple groups; created if absent). 683 connection: See :meth:`to_arrow`. 684 numeric_as_float: See :meth:`to_arrow`. 685 impute: See :meth:`to_arrow`. 686 **impute_kwargs: See :meth:`to_arrow`. 687 688 Raises: 689 ImportError: If pyarrow (the ``[parquet]`` extra) is not installed. 690 """ 691 import pyarrow.parquet as pq # pyright: ignore[reportMissingImports] 692 693 groups = self._arrow_groups( 694 connection=connection, 695 numeric_as_float=numeric_as_float, 696 impute=impute, 697 **impute_kwargs, 698 ) 699 if len(groups) == 1: 700 pq.write_table(next(iter(groups.values())), path) 701 return 702 703 import os as _os 704 705 _os.makedirs(path, exist_ok=True) 706 for gid, table in groups.items(): 707 pq.write_table(table, _os.path.join(path, f"{gid}.parquet")) 708 logger.info( 709 "Wrote {} column-group Parquet files to {} (re-join on {}).", 710 len(groups), 711 path, 712 ("as_of_date", self.target.id.name) if self.target.id else "(as_of_date,)", 713 ) 714 715 def to_tables( 716 self, 717 schema: str, 718 *, 719 connection: Any = None, 720 table_prefix: str | None = None, 721 create_schema: bool = True, 722 ) -> List["FeatureGroupTable"]: 723 """Persist the feature matrix as triage-style feature-group tables. 724 725 Writes each column group as a persistent table 726 ``"<schema>"."<stem>_group_<NNN>"`` keyed on ``(as_of_date, <target id>)``, 727 the feature-group contract triage-pg consumes (issue #7). A config that 728 fits one query writes a single ``<stem>_group_000``; a wide or 729 oversized-child config writes one table per column group, all re-joinable 730 on the keys. The issue-#7 intermediate shards stay ephemeral ``TEMP`` 731 tables — only the final groups are persisted. 732 733 Idempotent: each target table is ``DROP TABLE IF EXISTS`` + ``CREATE TABLE 734 … AS`` so a re-run replaces it cleanly. 735 736 Alongside the group tables, the feature manifest is persisted as 737 ``"<schema>"."<stem>_manifest"`` — one row per output column (label, 738 lineage, generated description, and the ``feature_group`` it landed 739 in), joinable to the group tables by column name. The returned list 740 contains the feature-group tables only, as before. 741 742 Args: 743 schema: Destination schema (created if absent unless 744 ``create_schema=False``). 745 connection: An open psycopg connection to write on. When supplied the 746 caller owns the transaction (nothing is committed here — the 747 integration harness verifies within its rolled-back transaction); 748 when ``None`` a connection is built from the environment, committed 749 so the tables persist, and closed. 750 table_prefix: Table-name stem; defaults to the target alias 751 (``stores`` -> ``stores_group_000``). 752 create_schema: Run ``CREATE SCHEMA IF NOT EXISTS`` first. 753 754 Returns: 755 The ordered manifest of created :class:`FeatureGroupTable`s. 756 757 Raises: 758 ValueError: If the target entity does not define a primary id. 759 """ 760 if self.target.id is None: 761 raise ValueError( 762 f"Target entity '{self.target.alias}' does not define a primary id." 763 ) 764 from .arrow import default_connection 765 from .sharding import FeatureGroupTable 766 767 grouped, column_groups = self._grouped_for_tables() 768 preamble = ( 769 grouped.materialization.ddl if grouped.materialization is not None else [] 770 ) 771 stem = table_prefix or self.target.alias 772 keys = list(grouped.key_columns) 773 774 own_connection = connection is None 775 conn = connection if connection is not None else default_connection() 776 tables: List["FeatureGroupTable"] = [] 777 try: 778 # jit off from the preamble on (issue #53); a caller's value is put 779 # back before their transaction goes on. 780 with jit_disabled(conn): 781 with conn.cursor() as cur: 782 if create_schema: 783 cur.execute(f'create schema if not exists "{schema}"') 784 for ddl in preamble: 785 cur.execute(ddl) 786 analyze_as_of_dates(conn) # planner-stats optimization (see executor) 787 if own_connection: # PLANNER_TUNING never reaches a caller's 788 apply_planner_tuning(conn) 789 with conn.cursor() as cur: 790 for gid, sql in grouped.queries.items(): 791 name = f'"{schema}"."{stem}_{gid}"' 792 cur.execute(f"drop table if exists {name}") 793 cur.execute(f"create table {name} as\n{sql}") 794 tables.append( 795 FeatureGroupTable( 796 name=name, group=gid, key_columns=list(keys) 797 ) 798 ) 799 self._write_manifest_table(cur, schema, stem, column_groups) 800 if own_connection: 801 conn.commit() 802 finally: 803 if own_connection: 804 conn.close() 805 logger.info( 806 "Persisted {} feature-group table(s) + manifest to schema {!r} " 807 "(re-join on {}).", 808 len(tables), 809 schema, 810 tuple(keys), 811 ) 812 return tables 813 814 def _write_manifest_table( 815 self, 816 cur: Any, 817 schema: str, 818 stem: str, 819 column_groups: "OrderedDict[str, List[str]]", 820 ) -> None: 821 """Persist the feature manifest as ``"<schema>"."<stem>_manifest"``. 822 823 One row per output feature column, including which feature-group table 824 the column landed in (``feature_group``, joinable back to the 825 ``<stem>_group_<NNN>`` tables by column name). Same contracts as the 826 group tables: idempotent DROP+CREATE, and the caller owns the 827 transaction. Values are inserted parameterized — labels and definitions 828 contain quotes and arbitrary SQL text. 829 830 ``column_groups`` is the exact partition the group tables were written 831 from (:meth:`_grouped_for_tables`) — never a freshly computed one, so 832 the ``feature_group`` tags cannot drift from the persisted tables. A 833 manifest column absent from the partition raises rather than being 834 silently mis-tagged: triage consumes this table for lineage, and a 835 wrong ``feature_group`` corrupts it downstream. 836 """ 837 column_to_group: Dict[str, str] = {} 838 for gid, names in column_groups.items(): 839 for column_name in names: 840 column_to_group[column_name.replace('"', "")] = gid 841 842 orphaned = [ 843 entry.column 844 for entry in self.feature_manifest 845 if entry.column not in column_to_group 846 ] 847 if orphaned: 848 raise RuntimeError( 849 f"Feature manifest column(s) map to no column group: " 850 f"{orphaned[:5]!r}{' …' if len(orphaned) > 5 else ''} " 851 f"(available groups: {', '.join(column_groups) or '(none)'}). " 852 "Persisting would mis-tag feature_group in " 853 f'"{schema}"."{stem}_manifest" and corrupt lineage for ' 854 "downstream consumers (triage joins on it). This is a " 855 "featurizer bug — the manifest and the column partition must " 856 "describe the same plan; please report it with your config." 857 ) 858 859 name = f'"{schema}"."{stem}_manifest"' 860 cur.execute(f"drop table if exists {name}") 861 cur.execute(f""" 862 create table {name} ( 863 "column_name" text not null, 864 "label" text not null, 865 "truncated" boolean not null, 866 "kind" text not null, 867 "entity" text, 868 "source_alias" text, 869 "depth" integer not null, 870 "parents" text[] not null, 871 "interval" text, 872 "source_column" text, 873 "value" text, 874 "description" text not null, 875 "definition" text, 876 "feature_group" text not null 877 ) 878 """) 879 rows = [ 880 ( 881 entry.column, 882 entry.label, 883 entry.truncated, 884 entry.kind, 885 entry.entity, 886 entry.source_alias, 887 entry.depth, 888 entry.parents, 889 entry.interval, 890 entry.source_column, 891 entry.value, 892 entry.description, 893 entry.definition, 894 column_to_group[entry.column], 895 ) 896 for entry in self.feature_manifest 897 ] 898 if rows: 899 cur.executemany( 900 f"insert into {name} values " 901 "(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)", 902 rows, 903 ) 904 905 def _arrow_groups( 906 self, 907 *, 908 connection: Any = None, 909 numeric_as_float: bool = True, 910 impute: bool = False, 911 **impute_kwargs: Any, 912 ) -> "OrderedDict[str, Any]": 913 """Run every column group through the Arrow exporter on one connection. 914 915 Always returns an ``OrderedDict`` (a single-group config yields one 916 entry). Imputation, when requested, runs per group with the same 917 ADR-0001 leakage gate as the single-query path; the gate is checked once 918 up front so a leaky strategy is refused before any query runs. 919 """ 920 from .arrow import ArrowExporter 921 922 if impute: 923 from .imputation import guard_full_matrix_fit 924 925 guard_full_matrix_fit( 926 impute_kwargs.get("measure_strategy", "none"), 927 allow_full_matrix_fit=bool(impute_kwargs.get("allow_full_matrix_fit")), 928 caller="to_arrow", 929 ) 930 931 # Grouped queries + any TEMP-table materialization preamble (issue #7). 932 # The single-group short-circuit + oversized warning live in _grouped(). 933 grouped = self._grouped() 934 preamble = ( 935 grouped.materialization.ddl if grouped.materialization is not None else [] 936 ) 937 938 exporter = ArrowExporter() 939 own_connection = connection is None 940 conn = connection if connection is not None else exporter.open_connection() 941 try: 942 from collections import OrderedDict as _OrderedDict 943 944 # Run the CREATE TEMP TABLE preamble first, on the *same* connection, 945 # so the group queries' shard references resolve. The connection is 946 # non-autocommit (default_connection / the harness), so ON COMMIT DROP 947 # shards live for the whole transaction and drop when it closes. 948 tables: "OrderedDict[str, Any]" = _OrderedDict() 949 # jit off from the preamble on (issue #53); a caller's value is put 950 # back before their transaction goes on. 951 with jit_disabled(conn): 952 if preamble: 953 with conn.cursor() as cur: 954 for ddl in preamble: 955 cur.execute(ddl) 956 analyze_as_of_dates(conn) # planner-stats optimization (see executor) 957 if own_connection: # PLANNER_TUNING never reaches a caller's 958 apply_planner_tuning(conn) 959 960 for gid, sql in grouped.queries.items(): 961 table = exporter.to_arrow( 962 sql, connection=conn, numeric_as_float=numeric_as_float 963 ) 964 if impute: 965 table = self._impute_group(table, **impute_kwargs) 966 tables[gid] = table 967 return tables 968 finally: 969 if own_connection: 970 conn.close() 971 972 def _impute_group(self, table: Any, **impute_kwargs: Any) -> Any: 973 """Apply the Arrow imputation pass to one group's table. 974 975 The leakage gate is already checked in :meth:`_arrow_groups`; strip the 976 consumed ``allow_full_matrix_fit`` flag so ``impute_arrow`` does not see 977 an unexpected keyword. 978 """ 979 from .imputation import impute_arrow 980 981 kwargs = dict(impute_kwargs) 982 kwargs.pop("allow_full_matrix_fit", None) 983 assert self.target.id is not None # guarded by callers 984 return impute_arrow( 985 table, 986 key_columns=("as_of_date", self.target.id.name), 987 **kwargs, 988 ) 989 990 # ------------------------------------------------------------------ # 991 # Internal helpers 992 # ------------------------------------------------------------------ # 993 994 def _get_entity(self, alias: str) -> Entity: 995 """Get an entity by alias from the graph. 996 997 Args: 998 alias: Entity alias to look up 999 1000 Returns: 1001 Entity with the given alias 1002 1003 Raises: 1004 ValueError: If entity with alias doesn't exist 1005 """ 1006 entity = self.graph.entities.get(alias) 1007 if entity is None: 1008 raise ValueError(f"Unknown target entity alias '{alias}'.") 1009 return entity 1010 1011 @staticmethod 1012 def _env_debug_enabled() -> bool: 1013 """Check if debug mode is enabled via environment variable.""" 1014 value = os.getenv("FEATURIZER_DEBUG", "") 1015 return value.lower() in {"1", "true", "yes", "on"} 1016 1017 @staticmethod 1018 def _load_config(config_file: str, validate: bool = True) -> Dict[str, Any]: 1019 """Load and validate configuration from YAML file. 1020 1021 Args: 1022 config_file: Path to YAML configuration file 1023 validate: Run enhanced validation checks 1024 1025 Returns: 1026 Validated configuration dictionary 1027 1028 Raises: 1029 FileNotFoundError: If config file doesn't exist 1030 ValueError: If config is invalid or missing required keys 1031 """ 1032 try: 1033 with open(config_file) as f: 1034 config = yaml.safe_load(f) or {} 1035 except FileNotFoundError as exc: 1036 raise FileNotFoundError(f"Config file not found: {config_file}") from exc 1037 except yaml.YAMLError as exc: 1038 raise ValueError(f"Invalid YAML in config file {config_file}") from exc 1039 1040 # Run enhanced validation if enabled 1041 if validate: 1042 validator = ConfigValidator(mode="strict") 1043 result = validator.validate(config) 1044 1045 if not result.is_valid: 1046 raise ValueError( 1047 f"Configuration validation failed:\n{result.format_errors()}" 1048 ) 1049 1050 # Log warnings 1051 for warning in result.warnings: 1052 location = f"[{warning.location}] " if warning.location else "" 1053 logger.warning(f"{location}{warning.message}") 1054 1055 # Backwards compatibility: Basic validation 1056 required_keys = {"target", "max_depth", "intervals", "entities"} 1057 missing = [key for key in required_keys if key not in config] 1058 if missing: 1059 raise ValueError(f"Config missing required keys: {', '.join(missing)}") 1060 1061 if not isinstance(config["target"], str) or not config["target"].strip(): 1062 raise ValueError("'target' must be a non-empty string.") 1063 1064 if not isinstance(config["max_depth"], int) or config["max_depth"] < 1: 1065 raise ValueError("'max_depth' must be a positive integer.") 1066 1067 if not isinstance(config["entities"], list) or not config["entities"]: 1068 raise ValueError("Config must declare at least one entity in 'entities'.") 1069 1070 if not isinstance(config["intervals"], list): 1071 raise ValueError("'intervals' must be a list of interval strings.") 1072 1073 relationships = config.get("relationships") 1074 if relationships is None: 1075 logger.debug( 1076 "No relationships defined in config; defaulting to empty list." 1077 ) 1078 config["relationships"] = [] 1079 elif not isinstance(relationships, list): 1080 raise ValueError("'relationships' must be a list when provided.") 1081 1082 return config
PostgreSQL implementation of the DFS algorithm (adapted for temporal data sets).
Coordinates configuration loading, feature planning, SQL rendering, and optional database execution.
80 def __init__( 81 self, 82 config_file: str, 83 *, 84 debug: bool = False, 85 validate: bool = True, 86 materialize_threshold: int | None = None, 87 connection: Any = None, 88 ) -> None: 89 """Initialize Featurizer from a YAML configuration file. 90 91 Args: 92 config_file: Path to YAML configuration file 93 debug: Enable debug logging with icecream. Can also be set via FEATURIZER_DEBUG env var. 94 validate: Run enhanced validation checks (default: True) 95 materialize_threshold: Column width above which an oversized 96 non-target child CTE is materialized into TEMP-table shards 97 (issue #7). Defaults to PostgreSQL's hard 1664-entry limit; lower 98 it to materialize earlier (advanced / testing). 99 connection: Optional psycopg connection used only to resolve a 100 ``role: categorical`` direct variable's vocabulary from its 101 PostgreSQL ``ENUM`` labels when no ``vocabulary`` is declared in 102 the config. When omitted, one is opened from ``DATABASE_URL`` / 103 ``PG*`` if any categorical actually needs it; a declared 104 vocabulary needs no database (``query`` / ``--show-sql`` stay 105 DB-free). The connection is never used to scan data. 106 107 Raises: 108 FileNotFoundError: If config file doesn't exist 109 ValueError: If config is missing required keys or has invalid values, 110 or a ``role: categorical`` variable has neither a declared 111 ``vocabulary`` nor an introspectable PostgreSQL ``ENUM``. 112 """ 113 config = self._load_config(config_file, validate=validate) 114 self._materialize_threshold = materialize_threshold 115 116 self._debug_enabled: bool = debug or self._env_debug_enabled() 117 if self._debug_enabled: 118 ic.configureOutput(prefix="[Featurizer] ", includeContext=True) 119 120 self.max_depth: int = config["max_depth"] 121 self.intervals: List[str] = config["intervals"] 122 # Point-in-time boundary mode: ``inclusive`` (default, ``<=``) keeps an 123 # event dated exactly on the as_of_date knowable; ``exclusive`` (``<``) 124 # treats it as not-yet-knowable. Validated in ConfigValidator. 125 self.as_of_boundary: AsOfBoundary = config.get( 126 "as_of_boundary", DEFAULT_BOUNDARY 127 ) 128 129 # Paired cohort (issue #10): the column of the caller's as_of_dates 130 # table that holds the target id. Absent -> the dense cohort, every 131 # target row under every as-of date, rendered exactly as before. 132 self.cohort_id_column: Optional[str] = (config.get("as_of_dates") or {}).get( 133 "id_column" 134 ) 135 136 self.graph: ERGraph = ERGraph( 137 config["entities"], 138 config["relationships"], 139 config.get("spatial_relationships"), 140 config.get("graph_relationships"), 141 ) 142 self.target: Entity = self._get_entity(config["target"]) 143 144 # Resolve fixed vocabularies for the target's role: categorical direct 145 # variables (declared list, else introspected ENUM) before planning so 146 # the planner stays DB-free. Fails loud if a categorical can be neither. 147 self._resolve_categorical_vocabularies(connection) 148 149 # Primitive selection: config may override the active set; an absent or 150 # null key applies the curated module defaults. An explicit empty list 151 # suppresses that layer instead: `aggregations: []` builds zero 152 # aggregation features, and `transformations: []` passes features 153 # through unchanged — spelled as identity because the transform CTE 154 # also feeds child aggregations, so a truly empty transformer set 155 # would drop every feature column. Unknown names raise in get_* (and 156 # are caught earlier with suggestions by the validator when 157 # validate=True). 158 agg_names = config.get("aggregations") 159 if agg_names is None: 160 agg_names = DEFAULT_AGGREGATIONS 161 tx_names = config.get("transformations") 162 if tx_names is None: 163 tx_names = DEFAULT_TRANSFORMATIONS 164 elif not tx_names: 165 tx_names = ("identity",) 166 self.aggregations: AggregationRegistry = get_aggregations(agg_names) 167 self.transformations: TransformationRegistry = get_transformers(tx_names) 168 169 planner = FeaturePlanner( 170 graph=self.graph, 171 target_alias=self.target.alias, 172 max_depth=self.max_depth, 173 intervals=self.intervals, 174 aggregations=self.aggregations, 175 transformations=self.transformations, 176 boundary=self.as_of_boundary, 177 debug=self._debug_enabled, 178 cohort_id_column=self.cohort_id_column, 179 ) 180 self._plan: PlannerResult = planner.plan() 181 182 self.features: Dict[str, Set[Feature]] = { 183 alias: set(features) for alias, features in self._plan.features.items() 184 } 185 self.ctes: List[str] = list(self._plan.ctes) 186 self.joins: Dict[str, List[str]] = { 187 alias: list(joins) for alias, joins in self._plan.joins.items() 188 } 189 190 self._renderer: SQLRenderer = SQLRenderer() 191 self._executor: QueryExecutor = QueryExecutor()
Initialize Featurizer from a YAML configuration file.
Arguments:
- config_file: Path to YAML configuration file
- debug: Enable debug logging with icecream. Can also be set via FEATURIZER_DEBUG env var.
- validate: Run enhanced validation checks (default: True)
- materialize_threshold: Column width above which an oversized non-target child CTE is materialized into TEMP-table shards (issue #7). Defaults to PostgreSQL's hard 1664-entry limit; lower it to materialize earlier (advanced / testing).
- connection: Optional psycopg connection used only to resolve a
role: categoricaldirect variable's vocabulary from its PostgreSQLENUMlabels when novocabularyis declared in the config. When omitted, one is opened fromDATABASE_URL/PG*if any categorical actually needs it; a declared vocabulary needs no database (query/--show-sqlstay DB-free). The connection is never used to scan data.
Raises:
- FileNotFoundError: If config file doesn't exist
- ValueError: If config is missing required keys or has invalid values,
or a
role: categoricalvariable has neither a declaredvocabularynor an introspectable PostgreSQLENUM.
242 @property 243 def entities(self) -> Iterable[Entity]: 244 """Return all entities in the graph.""" 245 return self.graph.entities.values()
Return all entities in the graph.
247 @property 248 def relationships(self) -> List[Any]: 249 """Return all relationships in the graph.""" 250 return self.graph.relationships
Return all relationships in the graph.
252 @property 253 def feature_manifest(self) -> "List[ManifestEntry]": 254 """Map every output column to its full, untruncated intended name. 255 256 One row per output feature column (in output order), each carrying the 257 rendered ``column`` name, the human-readable ``label`` (recovered even 258 when the 63-byte identifier cap truncated the column name), a 259 ``truncated`` flag, the ``kind`` (``one_hot`` | ``variable`` | 260 ``derived``), the owning ``entity``, and — for one-hot columns — the 261 ``source_column`` and ``value`` they encode. Useful for human/partner 262 labels, plot legends, and joining readable names back onto the matrix. 263 """ 264 from .manifest import build_feature_manifest 265 266 return build_feature_manifest(self._plan.target_output_features)
Map every output column to its full, untruncated intended name.
One row per output feature column (in output order), each carrying the
rendered column name, the human-readable label (recovered even
when the 63-byte identifier cap truncated the column name), a
truncated flag, the kind (one_hot | variable |
derived), the owning entity, and — for one-hot columns — the
source_column and value they encode. Useful for human/partner
labels, plot legends, and joining readable names back onto the matrix.
268 def manifest_dataframe(self) -> "pd.DataFrame": 269 """The feature manifest as a pandas DataFrame (table / plots / joins).""" 270 from .manifest import manifest_dataframe 271 272 return manifest_dataframe(self.feature_manifest)
The feature manifest as a pandas DataFrame (table / plots / joins).
274 def manifest_matching( 275 self, pattern: str, *, allow_empty: bool = False 276 ) -> "List[ManifestEntry]": 277 """Manifest entries whose full ``label`` matches a shell-style glob. 278 279 Match against the **label**, never the physical column name: names 280 longer than PostgreSQL's 63-byte identifier limit are hash-truncated 281 from the tail, so a pattern targeting an inner fragment 282 (``*(inspections.kw_*``) misses every truncated column. The label is 283 the only lossless surface — ``definition`` inherits the parent's 284 truncation and drops the interval, so windowed variants share one. 285 286 Args: 287 pattern: Glob matched against ``label``. Case-sensitive. ``_`` is a 288 literal here (unlike SQL ``LIKE`` — see 289 :func:`~featurizer.manifest.glob_to_like` if you are querying 290 the persisted manifest table by hand). 291 allow_empty: Return ``[]`` instead of raising when nothing matches. 292 293 Returns: 294 Matching entries in output order. 295 296 Raises: 297 LookupError: When nothing matches and ``allow_empty`` is False — 298 a silently empty selection is the failure this helper exists to 299 prevent. The message suggests near-miss labels. 300 301 Example: 302 >>> f.manifest_matching("*kw_rodent*")[0].column 303 'CUM_SUM(facilities.MEAN(inspections.ABS(inspections.kw_rod~978dcf98' 304 """ 305 from .manifest import filter_manifest 306 307 return filter_manifest(self.feature_manifest, pattern, allow_empty=allow_empty)
Manifest entries whose full label matches a shell-style glob.
Match against the label, never the physical column name: names
longer than PostgreSQL's 63-byte identifier limit are hash-truncated
from the tail, so a pattern targeting an inner fragment
(*(inspections.kw_*) misses every truncated column. The label is
the only lossless surface — definition inherits the parent's
truncation and drops the interval, so windowed variants share one.
Arguments:
- pattern: Glob matched against
label. Case-sensitive._is a literal here (unlike SQLLIKE— see~featurizer.manifest.glob_to_like()if you are querying the persisted manifest table by hand). - allow_empty: Return
[]instead of raising when nothing matches.
Returns:
Matching entries in output order.
Raises:
- LookupError: When nothing matches and
allow_emptyis False — a silently empty selection is the failure this helper exists to prevent. The message suggests near-miss labels.
Example:
>>> f.manifest_matching("*kw_rodent*")[0].column 'CUM_SUM(facilities.MEAN(inspections.ABS(inspections.kw_rod~978dcf98'
309 def columns_matching(self, pattern: str, *, allow_empty: bool = False) -> List[str]: 310 """Physical column names whose full ``label`` matches a glob, in output order. 311 312 The projection of :meth:`manifest_matching` onto the rendered column 313 name — what you put in a ``select`` list, a feature-group definition, or 314 a DataFrame projection. 315 316 Building a triage ``feature_groups.definitions`` entry from physical 317 names directly will abort the run on every truncated column ("matches no 318 feature_groups.definitions glob"); resolve it here first:: 319 320 definitions = {"cardiac": f.columns_matching("*frecuencia_cardiaca*")} 321 322 Args: 323 pattern: Glob matched against ``label``. Case-sensitive. 324 allow_empty: Return ``[]`` instead of raising when nothing matches. 325 326 Returns: 327 The rendered (possibly truncated) column names, in output order. 328 329 Raises: 330 LookupError: When nothing matches and ``allow_empty`` is False. 331 """ 332 return [ 333 entry.column 334 for entry in self.manifest_matching(pattern, allow_empty=allow_empty) 335 ]
Physical column names whose full label matches a glob, in output order.
The projection of manifest_matching() onto the rendered column
name — what you put in a select list, a feature-group definition, or
a DataFrame projection.
Building a triage feature_groups.definitions entry from physical
names directly will abort the run on every truncated column ("matches no
feature_groups.definitions glob"); resolve it here first::
definitions = {"cardiac": f.columns_matching("*frecuencia_cardiaca*")}
Arguments:
- pattern: Glob matched against
label. Case-sensitive. - allow_empty: Return
[]instead of raising when nothing matches.
Returns:
The rendered (possibly truncated) column names, in output order.
Raises:
- LookupError: When nothing matches and
allow_emptyis False.
337 @property 338 def query(self) -> str: 339 """Generate the single SQL query for this featurizer configuration. 340 341 Raises: 342 ValueError: If the configuration is too wide to render as one valid 343 query — the ``<target>_transform`` CTE (or an intermediate CTE) 344 would exceed PostgreSQL's 1664-entry target-list limit. Use 345 :attr:`query_groups` / :meth:`to_parquet` / :meth:`to_arrow` 346 (column-group sharding, issue #7) instead. The matrix is never 347 silently truncated. 348 """ 349 sharder = self._sharder() 350 if not sharder.fits_single_group: 351 n_groups = sharder.n_groups 352 raise ValueError( 353 f"Feature matrix for target '{self.target.alias}' is too wide for a " 354 "single query: it exceeds PostgreSQL's 1664-entry target-list limit " 355 f"and partitions into {n_groups} column groups. Use " 356 "`.query_groups` (group_id -> SQL), `.to_parquet(dir)` (one Parquet " 357 "per group), or `.to_arrow()` (list of tables); all groups re-join " 358 "on (as_of_date, id). `.to_arrow`/`.to_parquet`/`.to_dataframe` also " 359 "materialize any oversized child CTE automatically (issue #7). See " 360 "docs/adr/0005-column-group-sharding.md." 361 ) 362 return self._renderer.render(self._plan)
Generate the single SQL query for this featurizer configuration.
Raises:
- ValueError: If the configuration is too wide to render as one valid
query — the
<target>_transformCTE (or an intermediate CTE) would exceed PostgreSQL's 1664-entry target-list limit. Usequery_groups/to_parquet()/to_arrow()(column-group sharding, issue #7) instead. The matrix is never silently truncated.
495 @property 496 def query_groups(self) -> "OrderedDict[str, str]": 497 """SQL for each column group: ``group_<NNN>`` -> self-contained query. 498 499 Partitions the (possibly very wide) feature matrix into ordered column 500 groups, each a valid query whose every CTE tuple is under PostgreSQL's 501 1664-entry limit (issue #7). A config that fits in one query returns a 502 single ``group_000`` entry equal to :attr:`query`. Every group leads 503 with ``(as_of_date, <target id>)`` so the groups re-join into the full 504 matrix. 505 506 When an oversized non-target child CTE had to be materialized, these 507 queries reference TEMP-table shards and **presuppose** 508 :attr:`materialization_ddl` was executed first on the same session; 509 :meth:`to_arrow` / :meth:`to_parquet` do that automatically. 510 """ 511 return self._grouped().queries
SQL for each column group: group_<NNN> -> self-contained query.
Partitions the (possibly very wide) feature matrix into ordered column
groups, each a valid query whose every CTE tuple is under PostgreSQL's
1664-entry limit (issue #7). A config that fits in one query returns a
single group_000 entry equal to query. Every group leads
with (as_of_date, <target id>) so the groups re-join into the full
matrix.
When an oversized non-target child CTE had to be materialized, these
queries reference TEMP-table shards and presuppose
materialization_ddl was executed first on the same session;
to_arrow() / to_parquet() do that automatically.
513 @property 514 def materialization_ddl(self) -> List[str]: 515 """The ``CREATE TEMP TABLE`` preamble (issue #7) that :attr:`query_groups` 516 presupposes, or ``[]`` when no oversized child CTE needs materializing. 517 518 Run these statements on the same connection/session before executing the 519 grouped queries. :meth:`to_arrow` / :meth:`to_parquet` / :meth:`to_dataframe` 520 run them for you. 521 """ 522 mplan = self._grouped().materialization 523 return list(mplan.ddl) if mplan is not None else []
The CREATE TEMP TABLE preamble (issue #7) that query_groups
presupposes, or [] when no oversized child CTE needs materializing.
Run these statements on the same connection/session before executing the
grouped queries. to_arrow() / to_parquet() / to_dataframe()
run them for you.
525 def to_dataframe( 526 self, *, connection: Any = None, impute: bool = False, **impute_kwargs: Any 527 ) -> pd.DataFrame: 528 """Execute the query and return results as a DataFrame. 529 530 Args: 531 connection: An open psycopg connection to run on (required when the 532 query references session ``TEMP`` tables — the integration 533 harness). When ``None``, the fast single-query path uses 534 ``records`` and the grouped/materialized path builds its own 535 connection from the environment and closes it afterwards. 536 impute: When True, run the opt-in imputation pass (count-like 537 features → 0, measures left NULL unless ``measure_strategy`` is 538 given, with ``<feature>__missing`` indicator columns). The 539 default keeps the raw NULLs, since missingness is signal. 540 **impute_kwargs: Forwarded to 541 :func:`featurizer.imputation.impute_features`. 542 543 A config that fits one valid query uses the fast (single-query) path; a 544 wide or oversized-child config (issue #7) runs the column-group queries — 545 and any TEMP-table materialization preamble — on one connection and 546 re-joins them on ``(as_of_date, target_id)`` into the same indexed frame. 547 Passing ``connection`` forces the one-connection path (so it can see 548 session TEMP tables) regardless of width. 549 550 Returns: 551 DataFrame indexed by ['as_of_date', target_id] 552 553 Raises: 554 ValueError: If target entity doesn't define a primary ID 555 """ 556 if self.target.id is None: 557 raise ValueError( 558 f"Target entity '{self.target.alias}' does not define a primary id." 559 ) 560 grouped = self._grouped() 561 if grouped.fits_single and connection is None: 562 df = self._executor.to_dataframe(self.query, self.target.id.name) 563 else: 564 df = self._executor.to_dataframe_materialized( 565 preamble_ddl=( 566 grouped.materialization.ddl 567 if grouped.materialization is not None 568 else [] 569 ), 570 group_queries=grouped.queries, 571 target_id=self.target.id.name, 572 connection=connection, 573 key_columns=grouped.key_columns, 574 ) 575 if impute: 576 from .imputation import guard_full_matrix_fit, impute_features 577 578 # Engine path: this fits over the whole returned matrix, so gate the 579 # leaky measure strategies (ADR-0001). The pure impute_features helper 580 # stays ungated for callers that pre-split their own data. 581 guard_full_matrix_fit( 582 impute_kwargs.get("measure_strategy", "none"), 583 allow_full_matrix_fit=bool( 584 impute_kwargs.pop("allow_full_matrix_fit", False) 585 ), 586 caller="to_dataframe", 587 ) 588 df = impute_features(df, **impute_kwargs) 589 return df
Execute the query and return results as a DataFrame.
Arguments:
- connection: An open psycopg connection to run on (required when the
query references session
TEMPtables — the integration harness). WhenNone, the fast single-query path usesrecordsand the grouped/materialized path builds its own connection from the environment and closes it afterwards. - impute: When True, run the opt-in imputation pass (count-like
features → 0, measures left NULL unless
measure_strategyis given, with<feature>__missingindicator columns). The default keeps the raw NULLs, since missingness is signal. - **impute_kwargs: Forwarded to
featurizer.imputation.impute_features().
A config that fits one valid query uses the fast (single-query) path; a
wide or oversized-child config (issue #7) runs the column-group queries —
and any TEMP-table materialization preamble — on one connection and
re-joins them on (as_of_date, target_id) into the same indexed frame.
Passing connection forces the one-connection path (so it can see
session TEMP tables) regardless of width.
Returns:
DataFrame indexed by ['as_of_date', target_id]
Raises:
- ValueError: If target entity doesn't define a primary ID
591 def to_arrow( 592 self, 593 *, 594 connection: Any = None, 595 numeric_as_float: bool = True, 596 impute: bool = False, 597 **impute_kwargs: Any, 598 ) -> "Any": 599 """Execute the query and return Arrow output, no pandas hop. 600 601 Streams the result out of PostgreSQL with binary ``COPY`` and decodes it 602 column-by-column into Arrow, so SQL NULLs are preserved as Arrow nulls 603 (never coerced to ``NaN``) and the full result set never materializes as 604 a pandas frame. ``as_of_date`` and the target id are ordinary leading 605 columns (no index), unlike :meth:`to_dataframe`. 606 607 Sharding (issue #7): when the matrix fits in one query a single 608 :class:`pyarrow.Table` is returned. When it is too wide for a single 609 valid query (over PostgreSQL's 1664-entry target-list limit), an 610 ``OrderedDict[str, pyarrow.Table]`` of column groups is returned instead 611 — ``group_<NNN>`` -> table. Every group table leads with 612 ``(as_of_date, <target id>)`` and the groups re-join on those keys to 613 reconstruct the full matrix. 614 615 Args: 616 connection: An open psycopg connection to run ``COPY`` on. Required 617 when the rendered query references session ``TEMP`` tables (the 618 integration harness). When ``None``, a connection is built from 619 ``DATABASE_URL`` / ``PG*`` and closed afterwards. A single 620 connection is reused across all groups. 621 numeric_as_float: Cast PostgreSQL ``numeric`` aggregate columns to 622 ``float64`` (ML-ready, ``to_dataframe``-comparable). Set ``False`` 623 to keep exact ``decimal128``. NULLs are preserved either way. 624 impute: When True, apply the Arrow-native imputation pass 625 (:func:`featurizer.imputation.impute_arrow`) to each group: 626 count-like features → 0, measures left null unless 627 ``measure_strategy`` is given, with stable ``<feature>__missing`` 628 indicator columns. ``as_of_date`` and the target id are passed as 629 ``key_columns`` and left untouched. 630 **impute_kwargs: Forwarded to ``impute_arrow``. ``measure_strategy`` in 631 ``{"mean","median"}`` additionally requires 632 ``allow_full_matrix_fit=True`` (ADR-0001 leakage gate). 633 634 Returns: 635 A ``pyarrow.Table`` for a single-group config, otherwise an 636 ``OrderedDict[str, pyarrow.Table]`` keyed by group id. 637 638 Raises: 639 ImportError: If pyarrow (the ``[parquet]`` extra) is not installed. 640 ValueError: If the target entity does not define a primary id, or a 641 leaky measure strategy is requested without the opt-in. 642 """ 643 if self.target.id is None: 644 raise ValueError( 645 f"Target entity '{self.target.alias}' does not define a primary id." 646 ) 647 groups = self._arrow_groups( 648 connection=connection, 649 numeric_as_float=numeric_as_float, 650 impute=impute, 651 **impute_kwargs, 652 ) 653 if len(groups) == 1: 654 # Single-group config: preserve the original single-Table contract. 655 return next(iter(groups.values())) 656 return groups
Execute the query and return Arrow output, no pandas hop.
Streams the result out of PostgreSQL with binary COPY and decodes it
column-by-column into Arrow, so SQL NULLs are preserved as Arrow nulls
(never coerced to NaN) and the full result set never materializes as
a pandas frame. as_of_date and the target id are ordinary leading
columns (no index), unlike to_dataframe().
Sharding (issue #7): when the matrix fits in one query a single
pyarrow.Table is returned. When it is too wide for a single
valid query (over PostgreSQL's 1664-entry target-list limit), an
OrderedDict[str, pyarrow.Table] of column groups is returned instead
— group_<NNN> -> table. Every group table leads with
(as_of_date, <target id>) and the groups re-join on those keys to
reconstruct the full matrix.
Arguments:
- connection: An open psycopg connection to run
COPYon. Required when the rendered query references sessionTEMPtables (the integration harness). WhenNone, a connection is built fromDATABASE_URL/PG*and closed afterwards. A single connection is reused across all groups. - numeric_as_float: Cast PostgreSQL
numericaggregate columns tofloat64(ML-ready,to_dataframe-comparable). SetFalseto keep exactdecimal128. NULLs are preserved either way. - impute: When True, apply the Arrow-native imputation pass
(
featurizer.imputation.impute_arrow()) to each group: count-like features → 0, measures left null unlessmeasure_strategyis given, with stable<feature>__missingindicator columns.as_of_dateand the target id are passed askey_columnsand left untouched. - **impute_kwargs: Forwarded to
impute_arrow.measure_strategyin{"mean","median"}additionally requiresallow_full_matrix_fit=True(ADR-0001 leakage gate).
Returns:
A
pyarrow.Tablefor a single-group config, otherwise anOrderedDict[str, pyarrow.Table]keyed by group id.
Raises:
- ImportError: If pyarrow (the
[parquet]extra) is not installed. - ValueError: If the target entity does not define a primary id, or a leaky measure strategy is requested without the opt-in.
658 def to_parquet( 659 self, 660 path: str, 661 *, 662 connection: Any = None, 663 numeric_as_float: bool = True, 664 impute: bool = False, 665 **impute_kwargs: Any, 666 ) -> None: 667 """Execute the query and write the result to Parquet. 668 669 Thin wrapper over the Arrow path plus ``pyarrow.parquet.write_table``; 670 all arguments (including the imputation contract and its ADR-0001 leakage 671 gate) behave exactly as in :meth:`to_arrow`. NULLs are written as Parquet 672 nulls. 673 674 Sharding (issue #7): when the matrix fits in one query, a single Parquet 675 file is written at ``path``. When it is too wide for one valid query, 676 ``path`` is treated as a **directory** and one Parquet file per column 677 group is written under it as ``group_<NNN>.parquet``. All group files 678 re-join on ``(as_of_date, <target id>)`` to reconstruct the full matrix. 679 680 Args: 681 path: Destination ``.parquet`` file (single group) or output 682 directory (multiple groups; created if absent). 683 connection: See :meth:`to_arrow`. 684 numeric_as_float: See :meth:`to_arrow`. 685 impute: See :meth:`to_arrow`. 686 **impute_kwargs: See :meth:`to_arrow`. 687 688 Raises: 689 ImportError: If pyarrow (the ``[parquet]`` extra) is not installed. 690 """ 691 import pyarrow.parquet as pq # pyright: ignore[reportMissingImports] 692 693 groups = self._arrow_groups( 694 connection=connection, 695 numeric_as_float=numeric_as_float, 696 impute=impute, 697 **impute_kwargs, 698 ) 699 if len(groups) == 1: 700 pq.write_table(next(iter(groups.values())), path) 701 return 702 703 import os as _os 704 705 _os.makedirs(path, exist_ok=True) 706 for gid, table in groups.items(): 707 pq.write_table(table, _os.path.join(path, f"{gid}.parquet")) 708 logger.info( 709 "Wrote {} column-group Parquet files to {} (re-join on {}).", 710 len(groups), 711 path, 712 ("as_of_date", self.target.id.name) if self.target.id else "(as_of_date,)", 713 )
Execute the query and write the result to Parquet.
Thin wrapper over the Arrow path plus pyarrow.parquet.write_table;
all arguments (including the imputation contract and its ADR-0001 leakage
gate) behave exactly as in to_arrow(). NULLs are written as Parquet
nulls.
Sharding (issue #7): when the matrix fits in one query, a single Parquet
file is written at path. When it is too wide for one valid query,
path is treated as a directory and one Parquet file per column
group is written under it as group_<NNN>.parquet. All group files
re-join on (as_of_date, <target id>) to reconstruct the full matrix.
Arguments:
- path: Destination
.parquetfile (single group) or output directory (multiple groups; created if absent). - connection: See
to_arrow(). - numeric_as_float: See
to_arrow(). - impute: See
to_arrow(). - **impute_kwargs: See
to_arrow().
Raises:
- ImportError: If pyarrow (the
[parquet]extra) is not installed.
715 def to_tables( 716 self, 717 schema: str, 718 *, 719 connection: Any = None, 720 table_prefix: str | None = None, 721 create_schema: bool = True, 722 ) -> List["FeatureGroupTable"]: 723 """Persist the feature matrix as triage-style feature-group tables. 724 725 Writes each column group as a persistent table 726 ``"<schema>"."<stem>_group_<NNN>"`` keyed on ``(as_of_date, <target id>)``, 727 the feature-group contract triage-pg consumes (issue #7). A config that 728 fits one query writes a single ``<stem>_group_000``; a wide or 729 oversized-child config writes one table per column group, all re-joinable 730 on the keys. The issue-#7 intermediate shards stay ephemeral ``TEMP`` 731 tables — only the final groups are persisted. 732 733 Idempotent: each target table is ``DROP TABLE IF EXISTS`` + ``CREATE TABLE 734 … AS`` so a re-run replaces it cleanly. 735 736 Alongside the group tables, the feature manifest is persisted as 737 ``"<schema>"."<stem>_manifest"`` — one row per output column (label, 738 lineage, generated description, and the ``feature_group`` it landed 739 in), joinable to the group tables by column name. The returned list 740 contains the feature-group tables only, as before. 741 742 Args: 743 schema: Destination schema (created if absent unless 744 ``create_schema=False``). 745 connection: An open psycopg connection to write on. When supplied the 746 caller owns the transaction (nothing is committed here — the 747 integration harness verifies within its rolled-back transaction); 748 when ``None`` a connection is built from the environment, committed 749 so the tables persist, and closed. 750 table_prefix: Table-name stem; defaults to the target alias 751 (``stores`` -> ``stores_group_000``). 752 create_schema: Run ``CREATE SCHEMA IF NOT EXISTS`` first. 753 754 Returns: 755 The ordered manifest of created :class:`FeatureGroupTable`s. 756 757 Raises: 758 ValueError: If the target entity does not define a primary id. 759 """ 760 if self.target.id is None: 761 raise ValueError( 762 f"Target entity '{self.target.alias}' does not define a primary id." 763 ) 764 from .arrow import default_connection 765 from .sharding import FeatureGroupTable 766 767 grouped, column_groups = self._grouped_for_tables() 768 preamble = ( 769 grouped.materialization.ddl if grouped.materialization is not None else [] 770 ) 771 stem = table_prefix or self.target.alias 772 keys = list(grouped.key_columns) 773 774 own_connection = connection is None 775 conn = connection if connection is not None else default_connection() 776 tables: List["FeatureGroupTable"] = [] 777 try: 778 # jit off from the preamble on (issue #53); a caller's value is put 779 # back before their transaction goes on. 780 with jit_disabled(conn): 781 with conn.cursor() as cur: 782 if create_schema: 783 cur.execute(f'create schema if not exists "{schema}"') 784 for ddl in preamble: 785 cur.execute(ddl) 786 analyze_as_of_dates(conn) # planner-stats optimization (see executor) 787 if own_connection: # PLANNER_TUNING never reaches a caller's 788 apply_planner_tuning(conn) 789 with conn.cursor() as cur: 790 for gid, sql in grouped.queries.items(): 791 name = f'"{schema}"."{stem}_{gid}"' 792 cur.execute(f"drop table if exists {name}") 793 cur.execute(f"create table {name} as\n{sql}") 794 tables.append( 795 FeatureGroupTable( 796 name=name, group=gid, key_columns=list(keys) 797 ) 798 ) 799 self._write_manifest_table(cur, schema, stem, column_groups) 800 if own_connection: 801 conn.commit() 802 finally: 803 if own_connection: 804 conn.close() 805 logger.info( 806 "Persisted {} feature-group table(s) + manifest to schema {!r} " 807 "(re-join on {}).", 808 len(tables), 809 schema, 810 tuple(keys), 811 ) 812 return tables
Persist the feature matrix as triage-style feature-group tables.
Writes each column group as a persistent table
"<schema>"."<stem>_group_<NNN>" keyed on (as_of_date, <target id>),
the feature-group contract triage-pg consumes (issue #7). A config that
fits one query writes a single <stem>_group_000; a wide or
oversized-child config writes one table per column group, all re-joinable
on the keys. The issue-#7 intermediate shards stay ephemeral TEMP
tables — only the final groups are persisted.
Idempotent: each target table is DROP TABLE IF EXISTS + CREATE TABLE
… AS so a re-run replaces it cleanly.
Alongside the group tables, the feature manifest is persisted as
"<schema>"."<stem>_manifest" — one row per output column (label,
lineage, generated description, and the feature_group it landed
in), joinable to the group tables by column name. The returned list
contains the feature-group tables only, as before.
Arguments:
- schema: Destination schema (created if absent unless
create_schema=False). - connection: An open psycopg connection to write on. When supplied the
caller owns the transaction (nothing is committed here — the
integration harness verifies within its rolled-back transaction);
when
Nonea connection is built from the environment, committed so the tables persist, and closed. - table_prefix: Table-name stem; defaults to the target alias
(
stores->stores_group_000). - create_schema: Run
CREATE SCHEMA IF NOT EXISTSfirst.
Returns:
The ordered manifest of created
FeatureGroupTables.
Raises:
- ValueError: If the target entity does not define a primary id.
12class FeaturizerViz: 13 """Visualization toolkit for featurizer output DataFrames. 14 15 The matrix produced by :meth:`Featurizer.to_dataframe` is indexed by 16 ``(as_of_date, <entity id column>)`` where the second level is the target 17 entity's actual id column name (e.g. ``customer_id``), not the literal 18 ``"entity_id"``. This class accepts either that MultiIndex form or a flat 19 frame; index levels matching ``as_of_col`` / ``entity_col`` are moved to 20 columns automatically. Prefer :meth:`from_featurizer`, which wires the 21 correct column names for you. 22 23 Args: 24 df: Feature matrix. May be indexed by (as_of_date, entity) or flat. 25 as_of_col: Name of the as-of date column / index level. 26 entity_col: Name of the entity id column / index level. 27 """ 28 29 def __init__( 30 self, 31 df: pd.DataFrame, 32 *, 33 as_of_col: str = "as_of_date", 34 entity_col: str = "entity_id", 35 ) -> None: 36 # Normalize: if as_of/entity live in the index (the to_dataframe shape), 37 # move them to columns so every method can treat them uniformly. Work on 38 # a copy — never mutate the caller's frame. 39 index_names = [n for n in (df.index.names or []) if n is not None] 40 if as_of_col in index_names or entity_col in index_names: 41 df = df.reset_index() 42 self.df = df 43 self.as_of_col = as_of_col 44 self.entity_col = entity_col 45 self._feature_cols: list[str] | None = None 46 47 @classmethod 48 def from_featurizer( 49 cls, 50 featurizer: Featurizer, 51 df: pd.DataFrame | None = None, 52 ) -> FeaturizerViz: 53 """Build a ``FeaturizerViz`` with column names taken from the Featurizer. 54 55 Resolves ``entity_col`` from the target entity's id column (so the 56 ``entity_id`` default never silently treats the id as a feature). When 57 ``df`` is None, executes ``featurizer.to_dataframe()``. 58 59 Raises: 60 ValueError: if the target entity defines no primary id. 61 """ 62 if featurizer.target.id is None: 63 raise ValueError( 64 f"Target entity '{featurizer.target.alias}' defines no primary " 65 f"id; cannot determine the entity column for visualization." 66 ) 67 entity_col = featurizer.target.id.name 68 if df is None: 69 df = featurizer.to_dataframe() 70 return cls(df, as_of_col="as_of_date", entity_col=entity_col) 71 72 @property 73 def feature_cols(self) -> list[str]: 74 if self._feature_cols is None: 75 self._feature_cols = [ 76 c for c in self.df.columns if c not in {self.as_of_col, self.entity_col} 77 ] 78 return self._feature_cols 79 80 # Methods are defined as module-level functions (first arg ``self``) and 81 # bound here as class attributes, grouped by source file. 82 from .correlation import plot_correlation_clustermap, plot_redundancy_graph 83 from .distributions import feature_summary_table, plot_feature_distributions 84 from .importance import plot_feature_importance, plot_feature_variance 85 from .missing import plot_missing_heatmap, plot_missing_over_time 86 from .similarity import plot_entity_dendrogram, plot_entity_embedding 87 from .temporal import plot_entity_feature_heatmap, plot_feature_timeseries
Visualization toolkit for featurizer output DataFrames.
The matrix produced by Featurizer.to_dataframe() is indexed by
(as_of_date, <entity id column>) where the second level is the target
entity's actual id column name (e.g. customer_id), not the literal
"entity_id". This class accepts either that MultiIndex form or a flat
frame; index levels matching as_of_col / entity_col are moved to
columns automatically. Prefer from_featurizer(), which wires the
correct column names for you.
Arguments:
- df: Feature matrix. May be indexed by (as_of_date, entity) or flat.
- as_of_col: Name of the as-of date column / index level.
- entity_col: Name of the entity id column / index level.
29 def __init__( 30 self, 31 df: pd.DataFrame, 32 *, 33 as_of_col: str = "as_of_date", 34 entity_col: str = "entity_id", 35 ) -> None: 36 # Normalize: if as_of/entity live in the index (the to_dataframe shape), 37 # move them to columns so every method can treat them uniformly. Work on 38 # a copy — never mutate the caller's frame. 39 index_names = [n for n in (df.index.names or []) if n is not None] 40 if as_of_col in index_names or entity_col in index_names: 41 df = df.reset_index() 42 self.df = df 43 self.as_of_col = as_of_col 44 self.entity_col = entity_col 45 self._feature_cols: list[str] | None = None
47 @classmethod 48 def from_featurizer( 49 cls, 50 featurizer: Featurizer, 51 df: pd.DataFrame | None = None, 52 ) -> FeaturizerViz: 53 """Build a ``FeaturizerViz`` with column names taken from the Featurizer. 54 55 Resolves ``entity_col`` from the target entity's id column (so the 56 ``entity_id`` default never silently treats the id as a feature). When 57 ``df`` is None, executes ``featurizer.to_dataframe()``. 58 59 Raises: 60 ValueError: if the target entity defines no primary id. 61 """ 62 if featurizer.target.id is None: 63 raise ValueError( 64 f"Target entity '{featurizer.target.alias}' defines no primary " 65 f"id; cannot determine the entity column for visualization." 66 ) 67 entity_col = featurizer.target.id.name 68 if df is None: 69 df = featurizer.to_dataframe() 70 return cls(df, as_of_col="as_of_date", entity_col=entity_col)
Build a FeaturizerViz with column names taken from the Featurizer.
Resolves entity_col from the target entity's id column (so the
entity_id default never silently treats the id as a feature). When
df is None, executes featurizer.to_dataframe().
Raises:
- ValueError: if the target entity defines no primary id.
12def plot_correlation_clustermap( 13 self, 14 method: str = "spearman", 15 threshold: float | None = 0.95, 16 figsize: tuple[int, int] = (14, 12), 17 cmap: str = "RdBu_r", 18) -> matplotlib.figure.Figure: 19 """Plot hierarchically-clustered correlation heatmap. 20 21 Args: 22 method: Correlation method ('spearman', 'pearson', 'kendall'). 23 threshold: If set, annotate pairs above this absolute correlation. 24 figsize: Figure size. 25 cmap: Colormap name. 26 27 Returns: 28 matplotlib Figure. 29 """ 30 _require("seaborn") 31 _require("matplotlib") 32 import seaborn as sns 33 34 matrix = _get_feature_matrix(self.df, self.feature_cols) 35 corr = matrix.corr(method=method) 36 # A constant or (near-)all-NULL feature has undefined correlation with 37 # everything; scipy's linkage rejects a distance matrix with non-finite 38 # values, so drop those features from the clustermap instead of crashing. 39 finite = corr.columns[corr.notna().sum() > 1] 40 dropped = len(corr.columns) - len(finite) 41 if dropped: 42 print( 43 f"correlation clustermap: dropped {dropped} constant/all-NULL " 44 "feature(s) with undefined correlations." 45 ) 46 corr = corr.loc[finite, finite].fillna(0.0) 47 48 g = sns.clustermap( 49 corr, 50 cmap=cmap, 51 vmin=-1, 52 vmax=1, 53 figsize=figsize, 54 linewidths=0.5, 55 dendrogram_ratio=(0.1, 0.1), 56 ) 57 g.fig.suptitle(f"Feature Correlation ({method.title()})", y=1.02, fontsize=14) 58 59 if threshold is not None: 60 import numpy as np 61 62 mask = np.triu(np.ones_like(corr, dtype=bool), k=1) 63 high_corr = corr.where(mask & (corr.abs() > threshold)).stack() 64 if not high_corr.empty: 65 print(f"\n{len(high_corr)} feature pairs above |{threshold}|:") 66 for (f1, f2), val in high_corr.items(): 67 print(f" {f1} <-> {f2}: {val:.3f}") 68 69 return g.fig
Plot hierarchically-clustered correlation heatmap.
Arguments:
- method: Correlation method ('spearman', 'pearson', 'kendall').
- threshold: If set, annotate pairs above this absolute correlation.
- figsize: Figure size.
- cmap: Colormap name.
Returns:
matplotlib Figure.
72def plot_redundancy_graph( 73 self, 74 threshold: float = 0.95, 75 method: str = "spearman", 76) -> object: 77 """Plot network graph of highly correlated features. 78 79 Args: 80 threshold: Absolute correlation threshold for edges. 81 method: Correlation method. 82 83 Returns: 84 plotly Figure (interactive). 85 """ 86 _require("plotly") 87 _require("networkx") 88 import networkx as nx 89 import numpy as np 90 import plotly.graph_objects as go 91 92 matrix = _get_feature_matrix(self.df, self.feature_cols) 93 corr = matrix.corr(method=method) 94 95 G = nx.Graph() 96 mask = np.triu(np.ones_like(corr, dtype=bool), k=1) 97 high_corr = corr.where(mask & (corr.abs() > threshold)).stack() 98 99 for (f1, f2), val in high_corr.items(): 100 G.add_edge(f1, f2, weight=abs(val), correlation=val) 101 102 if len(G.nodes) == 0: 103 print(f"No feature pairs above |{threshold}| — try lowering threshold.") 104 return None 105 106 pos = nx.spring_layout(G, seed=42) 107 108 edge_x: list[float | None] = [] 109 edge_y: list[float | None] = [] 110 for u, v in G.edges(): 111 x0, y0 = pos[u] 112 x1, y1 = pos[v] 113 edge_x.extend([x0, x1, None]) 114 edge_y.extend([y0, y1, None]) 115 116 edge_trace = go.Scatter( 117 x=edge_x, 118 y=edge_y, 119 line=dict(width=0.5, color="#888"), 120 hoverinfo="none", 121 mode="lines", 122 ) 123 124 node_x = [pos[n][0] for n in G.nodes()] 125 node_y = [pos[n][1] for n in G.nodes()] 126 node_text = [f"{n} (degree={G.degree(n)})" for n in G.nodes()] 127 node_color = [G.degree(n) for n in G.nodes()] 128 129 node_trace = go.Scatter( 130 x=node_x, 131 y=node_y, 132 mode="markers+text", 133 hoverinfo="text", 134 text=[n.split("(")[-1].rstrip(")") if "(" in n else n[:15] for n in G.nodes()], 135 textposition="top center", 136 textfont=dict(size=8), 137 hovertext=node_text, 138 marker=dict( 139 showscale=True, 140 colorscale="YlOrRd", 141 color=node_color, 142 size=10, 143 colorbar=dict(thickness=15, title="Degree"), 144 ), 145 ) 146 147 fig = go.Figure( 148 data=[edge_trace, node_trace], 149 layout=go.Layout( 150 title=f"Feature Redundancy Graph (|corr| > {threshold})", 151 showlegend=False, 152 xaxis=dict(showgrid=False, zeroline=False, showticklabels=False), 153 yaxis=dict(showgrid=False, zeroline=False, showticklabels=False), 154 ), 155 ) 156 return fig
Plot network graph of highly correlated features.
Arguments:
- threshold: Absolute correlation threshold for edges.
- method: Correlation method.
Returns:
plotly Figure (interactive).
65def feature_summary_table(self) -> pd.DataFrame: 66 """Generate summary statistics table for all features. 67 68 Returns: 69 DataFrame (one row per numeric feature) with columns 70 ``mean``, ``std``, ``skewness``, ``pct_missing``. 71 """ 72 _require("pandas") 73 import pandas as pd 74 75 matrix = _get_feature_matrix(self.df, self.feature_cols) 76 summary = pd.DataFrame( 77 { 78 "mean": matrix.mean(), 79 "std": matrix.std(), 80 "skewness": matrix.skew(), 81 "pct_missing": matrix.isnull().mean() * 100.0, 82 } 83 ) 84 summary.index.name = "feature" 85 return summary
Generate summary statistics table for all features.
Returns:
DataFrame (one row per numeric feature) with columns
mean,std,skewness,pct_missing.
13def plot_feature_distributions( 14 self, 15 features: list[str] | None = None, 16 kind: str = "violin", 17 figsize: tuple[int, int] = (14, 8), 18) -> matplotlib.figure.Figure: 19 """Plot feature distributions across entities. 20 21 Args: 22 features: Features to plot. If None, selects top 12 by variance. 23 kind: Plot type ('violin', 'box', 'hist'). 24 figsize: Figure size. 25 26 Returns: 27 matplotlib Figure. 28 """ 29 _require("matplotlib") 30 _require("seaborn") 31 import matplotlib.pyplot as plt 32 import seaborn as sns 33 34 matrix = _get_feature_matrix(self.df, self.feature_cols) 35 if features is None: 36 features = _top_by_variance(matrix, 12) 37 else: 38 features = [f for f in features if f in matrix.columns] 39 if not features: 40 fig, ax = plt.subplots(figsize=figsize) 41 ax.text(0.5, 0.5, "No numeric features to plot", ha="center", va="center") 42 return fig 43 44 subset = matrix[features] 45 fig, ax = plt.subplots(figsize=figsize) 46 47 if kind == "hist": 48 subset.plot.hist(ax=ax, bins=30, alpha=0.5) 49 ax.set_xlabel("Value") 50 ax.legend(bbox_to_anchor=(1.05, 1), loc="upper left", fontsize=7) 51 else: 52 melted = subset.melt(var_name="feature", value_name="value").dropna() 53 if kind == "box": 54 sns.boxplot(data=melted, x="feature", y="value", ax=ax) 55 else: # default: violin 56 sns.violinplot(data=melted, x="feature", y="value", ax=ax) 57 ax.set_xlabel("") 58 plt.setp(ax.get_xticklabels(), rotation=90, fontsize=7) 59 60 ax.set_title(f"Feature Distributions ({kind})") 61 plt.tight_layout() 62 return fig
Plot feature distributions across entities.
Arguments:
- features: Features to plot. If None, selects top 12 by variance.
- kind: Plot type ('violin', 'box', 'hist').
- figsize: Figure size.
Returns:
matplotlib Figure.
12def plot_feature_importance( 13 self, 14 target_col: str, 15 kind: str = "mutual_info", 16 top_n: int = 30, 17 figsize: tuple[int, int] = (12, 10), 18) -> matplotlib.figure.Figure: 19 """Plot ranked feature importance. 20 21 Args: 22 target_col: Target variable column name. 23 kind: Method ('mutual_info', 'f_classif', 'f_regression'). 24 top_n: Number of top features to show. 25 figsize: Figure size. 26 27 Returns: 28 matplotlib Figure. 29 """ 30 _require("matplotlib") 31 _require("sklearn") 32 import matplotlib.pyplot as plt 33 import pandas as pd 34 from sklearn.feature_selection import ( 35 f_classif, 36 f_regression, 37 mutual_info_classif, 38 mutual_info_regression, 39 ) 40 41 if target_col not in self.df.columns: 42 raise ValueError(f"target_col '{target_col}' not found in DataFrame columns.") 43 44 matrix = _get_feature_matrix(self.df, self.feature_cols) 45 X = matrix.drop(columns=[target_col], errors="ignore") 46 y_raw = self.df[target_col] 47 48 # Drop rows with a missing target; median-impute the features locally. 49 mask = y_raw.notna() 50 X = _impute_median(X[mask.to_numpy()]) 51 y_raw = y_raw[mask] 52 53 # Decide regression vs classification. 54 y_numeric = pd.api.types.is_numeric_dtype(y_raw) 55 if kind == "f_regression": 56 regression = True 57 elif kind == "f_classif": 58 regression = False 59 else: # mutual_info: infer from the target 60 regression = y_numeric and y_raw.nunique() > 20 61 62 y = y_raw if regression else pd.factorize(y_raw)[0] 63 64 scorers = { 65 ("mutual_info", True): mutual_info_regression, 66 ("mutual_info", False): mutual_info_classif, 67 ("f_regression", True): f_regression, 68 ("f_classif", False): f_classif, 69 } 70 key = (kind if kind in ("f_regression", "f_classif") else "mutual_info", regression) 71 scorer = scorers[key] 72 73 raw = scorer(X, y) 74 scores = raw[0] if kind.startswith("f_") else raw # f_* returns (F, pvalue) 75 ranked = pd.Series(scores, index=X.columns).sort_values(ascending=False).head(top_n) 76 77 fig, ax = plt.subplots(figsize=figsize) 78 ax.barh(ranked.index[::-1], ranked.to_numpy()[::-1], color="steelblue") 79 ax.set_title(f"Feature Importance ({kind}, target={target_col})") 80 ax.set_xlabel("Score") 81 plt.tight_layout() 82 return fig
Plot ranked feature importance.
Arguments:
- target_col: Target variable column name.
- kind: Method ('mutual_info', 'f_classif', 'f_regression').
- top_n: Number of top features to show.
- figsize: Figure size.
Returns:
matplotlib Figure.
85def plot_feature_variance( 86 self, 87 top_n: int = 50, 88 figsize: tuple[int, int] = (12, 10), 89) -> matplotlib.figure.Figure: 90 """Plot feature variance ranking (no target needed). 91 92 Args: 93 top_n: Number of top features to show. 94 figsize: Figure size. 95 96 Returns: 97 matplotlib Figure. 98 """ 99 _require("matplotlib") 100 import matplotlib.pyplot as plt 101 102 matrix = _get_feature_matrix(self.df, self.feature_cols) 103 ranked = matrix.var().sort_values(ascending=False).head(top_n) 104 105 fig, ax = plt.subplots(figsize=figsize) 106 ax.barh(ranked.index[::-1], ranked.to_numpy()[::-1], color="darkorange") 107 ax.set_title(f"Feature Variance (top {top_n})") 108 ax.set_xlabel("Variance") 109 plt.tight_layout() 110 return fig
Plot feature variance ranking (no target needed).
Arguments:
- top_n: Number of top features to show.
- figsize: Figure size.
Returns:
matplotlib Figure.
12def plot_missing_heatmap( 13 self, 14 as_of_date: str | None = None, 15 figsize: tuple[int, int] = (16, 10), 16 cmap: str = "YlOrRd", 17 max_entities: int = 50, 18) -> matplotlib.figure.Figure: 19 """Plot binary heatmap of missing values (entities x features). 20 21 Args: 22 as_of_date: Filter to a specific as_of_date. If None, uses latest. 23 figsize: Figure size. 24 cmap: Colormap for the heatmap. 25 max_entities: Maximum entities to show (samples if exceeded). 26 27 Returns: 28 matplotlib Figure. 29 """ 30 _require("seaborn") 31 _require("matplotlib") 32 import matplotlib.pyplot as plt 33 import seaborn as sns 34 35 df = self.df.copy() 36 if as_of_date is not None: 37 df = df[df[self.as_of_col] == as_of_date] 38 else: 39 latest = df[self.as_of_col].max() 40 df = df[df[self.as_of_col] == latest] 41 as_of_date = str(latest) 42 43 if self.entity_col in df.columns: 44 df = df.set_index(self.entity_col) 45 46 feature_df = df[self.feature_cols] 47 if len(feature_df) > max_entities: 48 feature_df = feature_df.sample(max_entities, random_state=42) 49 50 missing = feature_df.isnull().astype(int) 51 52 fig, ax = plt.subplots(figsize=figsize) 53 sns.heatmap( 54 missing, 55 cmap=cmap, 56 cbar_kws={"label": "Missing (1=yes)"}, 57 xticklabels=True, 58 yticklabels=True, 59 ax=ax, 60 ) 61 ax.set_title(f"Missing Data Pattern (as_of_date={as_of_date})") 62 ax.set_xlabel("Features") 63 ax.set_ylabel("Entities") 64 plt.xticks(rotation=90, fontsize=7) 65 plt.yticks(fontsize=7) 66 plt.tight_layout() 67 return fig
Plot binary heatmap of missing values (entities x features).
Arguments:
- as_of_date: Filter to a specific as_of_date. If None, uses latest.
- figsize: Figure size.
- cmap: Colormap for the heatmap.
- max_entities: Maximum entities to show (samples if exceeded).
Returns:
matplotlib Figure.
70def plot_missing_over_time( 71 self, 72 figsize: tuple[int, int] = (14, 8), 73 top_n: int = 20, 74) -> matplotlib.figure.Figure: 75 """Plot percentage of missing values per feature across as_of_dates. 76 77 Args: 78 figsize: Figure size. 79 top_n: Show only top N features with most missingness. 80 81 Returns: 82 matplotlib Figure. 83 """ 84 _require("matplotlib") 85 import matplotlib.pyplot as plt 86 87 grouped = self.df.groupby(self.as_of_col)[self.feature_cols].apply( 88 lambda x: x.isnull().mean() 89 ) 90 91 avg_missing = grouped.mean().sort_values(ascending=False) 92 top_features = avg_missing.head(top_n).index.tolist() 93 94 if not top_features: 95 fig, ax = plt.subplots() 96 ax.text(0.5, 0.5, "No missing data found", ha="center", va="center") 97 return fig 98 99 fig, ax = plt.subplots(figsize=figsize) 100 subset = grouped[top_features] 101 subset.plot(ax=ax, linewidth=1.5, alpha=0.8) 102 ax.set_title(f"Missing Data Over Time (top {top_n} features)") 103 ax.set_xlabel("As-of Date") 104 ax.set_ylabel("Fraction Missing") 105 ax.legend(bbox_to_anchor=(1.05, 1), loc="upper left", fontsize=7) 106 ax.set_ylim(0, 1) 107 plt.tight_layout() 108 return fig
Plot percentage of missing values per feature across as_of_dates.
Arguments:
- figsize: Figure size.
- top_n: Show only top N features with most missingness.
Returns:
matplotlib Figure.
101def plot_entity_dendrogram( 102 self, 103 as_of_date: str | None = None, 104 figsize: tuple[int, int] = (14, 8), 105) -> matplotlib.figure.Figure: 106 """Plot hierarchical clustering dendrogram of entities. 107 108 Args: 109 as_of_date: Filter to specific date. If None, uses latest. 110 figsize: Figure size. 111 112 Returns: 113 matplotlib Figure. 114 """ 115 _require("matplotlib") 116 _require("scipy") 117 import matplotlib.pyplot as plt 118 from scipy.cluster.hierarchy import dendrogram, linkage 119 120 matrix, sliced, resolved = _entity_matrix(self, as_of_date) 121 fig, ax = plt.subplots(figsize=figsize) 122 if len(matrix) < 2: 123 ax.text(0.5, 0.5, "Need >=2 entities to cluster", ha="center", va="center") 124 return fig 125 126 if self.entity_col in sliced.columns: 127 labels = sliced.loc[matrix.index, self.entity_col].astype(str).tolist() 128 else: 129 labels = [str(i) for i in matrix.index] 130 131 linkage_matrix = linkage(matrix.to_numpy(), method="ward") 132 dendrogram(linkage_matrix, labels=labels, ax=ax, leaf_rotation=90, leaf_font_size=7) 133 ax.set_title(f"Entity Dendrogram (as_of_date={resolved})") 134 ax.set_ylabel("Ward distance") 135 plt.tight_layout() 136 return fig
Plot hierarchical clustering dendrogram of entities.
Arguments:
- as_of_date: Filter to specific date. If None, uses latest.
- figsize: Figure size.
Returns:
matplotlib Figure.
25def plot_entity_embedding( 26 self, 27 as_of_date: str | None = None, 28 method: str = "umap", 29 color_by: str | None = None, 30 figsize: tuple[int, int] = (10, 8), 31) -> matplotlib.figure.Figure: 32 """Plot UMAP/t-SNE/PCA scatter of entity feature vectors. 33 34 Args: 35 as_of_date: Filter to specific date. If None, uses latest. 36 method: Dimensionality reduction method ('umap', 'tsne', 'pca'). 37 color_by: Column to color points by. 38 figsize: Figure size. 39 40 Returns: 41 matplotlib Figure. 42 """ 43 _require("matplotlib") 44 import matplotlib.pyplot as plt 45 46 matrix, sliced, resolved = _entity_matrix(self, as_of_date) 47 fig, ax = plt.subplots(figsize=figsize) 48 n = len(matrix) 49 if n < 3: 50 ax.text( 51 0.5, 0.5, f"Need >=3 entities to embed (got {n})", ha="center", va="center" 52 ) 53 return fig 54 55 if method == "umap": 56 _require("umap") 57 import umap 58 59 coords = umap.UMAP(n_components=2, random_state=42).fit_transform(matrix) 60 elif method == "tsne": 61 _require("sklearn") 62 from sklearn.manifold import TSNE 63 64 perplexity = min(30, max(2, n - 1)) 65 coords = TSNE( 66 n_components=2, random_state=42, perplexity=perplexity, init="pca" 67 ).fit_transform(matrix) 68 elif method == "pca": 69 _require("sklearn") 70 from sklearn.decomposition import PCA 71 72 coords = PCA(n_components=2, random_state=42).fit_transform(matrix) 73 else: 74 raise ValueError(f"Unknown method '{method}'; use 'umap', 'tsne', or 'pca'.") 75 76 if color_by is not None and color_by in sliced.columns: 77 import pandas as pd 78 79 values = sliced.loc[matrix.index, color_by] 80 if pd.api.types.is_numeric_dtype(values): 81 sc = ax.scatter(coords[:, 0], coords[:, 1], c=values, cmap="viridis", s=30) 82 fig.colorbar(sc, ax=ax, label=color_by) 83 else: 84 codes, labels = pd.factorize(values) 85 sc = ax.scatter(coords[:, 0], coords[:, 1], c=codes, cmap="tab10", s=30) 86 handles = [ 87 plt.Line2D([], [], marker="o", linestyle="", label=str(lab)) 88 for lab in labels 89 ] 90 ax.legend(handles=handles, title=color_by, fontsize=7) 91 else: 92 ax.scatter(coords[:, 0], coords[:, 1], s=30, color="steelblue") 93 94 ax.set_title(f"Entity Embedding ({method}, as_of_date={resolved})") 95 ax.set_xlabel("dim 1") 96 ax.set_ylabel("dim 2") 97 plt.tight_layout() 98 return fig
Plot UMAP/t-SNE/PCA scatter of entity feature vectors.
Arguments:
- as_of_date: Filter to specific date. If None, uses latest.
- method: Dimensionality reduction method ('umap', 'tsne', 'pca').
- color_by: Column to color points by.
- figsize: Figure size.
Returns:
matplotlib Figure.
67def plot_entity_feature_heatmap( 68 self, 69 entity_id: str | int, 70 figsize: tuple[int, int] = (16, 10), 71) -> matplotlib.figure.Figure: 72 """Plot features x time z-scored heatmap for a single entity. 73 74 Args: 75 entity_id: Entity to plot. 76 figsize: Figure size. 77 78 Returns: 79 matplotlib Figure. 80 """ 81 _require("matplotlib") 82 _require("seaborn") 83 import matplotlib.pyplot as plt 84 import seaborn as sns 85 86 sub = self.df[self.df[self.entity_col] == entity_id].sort_values(self.as_of_col) 87 fig, ax = plt.subplots(figsize=figsize) 88 if sub.empty: 89 ax.text(0.5, 0.5, f"No rows for entity {entity_id}", ha="center", va="center") 90 return fig 91 92 matrix = _get_feature_matrix(sub, self.feature_cols) 93 # z-score each feature across this entity's timepoints, then features-as-rows. 94 data = _zscore(matrix).T 95 data.columns = sub[self.as_of_col].astype(str).to_numpy() 96 97 sns.heatmap(data, cmap="RdBu_r", center=0, ax=ax, cbar_kws={"label": "z-score"}) 98 ax.set_title(f"Feature x Time (entity={entity_id})") 99 ax.set_xlabel(self.as_of_col) 100 ax.set_ylabel("Feature") 101 plt.setp(ax.get_xticklabels(), rotation=90, fontsize=7) 102 plt.setp(ax.get_yticklabels(), fontsize=6) 103 plt.tight_layout() 104 return fig
Plot features x time z-scored heatmap for a single entity.
Arguments:
- entity_id: Entity to plot.
- figsize: Figure size.
Returns:
matplotlib Figure.
12def plot_feature_timeseries( 13 self, 14 entity_id: str | int, 15 features: list[str] | None = None, 16 normalize: bool = False, 17 figsize: tuple[int, int] = (14, 8), 18) -> matplotlib.figure.Figure: 19 """Plot feature values over time for a single entity. 20 21 Args: 22 entity_id: Entity to plot. 23 features: Features to plot. If None, selects top 8 by variance. 24 normalize: Z-score normalize features for comparison. 25 figsize: Figure size. 26 27 Returns: 28 matplotlib Figure. 29 """ 30 _require("matplotlib") 31 import matplotlib.pyplot as plt 32 33 sub = self.df[self.df[self.entity_col] == entity_id].sort_values(self.as_of_col) 34 fig, ax = plt.subplots(figsize=figsize) 35 if sub.empty: 36 ax.text(0.5, 0.5, f"No rows for entity {entity_id}", ha="center", va="center") 37 return fig 38 39 matrix = _get_feature_matrix(sub, self.feature_cols) 40 if features is None: 41 features = _top_by_variance(matrix, 8) 42 else: 43 features = [f for f in features if f in matrix.columns] 44 if not features: 45 ax.text(0.5, 0.5, "No numeric features to plot", ha="center", va="center") 46 return fig 47 48 data = matrix[features] 49 if normalize: 50 data = _zscore(data) 51 52 x = sub[self.as_of_col].to_numpy() 53 for col in features: 54 ax.plot( 55 x, data[col].to_numpy(), marker="o", markersize=3, linewidth=1, label=col 56 ) 57 58 ax.set_title(f"Feature Time Series (entity={entity_id})") 59 ax.set_xlabel(self.as_of_col) 60 ax.set_ylabel("z-score" if normalize else "value") 61 ax.legend(bbox_to_anchor=(1.05, 1), loc="upper left", fontsize=7) 62 plt.setp(ax.get_xticklabels(), rotation=45, ha="right") 63 plt.tight_layout() 64 return fig
Plot feature values over time for a single entity.
Arguments:
- entity_id: Entity to plot.
- features: Features to plot. If None, selects top 8 by variance.
- normalize: Z-score normalize features for comparison.
- figsize: Figure size.
Returns:
matplotlib Figure.
103def impute_features( 104 df: pd.DataFrame, 105 *, 106 count_fill_zero: bool = True, 107 measure_strategy: str = "none", 108 add_missing_indicators: bool = True, 109 count_like_prefixes: Sequence[str] = DEFAULT_COUNT_LIKE_PREFIXES, 110 columns: Sequence[str] | None = None, 111) -> pd.DataFrame: 112 """Impute a feature matrix, preserving the missingness signal. 113 114 Args: 115 df: Feature matrix. The entity/as-of keys are expected to live in the 116 index (the ``Featurizer.to_dataframe`` shape); pass ``columns`` to 117 restrict which columns are treated as features otherwise. 118 count_fill_zero: Fill count-like features with 0 (structural zero). 119 measure_strategy: Fill for non-count numeric features — one of 120 ``"none"`` (leave NULL), ``"median"``, ``"mean"``. 121 add_missing_indicators: Emit a ``<feature>__missing`` 0/1 column for 122 each column that had NULLs, recorded before filling. 123 count_like_prefixes: Aggregation-name prefixes treated as count-like. 124 columns: Explicit feature columns to operate on (default: all columns). 125 126 Returns: 127 A new DataFrame (the input is never mutated). 128 """ 129 _validate_measure_strategy(measure_strategy) 130 131 result = df.copy() 132 target_cols = ( 133 list(result.columns) 134 if columns is None 135 else [c for c in columns if c in result.columns] 136 ) 137 138 # 1. Missing indicators — recorded BEFORE any fill, only where NULLs exist. 139 if add_missing_indicators: 140 indicators = { 141 f"{c}{MISSING_INDICATOR_SUFFIX}": result[c].isnull().astype(int) 142 for c in target_cols 143 if bool(result[c].isnull().to_numpy().any()) 144 } 145 for name, series in indicators.items(): 146 result[name] = series 147 148 # 2. Fill — count-like to 0; measures left NULL unless a strategy is given. 149 count_cols = [c for c in target_cols if _is_count_like(c, count_like_prefixes)] 150 measure_cols = [c for c in target_cols if c not in count_cols] 151 152 if count_fill_zero and count_cols: 153 result[count_cols] = result[count_cols].fillna(0) 154 155 if measure_strategy != "none" and measure_cols: 156 numeric_measures = result[measure_cols].select_dtypes(include="number").columns 157 if len(numeric_measures): 158 fill = ( 159 result[numeric_measures].median() 160 if measure_strategy == "median" 161 else result[numeric_measures].mean() 162 ) 163 result[numeric_measures] = result[numeric_measures].fillna(fill) 164 165 return result
Impute a feature matrix, preserving the missingness signal.
Arguments:
- df: Feature matrix. The entity/as-of keys are expected to live in the
index (the
Featurizer.to_dataframeshape); passcolumnsto restrict which columns are treated as features otherwise. - count_fill_zero: Fill count-like features with 0 (structural zero).
- measure_strategy: Fill for non-count numeric features — one of
"none"(leave NULL),"median","mean". - add_missing_indicators: Emit a
<feature>__missing0/1 column for each column that had NULLs, recorded before filling. - count_like_prefixes: Aggregation-name prefixes treated as count-like.
- columns: Explicit feature columns to operate on (default: all columns).
Returns:
A new DataFrame (the input is never mutated).
168def impute_arrow( 169 table: "pa.Table", 170 *, 171 key_columns: Sequence[str] = (), 172 count_fill_zero: bool = True, 173 measure_strategy: str = "none", 174 add_missing_indicators: bool = True, 175 count_like_prefixes: Sequence[str] = DEFAULT_COUNT_LIKE_PREFIXES, 176) -> "pa.Table": 177 """Arrow-native mirror of :func:`impute_features`. 178 179 Applies the same NULL-preserving contract to a :class:`pyarrow.Table` so the 180 Parquet/Arrow output path behaves identically to ``to_dataframe(impute=...)``: 181 count-like features are filled with the structural zero, measures stay null 182 unless an explicit ``measure_strategy`` is requested, and a stable 183 ``<feature>__missing`` 0/1 column is emitted (recorded *before* any fill) for 184 every feature column that had nulls. 185 186 Unlike the pandas path (where ``as_of_date`` and the target id live in the 187 index), an Arrow table carries the keys as ordinary columns; pass them in 188 ``key_columns`` so they are never treated as features. 189 190 The prefix logic and naming are shared with :func:`impute_features` — the 191 count-like classification (:func:`_is_count_like`) and the 192 :data:`MISSING_INDICATOR_SUFFIX` are not duplicated. 193 194 Args: 195 table: The feature matrix as a pyarrow.Table (e.g. from 196 :class:`featurizer.arrow.ArrowExporter`). 197 key_columns: Columns to leave untouched (``as_of_date``, target id). 198 count_fill_zero: Fill count-like features with 0 (structural zero). 199 measure_strategy: Fill for non-count numeric features — one of 200 ``"none"`` (leave null), ``"median"``, ``"mean"``. NOTE: ``mean`` / 201 ``median`` fit over the whole table; the engine path gates this 202 behind :func:`guard_full_matrix_fit` (ADR-0001 leakage rule). 203 add_missing_indicators: Emit a ``<feature>__missing`` 0/1 column for each 204 feature column that had nulls, recorded before filling. 205 count_like_prefixes: Aggregation-name prefixes treated as count-like. 206 207 Returns: 208 A new pyarrow.Table (the input is never mutated). 209 """ 210 import pyarrow as pa # local import: only needed on the Arrow path 211 import pyarrow.compute as pc 212 213 _validate_measure_strategy(measure_strategy) 214 215 keys = set(key_columns) 216 feature_cols = [n for n in table.column_names if n not in keys] 217 218 columns: dict[str, Any] = {n: table.column(n) for n in table.column_names} 219 appended: list[tuple[str, Any]] = [] 220 221 for name in feature_cols: 222 col = table.column(name) 223 had_nulls = col.null_count > 0 224 225 # 1. Missing indicator — recorded BEFORE any fill, only where nulls exist. 226 if add_missing_indicators and had_nulls: 227 # pyarrow.compute functions are generated at import time, so the type 228 # checker cannot see them statically (matches the [bridge] idiom). 229 is_null = pc.is_null(col) # pyright: ignore[reportAttributeAccessIssue] 230 indicator = pc.cast(is_null, pa.int64()) 231 appended.append((f"{name}{MISSING_INDICATOR_SUFFIX}", indicator)) 232 233 if not had_nulls: 234 continue 235 236 # 2. Fill — count-like to 0; measures left null unless a strategy is set. 237 if count_fill_zero and _is_count_like(name, count_like_prefixes): 238 columns[name] = _fill_arrow(col, pa.scalar(0), pa) 239 elif measure_strategy != "none" and pa.types.is_floating(col.type): 240 stat = ( 241 pc.mean(col) # pyright: ignore[reportAttributeAccessIssue] 242 if measure_strategy == "mean" 243 else _arrow_median(col, pc) 244 ) 245 if stat.is_valid: 246 columns[name] = _fill_arrow(col, stat, pa) 247 248 arrays = [columns[n] for n in table.column_names] + [a for _, a in appended] 249 names = list(table.column_names) + [n for n, _ in appended] 250 return pa.table(dict(zip(names, arrays)))
Arrow-native mirror of impute_features().
Applies the same NULL-preserving contract to a pyarrow.Table so the
Parquet/Arrow output path behaves identically to to_dataframe(impute=...):
count-like features are filled with the structural zero, measures stay null
unless an explicit measure_strategy is requested, and a stable
<feature>__missing 0/1 column is emitted (recorded before any fill) for
every feature column that had nulls.
Unlike the pandas path (where as_of_date and the target id live in the
index), an Arrow table carries the keys as ordinary columns; pass them in
key_columns so they are never treated as features.
The prefix logic and naming are shared with impute_features() — the
count-like classification (_is_count_like()) and the
MISSING_INDICATOR_SUFFIX are not duplicated.
Arguments:
- table: The feature matrix as a pyarrow.Table (e.g. from
featurizer.arrow.ArrowExporter). - key_columns: Columns to leave untouched (
as_of_date, target id). - count_fill_zero: Fill count-like features with 0 (structural zero).
- measure_strategy: Fill for non-count numeric features — one of
"none"(leave null),"median","mean". NOTE:mean/medianfit over the whole table; the engine path gates this behindguard_full_matrix_fit()(ADR-0001 leakage rule). - add_missing_indicators: Emit a
<feature>__missing0/1 column for each feature column that had nulls, recorded before filling. - count_like_prefixes: Aggregation-name prefixes treated as count-like.
Returns:
A new pyarrow.Table (the input is never mutated).
174class ColumnGroupSharder: 175 """Partitions a :class:`PlannerResult` into joinable column-group queries.""" 176 177 def __init__( 178 self, 179 plan: PlannerResult, 180 *, 181 max_columns_per_group: int = DEFAULT_MAX_COLUMNS_PER_GROUP, 182 materialize_threshold: int = PG_MAX_TARGET_LIST, 183 max_window_fns_per_group: int = DEFAULT_MAX_WINDOW_FNS_PER_GROUP, 184 ) -> None: 185 if max_columns_per_group < 1: 186 raise ValueError("max_columns_per_group must be a positive integer.") 187 if max_window_fns_per_group < 1: 188 raise ValueError("max_window_fns_per_group must be a positive integer.") 189 self.plan = plan 190 self.max_columns_per_group = max_columns_per_group 191 self.materialize_threshold = materialize_threshold 192 self.max_window_fns_per_group = max_window_fns_per_group 193 194 self.target_alias = plan.target.alias 195 self.transform_name = f"{self.target_alias}_transform" 196 self.synth_name = f"{self.target_alias}_synth" 197 198 transform_spec = plan.cte_specs.get(self.transform_name) 199 synth_spec = plan.cte_specs.get(self.synth_name) 200 if transform_spec is None or synth_spec is None: 201 raise ValueError( 202 "Planner did not record sharding metadata for the target's " 203 f"synth/transform CTEs ({self.synth_name!r} / " 204 f"{self.transform_name!r}). The plan must come from " 205 "FeaturePlanner.plan(); sharding cannot proceed." 206 ) 207 self.transform_spec: ShardableCTE = transform_spec 208 self.synth_spec: ShardableCTE = synth_spec 209 210 # Identifier columns the final query carries (as_of_date is added by the 211 # wrapper). The target id is the first identifier column. 212 self._id_columns = list(self.synth_spec.key_columns) 213 self._key_columns = ["as_of_date"] + [ 214 self._bare(name) for name in self._id_columns 215 ] 216 217 # Agg CTEs that feed the *target's* synth — the only aggs that are pruned 218 # per group (their columns are target synth columns). Deeper-chain agg 219 # CTEs (e.g. ``items_aggs_for_orders`` when ``orders`` is not the target) 220 # feed a *child* synth, carry no target synth columns, and so must be 221 # emitted whole. Identified by being the source CTE of a target synth 222 # column with ``kind == "aggs"``. 223 self._target_agg_ctes: Set[str] = { 224 cte_name 225 for cte_name, _ in plan.synth_column_source.values() 226 if plan.cte_specs.get(cte_name) is not None 227 and plan.cte_specs[cte_name].kind == "aggs" 228 } 229 230 # All registered CTE names (for the reachability name scan). 231 self._all_cte_names = list(plan.cte_order) 232 self._name_scanner = ( 233 _cte_name_scanner(self._all_cte_names) if self._all_cte_names else None 234 ) 235 # CTE bodies are immutable once planned, so the name-scan of each body 236 # is computed once and reused across groups (reachability runs per 237 # group for both rendering and the plan-size report). 238 self._body_refs_cache: Dict[str, Set[str]] = {} 239 self._agg_refs_cache: Dict[str, Tuple[Set[str], Dict[str, Set[str]]]] = {} 240 241 # Temp-table materialization for oversized non-target child CTEs (issue 242 # #7). Built lazily and cached: a ``MaterializationPlan`` when the plan 243 # has child CTEs over the threshold, else ``None`` (the common case). 244 self._materializer = MaterializationPlanner( 245 plan, materialize_threshold=materialize_threshold 246 ) 247 self._materialization_built = False 248 self._materialization_cache: "MaterializationPlan | None" = None 249 250 def materialization(self) -> "MaterializationPlan | None": 251 """The temp-table preamble plan, or ``None`` when no child CTE is over 252 the threshold. Cached after first call.""" 253 if not self._materialization_built: 254 self._materialization_cache = ( 255 self._materializer.build() 256 if self._materializer.oversized_ctes() 257 else None 258 ) 259 self._materialization_built = True 260 return self._materialization_cache 261 262 # ------------------------------------------------------------------ # 263 # Public API 264 # ------------------------------------------------------------------ # 265 266 @property 267 def key_columns(self) -> List[str]: 268 """The leading columns every group projects and re-joins on 269 (``["as_of_date", <target id>, …]``).""" 270 return list(self._key_columns) 271 272 @property 273 def fits_single_group(self) -> bool: 274 """True when the unsharded query is valid and needs no materialization. 275 276 This is the test ``Featurizer.query`` uses, judged against the *hard* 277 PostgreSQL limit (``PG_MAX_TARGET_LIST``), independent of the (smaller, 278 headroom-leaving) ``max_columns_per_group`` partition size: a single 279 valid query requires the target transform tuple **and** every 280 intermediate CTE tuple to be ≤ the limit. A 1500-column matrix is one 281 valid query even though it would shard into two groups for output. 282 283 A config with any child CTE over ``materialize_threshold`` does **not** 284 fit a single query — it needs the TEMP-table preamble (issue #7) — so it 285 is reported as not-single here too. With the default threshold (the hard 286 1664 limit) this matches the old behaviour exactly; a lower threshold 287 (advanced / testing) forces the materialized path. 288 """ 289 target_width = len(self.transform_spec.key_columns) + len( 290 self.transform_spec.columns 291 ) 292 return ( 293 target_width <= PG_MAX_TARGET_LIST 294 and not self._materializer.oversized_ctes() 295 ) 296 297 @property 298 def n_groups(self) -> int: 299 """Number of column groups the target transform partitions into.""" 300 return len(self._partition_columns()) 301 302 def group_queries(self) -> "OrderedDict[str, str]": 303 """Render every column group to a self-contained SQL string.""" 304 return self.build().queries 305 306 def column_groups(self) -> "OrderedDict[str, List[str]]": 307 """Map ``group_<NNN>`` -> the feature column names that group carries. 308 309 Mirrors :meth:`_partition_columns` exactly (same order, same chunking), 310 so the mapping matches what :meth:`build` renders. Key columns 311 (``as_of_date`` + the target id) are carried by *every* group and are 312 not listed. Used by ``Featurizer.to_tables`` to record, per manifest 313 row, which feature-group table the column landed in. 314 """ 315 mapping: "OrderedDict[str, List[str]]" = OrderedDict() 316 for idx, group_columns in enumerate(self._partition_columns()): 317 mapping[f"group_{idx:03d}"] = [c.name for c in group_columns] 318 return mapping 319 320 def plan_size_report(self) -> "OrderedDict[str, int]": 321 """Map ``group_<NNN>`` -> the CTE-closure size of that group's query. 322 323 The closure is exactly the set of CTEs :meth:`build` would emit for the 324 group (target synth/transform included, temp-materialized CTEs 325 excluded), computed without rendering any SQL. PostgreSQL's planner 326 memory and planning time grow with this number, so it is the pre-flight 327 predictor of planner blowup — see :meth:`warn_plan_size`. 328 """ 329 mplan = self.materialization() 330 materialized: AbstractSet[str] = ( 331 mplan.materialized_ctes if mplan is not None else frozenset() 332 ) 333 report: "OrderedDict[str, int]" = OrderedDict() 334 for idx, group_columns in enumerate(self._partition_columns()): 335 _, _, reachable = self._group_dependencies(group_columns, materialized) 336 report[f"group_{idx:03d}"] = len(reachable) 337 return report 338 339 def warn_plan_size(self) -> None: 340 """Log a loud, actionable warning when a group query's CTE closure 341 predicts a PostgreSQL planner blowup. 342 343 Evidence (2026-07-10, live donorschoose `wide`): group queries closing 344 over ~450–980 CTEs took 27–43s of *planning* each (5,300+ plan nodes) 345 and OOM-killed the backend during a plain ``EXPLAIN`` on a 12 GiB 346 server — with a 3,000-row cohort, so data volume was irrelevant. 347 Configs that completed (dirtyduck/chicago311 wide) stayed materially 348 smaller. The warning fires per run, not per group, and names the worst 349 offenders so the user can shrink the config (fewer transformers / 350 intervals / entities) before burning minutes on a doomed run. 351 """ 352 report = self.plan_size_report() 353 offenders = {gid: n for gid, n in report.items() if n > PLAN_SIZE_WARN_CLOSURE} 354 if not offenders: 355 return 356 worst = sorted(offenders.items(), key=lambda kv: -kv[1])[:5] 357 logger.warning( 358 "Plan-size risk: {} of {} column-group queries carry a CTE closure " 359 "over {} (worst: {}). PostgreSQL planning time/memory grows with " 360 "closure size — measured on live data, ~1000-CTE groups took 30-45s " 361 "of planning EACH and OOM-killed the backend regardless of row " 362 "count. Reduce the config's transformer/interval/entity breadth, or " 363 "expect very long runs and possible 'server closed the connection " 364 "unexpectedly' failures on memory-constrained servers.", 365 len(offenders), 366 len(report), 367 PLAN_SIZE_WARN_CLOSURE, 368 ", ".join(f"{gid}={n}" for gid, n in worst), 369 ) 370 371 def build(self) -> GroupedQueries: 372 """Partition + render. Returns the ordered group queries and join keys. 373 374 When the plan has oversized non-target child CTEs, the materialization 375 preamble is computed once and every group query is rendered to reference 376 the resulting temp-table shards (the materialized CTEs and their now-dead 377 upstreams are dropped from each group's ``with``).""" 378 mplan = self.materialization() 379 materialized: Set[str] = mplan.materialized_ctes if mplan is not None else set() 380 groups = self._partition_columns() 381 queries: "OrderedDict[str, str]" = OrderedDict() 382 for idx, group_columns in enumerate(groups): 383 gid = f"group_{idx:03d}" 384 queries[gid] = self._render_group(group_columns, materialized, mplan) 385 return GroupedQueries( 386 queries=queries, 387 key_columns=list(self._key_columns), 388 fits_single=self.fits_single_group, 389 materialization=mplan, 390 ) 391 392 # ------------------------------------------------------------------ # 393 # Partitioning 394 # ------------------------------------------------------------------ # 395 396 def _partition_columns(self) -> List[List[ColumnSpec]]: 397 """Split the target transform columns into ordered ≤-limit groups, 398 clustering columns that share a dependency lineage. 399 400 Columns are bucketed by their *source-CTE signature* — the set of CTEs 401 their ``depends_on`` synth columns come from — and buckets are packed 402 in sorted-signature order. Same-lineage columns therefore land in the 403 same group (up to the size limit), so each group's CTE closure stays 404 small and a companion pre-aggregation CTE is emitted/executed by the 405 few groups that need it instead of most of them. Measured on the 406 donorschoose ``wide`` config (27 groups): max closure 979 → 287 CTEs, 407 total closure 11,338 → 2,428, duplicated companion instances 899 → 18, 408 emitted SQL 29.2 MB → 17.4 MB — the difference between OOM-killing the 409 backend during planning and a plannable query set. 410 411 Deterministic: signatures sort lexicographically and columns keep 412 their emission order within a bucket, so the same plan always yields 413 the same partition (``column_groups`` / the manifest rely on this). 414 """ 415 columns = list(self.transform_spec.columns) 416 if not columns: 417 return [[]] 418 419 def signature(col: ColumnSpec) -> Tuple[str, ...]: 420 sources = { 421 source[0] 422 for dep in col.depends_on 423 if (source := self.plan.synth_column_source.get(dep)) is not None 424 } 425 return tuple(sorted(sources)) 426 427 buckets: "OrderedDict[Tuple[str, ...], List[ColumnSpec]]" = OrderedDict() 428 for col in columns: 429 buckets.setdefault(signature(col), []).append(col) 430 431 # Dual budget: the column limit keeps the tuple under PostgreSQL's 432 # target-list cap; the window-function limit keeps *planning* off the 433 # superlinear memory cliff (see DEFAULT_MAX_WINDOW_FNS_PER_GROUP — a 434 # ~1350-window select list OOM-killed the backend at a plain EXPLAIN). 435 per = self.max_columns_per_group 436 max_windows = self.max_window_fns_per_group 437 groups: List[List[ColumnSpec]] = [] 438 current: List[ColumnSpec] = [] 439 current_windows = 0 440 for sig in sorted(buckets.keys()): 441 for col in buckets[sig]: 442 n_windows = self._n_window_fns(col) 443 if current and ( 444 len(current) == per or current_windows + n_windows > max_windows 445 ): 446 groups.append(current) 447 current = [] 448 current_windows = 0 449 current.append(col) 450 current_windows += n_windows 451 if current: 452 groups.append(current) 453 return groups 454 455 @staticmethod 456 def _n_window_fns(col: ColumnSpec) -> int: 457 """Window-function count of one projection (``over (`` occurrences).""" 458 return len(_WINDOW_FN_RE.findall(col.projection)) 459 460 # ------------------------------------------------------------------ # 461 # Per-group rendering 462 # ------------------------------------------------------------------ # 463 464 def _group_dependencies( 465 self, 466 group_columns: List[ColumnSpec], 467 materialized: AbstractSet[str] = frozenset(), 468 ) -> Tuple[Set[str], List[str], Set[str]]: 469 """One group's pruned dependencies: (needed synth columns, kept joins, 470 reachable CTE closure). Shared by :meth:`_render_group` (which renders 471 the closure) and :meth:`plan_size_report` (which only counts it).""" 472 # 1. Synth columns this group needs = union of its columns' deps. 473 needed_synth: Set[str] = set() 474 for col in group_columns: 475 needed_synth.update(col.depends_on) 476 477 # A pathological fan-out (transformers referencing many synth columns) 478 # could push the pruned synth tuple over the limit even though the 479 # transform tuple is within budget. Fail fast with context rather than 480 # emit a query PostgreSQL will reject. 481 synth_width = len(self.synth_spec.key_columns) + len(needed_synth) 482 if synth_width > PG_MAX_TARGET_LIST: 483 raise ValueError( 484 f"A column group's pruned synth CTE would project {synth_width} " 485 f"columns, over PostgreSQL's {PG_MAX_TARGET_LIST}-entry limit: the " 486 f"group's {len(group_columns)} transform columns depend on " 487 f"{len(needed_synth)} distinct synth columns. Lower " 488 "max_columns_per_group so each group's synth fan-out stays under " 489 "the limit." 490 ) 491 492 # 2. Joins + upstream CTEs feeding those synth columns. Base-table 493 # variables have no source entry (they come from the target table). 494 kept_joins: List[str] = [] 495 seen_joins: Set[str] = set() 496 kept_upstream: Set[str] = set() 497 for col in sorted(needed_synth): 498 source = self.plan.synth_column_source.get(col) 499 if source is None: 500 continue # base-table variable, available from the target table 501 cte_name, join_sql = source 502 kept_upstream.add(cte_name) 503 if join_sql not in seen_joins: 504 seen_joins.add(join_sql) 505 kept_joins.append(join_sql) 506 507 # 3. Reachability: pull in every CTE transitively referenced by the 508 # target synth/transform, the kept upstream CTEs, or the kept joins. 509 # Materialized CTEs are temp tables, so they (and any upstream reachable 510 # only through them) are excluded — the rewrite reads their shards. 511 reachable = self._reachable_ctes( 512 kept_upstream, kept_joins, materialized, needed_synth 513 ) 514 return needed_synth, kept_joins, reachable 515 516 def _render_group( 517 self, 518 group_columns: List[ColumnSpec], 519 materialized: AbstractSet[str] = frozenset(), 520 mplan: "MaterializationPlan | None" = None, 521 ) -> str: 522 """Build the self-contained query for one column group. 523 524 ``materialized`` names the CTEs backed by temp tables (issue #7): they are 525 dropped from the ``with`` list and references to them are rewritten to 526 their shards. ``mplan`` carries the shard mapping for the rewrite.""" 527 needed_synth, kept_joins, reachable = self._group_dependencies( 528 group_columns, materialized 529 ) 530 531 # Render the CTE list in original emission order. 532 rendered_ctes = self._render_ctes( 533 reachable, group_columns, needed_synth, kept_joins, materialized, mplan 534 ) 535 536 return self._wrap(rendered_ctes, group_columns) 537 538 def _reachable_ctes( 539 self, 540 kept_upstream: Set[str], 541 kept_joins: List[str], 542 materialized: AbstractSet[str] = frozenset(), 543 needed_synth: AbstractSet[str] = frozenset(), 544 ) -> Set[str]: 545 """Transitive closure of CTE names a group's query references. 546 547 Seeds: target synth + transform (always present), the upstream CTEs 548 feeding kept synth columns, and any CTE named in a kept join's text 549 (e.g. an as-of lateral join reads ``<source>_transform``). The frontier 550 then expands by scanning each *non-target* reached CTE's own body for 551 further CTE names. 552 553 The target synth/transform are deliberately **not** body-scanned: they 554 are re-rendered per group with pruned joins, so their dependencies are 555 exactly ``kept_upstream`` + ``kept_joins`` — scanning their full-width 556 ``rendered`` text would spuriously pull in agg/peer CTEs this group 557 dropped. 558 559 A target-level agg CTE is likewise emitted *pruned* to the group's 560 ``needed_synth`` columns (:meth:`_render_agg`), so its refs are the 561 union of its surviving columns' refs, not the full-width body's — 562 otherwise a companion pre-aggregation CTE whose only consumer columns 563 landed in other groups would ride along as a dead (unreferenced) CTE 564 in every group that touches the relationship at all. 565 """ 566 target_ctes = {self.synth_name, self.transform_name} 567 reachable: Set[str] = set(target_ctes) 568 569 seeds: Set[str] = {n for n in kept_upstream if n in self.plan.cte_order} 570 for join_sql in kept_joins: 571 seeds.update(self._names_in(join_sql)) 572 seeds -= target_ctes 573 seeds -= materialized # temp tables, not emitted as CTEs 574 575 frontier = list(seeds) 576 reachable.update(seeds) 577 while frontier: 578 name = frontier.pop() 579 if name in self._target_agg_ctes: 580 refs = self._agg_refs(name, needed_synth) 581 else: 582 refs = self._body_refs(name) 583 for ref in refs: 584 if ( 585 ref not in reachable 586 and ref not in target_ctes 587 and ref not in materialized 588 ): 589 reachable.add(ref) 590 frontier.append(ref) 591 return reachable 592 593 def _agg_refs(self, name: str, needed_synth: AbstractSet[str]) -> Set[str]: 594 """CTE refs of a target agg CTE as :meth:`_render_agg` will emit it. 595 596 Per-column projection refs are scanned once and cached (the name-scan 597 regex is a >2000-alternation on wide plans; re-scanning a rendered 598 multi-hundred-column body per group measured ~30s on the donorschoose 599 wide config). A group's refs = the CTE's frame (prefix/suffix: the 600 ``from <child>_transform`` scan etc.) + its surviving columns' refs. 601 """ 602 cached = self._agg_refs_cache.get(name) 603 if cached is None: 604 spec = self.plan.cte_specs[name] 605 frame = self._names_in(spec.prefix + spec.suffix) 606 for key_col in spec.key_columns: 607 frame |= self._names_in(key_col) 608 per_column = {c.name: self._names_in(c.projection) for c in spec.columns} 609 cached = (frame, per_column) 610 self._agg_refs_cache[name] = cached 611 frame, per_column = cached 612 refs = set(frame) 613 for col_name, col_refs in per_column.items(): 614 if col_name in needed_synth: 615 refs |= col_refs 616 return refs 617 618 def _body_refs(self, name: str) -> Set[str]: 619 """CTE names referenced by ``name``'s body (memoized — bodies are 620 immutable once planned, and reachability runs once per group).""" 621 cached = self._body_refs_cache.get(name) 622 if cached is None: 623 body = self._cte_body(name) 624 cached = self._names_in(body) if body is not None else set() 625 self._body_refs_cache[name] = cached 626 return cached 627 628 def _render_ctes( 629 self, 630 reachable: Set[str], 631 group_columns: List[ColumnSpec], 632 needed_synth: Set[str], 633 kept_joins: List[str], 634 materialized: AbstractSet[str] = frozenset(), 635 mplan: "MaterializationPlan | None" = None, 636 ) -> List[str]: 637 """Render each reachable CTE in emission order, pruning the target's. 638 639 Materialized CTEs are never emitted (they are temp tables); each emitted 640 CTE's references to a materialized CTE are rewritten to read its shards. 641 """ 642 out: List[str] = [] 643 for name in self.plan.cte_order: 644 if name not in reachable or name in materialized: 645 continue 646 if name == self.transform_name: 647 text = self._render_transform(group_columns) 648 elif name == self.synth_name: 649 text = self._render_synth(needed_synth, kept_joins) 650 elif name in self._target_agg_ctes: 651 # A target-level agg CTE: prune to the surviving target synth 652 # columns it feeds. 653 text = self._render_agg(self.plan.cte_specs[name], needed_synth) 654 else: 655 # A bounded / non-target CTE (verbatim string, a deeper-chain 656 # agg, or a full-width child synth/transform): emit whole. 657 text = self._cte_body(name) or "" 658 if mplan is not None: 659 # e.g. a target-level agg's ``from <child>_transform`` becomes a 660 # re-join over that transform's materialized shards. 661 text = self._materializer.rewrite_group_body(text, mplan) 662 out.append(text) 663 return out 664 665 def _render_transform(self, group_columns: List[ColumnSpec]) -> str: 666 spec = self.transform_spec 667 projections = spec.sql_keys + [c.projection for c in group_columns] 668 return spec.prefix + ",\n ".join(projections) + spec.suffix 669 670 def _render_synth(self, needed_synth: Set[str], kept_joins: List[str]) -> str: 671 spec = self.synth_spec 672 surviving = [c.projection for c in spec.columns if c.name in needed_synth] 673 projections = spec.sql_keys + surviving 674 select_list = ",\n ".join(projections) 675 joins_sql = "" 676 if kept_joins: 677 joins_sql = "\n left join " + "\n left join ".join(kept_joins) 678 # The paired-cohort predicate (issue #10) follows whatever joins this 679 # group kept. Empty for the default dense cohort. 680 where_sql = f"\n {spec.where}" if spec.where else "" 681 return ( 682 spec.prefix 683 + select_list 684 + spec.suffix 685 + joins_sql 686 + where_sql 687 + "\n )\n " 688 ) 689 690 def _render_agg(self, spec: ShardableCTE, needed_synth: Set[str]) -> str: 691 surviving = [c.projection for c in spec.columns if c.name in needed_synth] 692 # The group only reaches this agg CTE because it keeps ≥1 of its 693 # columns, so ``surviving`` is non-empty here. 694 projections = spec.sql_keys + surviving 695 return spec.prefix + ",\n ".join(projections) + spec.suffix 696 697 def _wrap(self, rendered_ctes: List[str], group_columns: List[ColumnSpec]) -> str: 698 """Wrap the CTEs in the lateral-join shell, selecting the group output. 699 700 Identical shape to :class:`featurizer.sql.SQLRenderer`: ``t.*`` over the 701 pruned ``<target>_transform`` CTE, which is built to lead with the 702 identifier columns (the target id first) followed by this group's 703 feature columns — so every group's output is ``(as_of_date, <id>, 704 <group features…>)`` and the groups re-join on ``(as_of_date, id)``. 705 706 ``t.*`` (not an explicit ``t."col"`` list) is deliberate: generated 707 feature identifiers can exceed PostgreSQL's 63-byte limit and a verbatim 708 long name would not resolve against the (server-truncated) projected 709 column. The transform CTE already fixes the column order, so ``*`` is 710 both correct and limit-safe. 711 """ 712 ctes = ",".join(rendered_ctes) 713 # Same two switches as SQLRenderer (issue #10); bare defaults otherwise. 714 spine = as_of_dates_source(paired=self.plan.cohort_id_column is not None) 715 post_filter = ( 716 f" {self.plan.cohort_post_filter}" if self.plan.cohort_post_filter else "" 717 ) 718 return f""" 719 select aod.as_of_date, t.* 720 from {spine} as aod 721 cross join lateral ( 722 723 with 724 725 {ctes} 726 727 select * from {self.transform_name}{post_filter} 728 ) as t 729 730 order by aod.as_of_date 731 """ 732 733 # ------------------------------------------------------------------ # 734 # Limit diagnostics 735 # ------------------------------------------------------------------ # 736 737 def _oversized_intermediate_ctes(self) -> Dict[str, int]: 738 """CTEs that sharding cannot shrink yet still exceed the limit. 739 740 These are the *documented limitation*: a single child entity whose own 741 transform/synth tuple (or a deeper-chain agg CTE) already exceeds 1664 742 cannot be made to fit by sharding the *target's* output — pruning 743 operates per target column group, and such a CTE is reused whole across 744 groups. The target's own ``transform``/``synth`` and its per-child 745 ``aggs`` are pruned per group, so they are *not* counted here even when 746 their full width is over the limit. We surface the rest rather than 747 silently truncating. 748 """ 749 prunable = {self.transform_name, self.synth_name} | self._target_agg_ctes 750 oversized: Dict[str, int] = {} 751 for name, spec in self.plan.cte_specs.items(): 752 if name in prunable: 753 continue # re-rendered (pruned) per group, never over-limit there 754 width = len(spec.key_columns) + len(spec.columns) 755 if width > PG_MAX_TARGET_LIST: 756 oversized[name] = width 757 return oversized 758 759 def warn_oversized(self) -> None: 760 """Log a clear warning for any oversized intermediate CTE that **cannot** 761 be materialized into temp-table shards (issue #7). 762 763 An oversized child CTE with a recorded join key is handled by the 764 materialization path (``MaterializationPlanner``), so it no longer warns. 765 Only a CTE with no materialization key — an id-less entity's synth that 766 cannot be re-joined — remains an un-fixable bound worth surfacing. 767 """ 768 for name, width in self._oversized_intermediate_ctes().items(): 769 if name in self.plan.materialization_keys: 770 continue # handled by temp-table materialization 771 logger.warning( 772 "Sharding bound: intermediate CTE {!r} projects {} columns, over " 773 "PostgreSQL's {}-entry target-list limit, and cannot be " 774 "materialized (no join key — its entity has no id to re-join on). " 775 "Reduce the child entity's primitive/interval breadth, give it an " 776 "id, or raise the relationship that produces it to the target.", 777 name, 778 width, 779 PG_MAX_TARGET_LIST, 780 ) 781 782 # ------------------------------------------------------------------ # 783 # Small helpers 784 # ------------------------------------------------------------------ # 785 786 def _cte_body(self, name: str) -> str | None: 787 """The full-width rendered text for a CTE (verbatim or shardable). 788 789 Non-target CTEs are emitted byte-for-byte identical to the single-query 790 renderer (the planner stashed each one's full text), so a non-target 791 child synth keeps its own joins and a single-group config reproduces 792 ``Featurizer.query`` exactly. Only the *target's* synth/transform/aggs 793 are re-rendered (pruned) per group, handled by the callers. 794 """ 795 if name in self.plan.verbatim_ctes: 796 return self.plan.verbatim_ctes[name] 797 spec = self.plan.cte_specs.get(name) 798 if spec is None: 799 return None 800 return spec.rendered 801 802 def _names_in(self, text: str) -> Set[str]: 803 if self._name_scanner is None: 804 return set() 805 return set(self._name_scanner.findall(text)) 806 807 @staticmethod 808 def _bare(name: str) -> str: 809 """The unqualified column reference for ``t.<col>`` selection. 810 811 ``key_columns`` arrive table-qualified (``customers.customer_id``); the 812 outer select references them by their projected (bare) name. Quoted 813 feature identifiers are passed through unchanged. 814 """ 815 if name.startswith('"'): 816 return name 817 return name.rsplit(".", 1)[-1]
Partitions a PlannerResult into joinable column-group queries.
177 def __init__( 178 self, 179 plan: PlannerResult, 180 *, 181 max_columns_per_group: int = DEFAULT_MAX_COLUMNS_PER_GROUP, 182 materialize_threshold: int = PG_MAX_TARGET_LIST, 183 max_window_fns_per_group: int = DEFAULT_MAX_WINDOW_FNS_PER_GROUP, 184 ) -> None: 185 if max_columns_per_group < 1: 186 raise ValueError("max_columns_per_group must be a positive integer.") 187 if max_window_fns_per_group < 1: 188 raise ValueError("max_window_fns_per_group must be a positive integer.") 189 self.plan = plan 190 self.max_columns_per_group = max_columns_per_group 191 self.materialize_threshold = materialize_threshold 192 self.max_window_fns_per_group = max_window_fns_per_group 193 194 self.target_alias = plan.target.alias 195 self.transform_name = f"{self.target_alias}_transform" 196 self.synth_name = f"{self.target_alias}_synth" 197 198 transform_spec = plan.cte_specs.get(self.transform_name) 199 synth_spec = plan.cte_specs.get(self.synth_name) 200 if transform_spec is None or synth_spec is None: 201 raise ValueError( 202 "Planner did not record sharding metadata for the target's " 203 f"synth/transform CTEs ({self.synth_name!r} / " 204 f"{self.transform_name!r}). The plan must come from " 205 "FeaturePlanner.plan(); sharding cannot proceed." 206 ) 207 self.transform_spec: ShardableCTE = transform_spec 208 self.synth_spec: ShardableCTE = synth_spec 209 210 # Identifier columns the final query carries (as_of_date is added by the 211 # wrapper). The target id is the first identifier column. 212 self._id_columns = list(self.synth_spec.key_columns) 213 self._key_columns = ["as_of_date"] + [ 214 self._bare(name) for name in self._id_columns 215 ] 216 217 # Agg CTEs that feed the *target's* synth — the only aggs that are pruned 218 # per group (their columns are target synth columns). Deeper-chain agg 219 # CTEs (e.g. ``items_aggs_for_orders`` when ``orders`` is not the target) 220 # feed a *child* synth, carry no target synth columns, and so must be 221 # emitted whole. Identified by being the source CTE of a target synth 222 # column with ``kind == "aggs"``. 223 self._target_agg_ctes: Set[str] = { 224 cte_name 225 for cte_name, _ in plan.synth_column_source.values() 226 if plan.cte_specs.get(cte_name) is not None 227 and plan.cte_specs[cte_name].kind == "aggs" 228 } 229 230 # All registered CTE names (for the reachability name scan). 231 self._all_cte_names = list(plan.cte_order) 232 self._name_scanner = ( 233 _cte_name_scanner(self._all_cte_names) if self._all_cte_names else None 234 ) 235 # CTE bodies are immutable once planned, so the name-scan of each body 236 # is computed once and reused across groups (reachability runs per 237 # group for both rendering and the plan-size report). 238 self._body_refs_cache: Dict[str, Set[str]] = {} 239 self._agg_refs_cache: Dict[str, Tuple[Set[str], Dict[str, Set[str]]]] = {} 240 241 # Temp-table materialization for oversized non-target child CTEs (issue 242 # #7). Built lazily and cached: a ``MaterializationPlan`` when the plan 243 # has child CTEs over the threshold, else ``None`` (the common case). 244 self._materializer = MaterializationPlanner( 245 plan, materialize_threshold=materialize_threshold 246 ) 247 self._materialization_built = False 248 self._materialization_cache: "MaterializationPlan | None" = None
250 def materialization(self) -> "MaterializationPlan | None": 251 """The temp-table preamble plan, or ``None`` when no child CTE is over 252 the threshold. Cached after first call.""" 253 if not self._materialization_built: 254 self._materialization_cache = ( 255 self._materializer.build() 256 if self._materializer.oversized_ctes() 257 else None 258 ) 259 self._materialization_built = True 260 return self._materialization_cache
The temp-table preamble plan, or None when no child CTE is over
the threshold. Cached after first call.
266 @property 267 def key_columns(self) -> List[str]: 268 """The leading columns every group projects and re-joins on 269 (``["as_of_date", <target id>, …]``).""" 270 return list(self._key_columns)
The leading columns every group projects and re-joins on
(["as_of_date", <target id>, …]).
272 @property 273 def fits_single_group(self) -> bool: 274 """True when the unsharded query is valid and needs no materialization. 275 276 This is the test ``Featurizer.query`` uses, judged against the *hard* 277 PostgreSQL limit (``PG_MAX_TARGET_LIST``), independent of the (smaller, 278 headroom-leaving) ``max_columns_per_group`` partition size: a single 279 valid query requires the target transform tuple **and** every 280 intermediate CTE tuple to be ≤ the limit. A 1500-column matrix is one 281 valid query even though it would shard into two groups for output. 282 283 A config with any child CTE over ``materialize_threshold`` does **not** 284 fit a single query — it needs the TEMP-table preamble (issue #7) — so it 285 is reported as not-single here too. With the default threshold (the hard 286 1664 limit) this matches the old behaviour exactly; a lower threshold 287 (advanced / testing) forces the materialized path. 288 """ 289 target_width = len(self.transform_spec.key_columns) + len( 290 self.transform_spec.columns 291 ) 292 return ( 293 target_width <= PG_MAX_TARGET_LIST 294 and not self._materializer.oversized_ctes() 295 )
True when the unsharded query is valid and needs no materialization.
This is the test Featurizer.query uses, judged against the hard
PostgreSQL limit (PG_MAX_TARGET_LIST), independent of the (smaller,
headroom-leaving) max_columns_per_group partition size: a single
valid query requires the target transform tuple and every
intermediate CTE tuple to be ≤ the limit. A 1500-column matrix is one
valid query even though it would shard into two groups for output.
A config with any child CTE over materialize_threshold does not
fit a single query — it needs the TEMP-table preamble (issue #7) — so it
is reported as not-single here too. With the default threshold (the hard
1664 limit) this matches the old behaviour exactly; a lower threshold
(advanced / testing) forces the materialized path.
297 @property 298 def n_groups(self) -> int: 299 """Number of column groups the target transform partitions into.""" 300 return len(self._partition_columns())
Number of column groups the target transform partitions into.
302 def group_queries(self) -> "OrderedDict[str, str]": 303 """Render every column group to a self-contained SQL string.""" 304 return self.build().queries
Render every column group to a self-contained SQL string.
306 def column_groups(self) -> "OrderedDict[str, List[str]]": 307 """Map ``group_<NNN>`` -> the feature column names that group carries. 308 309 Mirrors :meth:`_partition_columns` exactly (same order, same chunking), 310 so the mapping matches what :meth:`build` renders. Key columns 311 (``as_of_date`` + the target id) are carried by *every* group and are 312 not listed. Used by ``Featurizer.to_tables`` to record, per manifest 313 row, which feature-group table the column landed in. 314 """ 315 mapping: "OrderedDict[str, List[str]]" = OrderedDict() 316 for idx, group_columns in enumerate(self._partition_columns()): 317 mapping[f"group_{idx:03d}"] = [c.name for c in group_columns] 318 return mapping
Map group_<NNN> -> the feature column names that group carries.
Mirrors _partition_columns() exactly (same order, same chunking),
so the mapping matches what build() renders. Key columns
(as_of_date + the target id) are carried by every group and are
not listed. Used by Featurizer.to_tables to record, per manifest
row, which feature-group table the column landed in.
320 def plan_size_report(self) -> "OrderedDict[str, int]": 321 """Map ``group_<NNN>`` -> the CTE-closure size of that group's query. 322 323 The closure is exactly the set of CTEs :meth:`build` would emit for the 324 group (target synth/transform included, temp-materialized CTEs 325 excluded), computed without rendering any SQL. PostgreSQL's planner 326 memory and planning time grow with this number, so it is the pre-flight 327 predictor of planner blowup — see :meth:`warn_plan_size`. 328 """ 329 mplan = self.materialization() 330 materialized: AbstractSet[str] = ( 331 mplan.materialized_ctes if mplan is not None else frozenset() 332 ) 333 report: "OrderedDict[str, int]" = OrderedDict() 334 for idx, group_columns in enumerate(self._partition_columns()): 335 _, _, reachable = self._group_dependencies(group_columns, materialized) 336 report[f"group_{idx:03d}"] = len(reachable) 337 return report
Map group_<NNN> -> the CTE-closure size of that group's query.
The closure is exactly the set of CTEs build() would emit for the
group (target synth/transform included, temp-materialized CTEs
excluded), computed without rendering any SQL. PostgreSQL's planner
memory and planning time grow with this number, so it is the pre-flight
predictor of planner blowup — see warn_plan_size().
339 def warn_plan_size(self) -> None: 340 """Log a loud, actionable warning when a group query's CTE closure 341 predicts a PostgreSQL planner blowup. 342 343 Evidence (2026-07-10, live donorschoose `wide`): group queries closing 344 over ~450–980 CTEs took 27–43s of *planning* each (5,300+ plan nodes) 345 and OOM-killed the backend during a plain ``EXPLAIN`` on a 12 GiB 346 server — with a 3,000-row cohort, so data volume was irrelevant. 347 Configs that completed (dirtyduck/chicago311 wide) stayed materially 348 smaller. The warning fires per run, not per group, and names the worst 349 offenders so the user can shrink the config (fewer transformers / 350 intervals / entities) before burning minutes on a doomed run. 351 """ 352 report = self.plan_size_report() 353 offenders = {gid: n for gid, n in report.items() if n > PLAN_SIZE_WARN_CLOSURE} 354 if not offenders: 355 return 356 worst = sorted(offenders.items(), key=lambda kv: -kv[1])[:5] 357 logger.warning( 358 "Plan-size risk: {} of {} column-group queries carry a CTE closure " 359 "over {} (worst: {}). PostgreSQL planning time/memory grows with " 360 "closure size — measured on live data, ~1000-CTE groups took 30-45s " 361 "of planning EACH and OOM-killed the backend regardless of row " 362 "count. Reduce the config's transformer/interval/entity breadth, or " 363 "expect very long runs and possible 'server closed the connection " 364 "unexpectedly' failures on memory-constrained servers.", 365 len(offenders), 366 len(report), 367 PLAN_SIZE_WARN_CLOSURE, 368 ", ".join(f"{gid}={n}" for gid, n in worst), 369 )
Log a loud, actionable warning when a group query's CTE closure predicts a PostgreSQL planner blowup.
Evidence (2026-07-10, live donorschoose wide): group queries closing
over ~450–980 CTEs took 27–43s of planning each (5,300+ plan nodes)
and OOM-killed the backend during a plain EXPLAIN on a 12 GiB
server — with a 3,000-row cohort, so data volume was irrelevant.
Configs that completed (dirtyduck/chicago311 wide) stayed materially
smaller. The warning fires per run, not per group, and names the worst
offenders so the user can shrink the config (fewer transformers /
intervals / entities) before burning minutes on a doomed run.
371 def build(self) -> GroupedQueries: 372 """Partition + render. Returns the ordered group queries and join keys. 373 374 When the plan has oversized non-target child CTEs, the materialization 375 preamble is computed once and every group query is rendered to reference 376 the resulting temp-table shards (the materialized CTEs and their now-dead 377 upstreams are dropped from each group's ``with``).""" 378 mplan = self.materialization() 379 materialized: Set[str] = mplan.materialized_ctes if mplan is not None else set() 380 groups = self._partition_columns() 381 queries: "OrderedDict[str, str]" = OrderedDict() 382 for idx, group_columns in enumerate(groups): 383 gid = f"group_{idx:03d}" 384 queries[gid] = self._render_group(group_columns, materialized, mplan) 385 return GroupedQueries( 386 queries=queries, 387 key_columns=list(self._key_columns), 388 fits_single=self.fits_single_group, 389 materialization=mplan, 390 )
Partition + render. Returns the ordered group queries and join keys.
When the plan has oversized non-target child CTEs, the materialization
preamble is computed once and every group query is rendered to reference
the resulting temp-table shards (the materialized CTEs and their now-dead
upstreams are dropped from each group's with).
759 def warn_oversized(self) -> None: 760 """Log a clear warning for any oversized intermediate CTE that **cannot** 761 be materialized into temp-table shards (issue #7). 762 763 An oversized child CTE with a recorded join key is handled by the 764 materialization path (``MaterializationPlanner``), so it no longer warns. 765 Only a CTE with no materialization key — an id-less entity's synth that 766 cannot be re-joined — remains an un-fixable bound worth surfacing. 767 """ 768 for name, width in self._oversized_intermediate_ctes().items(): 769 if name in self.plan.materialization_keys: 770 continue # handled by temp-table materialization 771 logger.warning( 772 "Sharding bound: intermediate CTE {!r} projects {} columns, over " 773 "PostgreSQL's {}-entry target-list limit, and cannot be " 774 "materialized (no join key — its entity has no id to re-join on). " 775 "Reduce the child entity's primitive/interval breadth, give it an " 776 "id, or raise the relationship that produces it to the target.", 777 name, 778 width, 779 PG_MAX_TARGET_LIST, 780 )
Log a clear warning for any oversized intermediate CTE that cannot be materialized into temp-table shards (issue #7).
An oversized child CTE with a recorded join key is handled by the
materialization path (MaterializationPlanner), so it no longer warns.
Only a CTE with no materialization key — an id-less entity's synth that
cannot be re-joined — remains an un-fixable bound worth surfacing.
126@dataclass(frozen=True) 127class GroupedQueries: 128 """Result of sharding: the ordered group queries plus the join keys. 129 130 ``queries`` maps ``group_<NNN>`` -> the self-contained SQL for that group. 131 ``key_columns`` are the leading columns every group projects and on which 132 all groups re-join (``["as_of_date", <target id>]``). ``fits_single`` is 133 True when the whole matrix fits in one group (the single-query fast path). 134 ``materialization`` carries the TEMP-table preamble (issue #7) when an 135 oversized non-target child CTE had to be materialized; the group ``queries`` 136 then reference those temp tables and presuppose ``materialization.ddl`` ran 137 first on the same connection. ``None`` for the common (no-preamble) case. 138 """ 139 140 queries: "OrderedDict[str, str]" 141 key_columns: List[str] 142 fits_single: bool 143 materialization: "MaterializationPlan | None" = None
Result of sharding: the ordered group queries plus the join keys.
queries maps group_<NNN> -> the self-contained SQL for that group.
key_columns are the leading columns every group projects and on which
all groups re-join (["as_of_date", <target id>]). fits_single is
True when the whole matrix fits in one group (the single-query fast path).
materialization carries the TEMP-table preamble (issue #7) when an
oversized non-target child CTE had to be materialized; the group queries
then reference those temp tables and presuppose materialization.ddl ran
first on the same connection. None for the common (no-preamble) case.
1503def validate_config(config_path: str, mode: str = "strict") -> ValidationResult: 1504 """Validate a configuration file. 1505 1506 Args: 1507 config_path: Path to YAML configuration file 1508 mode: Validation mode - "strict" or "permissive" 1509 1510 Returns: 1511 ValidationResult with any errors and warnings 1512 1513 Raises: 1514 FileNotFoundError: If config file doesn't exist 1515 yaml.YAMLError: If YAML is malformed 1516 """ 1517 with open(config_path) as f: 1518 config = yaml.safe_load(f) or {} 1519 1520 validator = ConfigValidator(mode=mode) 1521 return validator.validate(config)
Validate a configuration file.
Arguments:
- config_path: Path to YAML configuration file
- mode: Validation mode - "strict" or "permissive"
Returns:
ValidationResult with any errors and warnings
Raises:
- FileNotFoundError: If config file doesn't exist
- yaml.YAMLError: If YAML is malformed
53@dataclass 54class ValidationResult: 55 """Result of configuration validation.""" 56 57 errors: List[ValidationError] = field(default_factory=list) 58 warnings: List[ValidationWarning] = field(default_factory=list) 59 60 @property 61 def is_valid(self) -> bool: 62 """Check if configuration is valid (no errors).""" 63 return len(self.errors) == 0 64 65 def format_errors(self) -> str: 66 """Format all errors for display.""" 67 if not self.errors: 68 return "Configuration is valid." 69 70 lines = [f"Configuration has {len(self.errors)} error(s):\n"] 71 72 for i, error in enumerate(self.errors, 1): 73 lines.append(f" {i}. {error.format()}") 74 75 if self.warnings: 76 lines.append(f"\n{len(self.warnings)} warning(s):") 77 for warning in self.warnings: 78 location = f"[{warning.location}] " if warning.location else "" 79 lines.append(f" ⚠ {location}{warning.message}") 80 81 return "\n".join(lines)
Result of configuration validation.
60 @property 61 def is_valid(self) -> bool: 62 """Check if configuration is valid (no errors).""" 63 return len(self.errors) == 0
Check if configuration is valid (no errors).
65 def format_errors(self) -> str: 66 """Format all errors for display.""" 67 if not self.errors: 68 return "Configuration is valid." 69 70 lines = [f"Configuration has {len(self.errors)} error(s):\n"] 71 72 for i, error in enumerate(self.errors, 1): 73 lines.append(f" {i}. {error.format()}") 74 75 if self.warnings: 76 lines.append(f"\n{len(self.warnings)} warning(s):") 77 for warning in self.warnings: 78 location = f"[{warning.location}] " if warning.location else "" 79 lines.append(f" ⚠ {location}{warning.message}") 80 81 return "\n".join(lines)
Format all errors for display.
21@dataclass 22class ValidationError: 23 """A single validation error with context.""" 24 25 message: str 26 location: Optional[str] = None 27 suggestion: Optional[str] = None 28 line_number: Optional[int] = None 29 30 def format(self) -> str: 31 """Format error for display.""" 32 parts = [] 33 34 if self.location: 35 parts.append(f"[{self.location}]") 36 37 parts.append(self.message) 38 39 if self.suggestion: 40 parts.append(f"\n → {self.suggestion}") 41 42 return " ".join(parts)
A single validation error with context.
30 def format(self) -> str: 31 """Format error for display.""" 32 parts = [] 33 34 if self.location: 35 parts.append(f"[{self.location}]") 36 37 parts.append(self.message) 38 39 if self.suggestion: 40 parts.append(f"\n → {self.suggestion}") 41 42 return " ".join(parts)
Format error for display.
45@dataclass 46class ValidationWarning: 47 """A validation warning (non-fatal).""" 48 49 message: str 50 location: Optional[str] = None
A validation warning (non-fatal).