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