Edit on GitHub

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]
class Featurizer:
  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.

Featurizer( config_file: str, *, debug: bool = False, validate: bool = True, materialize_threshold: int | None = None, connection: Any = None)
 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: categorical direct variable's vocabulary from its PostgreSQL ENUM labels when no vocabulary is declared in the config. When omitted, one is opened from DATABASE_URL / PG* if any categorical actually needs it; a declared vocabulary needs no database (query / --show-sql stay 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: categorical variable has neither a declared vocabulary nor an introspectable PostgreSQL ENUM.
max_depth: int
intervals: List[str]
as_of_boundary: Literal['inclusive', 'exclusive']
aggregations: Dict[str, Callable[..., Any]]
transformations: Dict[str, Callable[..., Any]]
features: Dict[str, Set[featurizer.primitives.abstractions.Feature]]
ctes: List[str]
joins: Dict[str, List[str]]
entities: Iterable[featurizer.primitives.abstractions.Entity]
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.

relationships: List[Any]
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.

feature_manifest: List[featurizer.manifest.ManifestEntry]
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.

def manifest_dataframe(self) -> pandas.core.frame.DataFrame:
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).

def manifest_matching( self, pattern: str, *, allow_empty: bool = False) -> List[featurizer.manifest.ManifestEntry]:
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 SQL LIKE — 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_empty is 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'
def columns_matching(self, pattern: str, *, allow_empty: bool = False) -> List[str]:
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_empty is False.
query: str
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>_transform CTE (or an intermediate CTE) would exceed PostgreSQL's 1664-entry target-list limit. Use query_groups / to_parquet() / to_arrow() (column-group sharding, issue #7) instead. The matrix is never silently truncated.
query_groups: collections.OrderedDict[str, str]
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.

materialization_ddl: List[str]
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.

def to_dataframe( self, *, connection: Any = None, impute: bool = False, **impute_kwargs: Any) -> pandas.core.frame.DataFrame:
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 TEMP tables — the integration harness). When None, the fast single-query path uses records and 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_strategy is given, with <feature>__missing indicator 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
def to_arrow( self, *, connection: Any = None, numeric_as_float: bool = True, impute: bool = False, **impute_kwargs: Any) -> Any:
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 COPY on. Required when the rendered query references session TEMP tables (the integration harness). When None, a connection is built from DATABASE_URL / PG* and closed afterwards. A single connection is reused across all groups.
  • numeric_as_float: Cast PostgreSQL numeric aggregate columns to float64 (ML-ready, to_dataframe-comparable). Set False to keep exact decimal128. 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 unless measure_strategy is given, with stable <feature>__missing indicator columns. as_of_date and the target id are passed as key_columns and left untouched.
  • **impute_kwargs: Forwarded to impute_arrow. measure_strategy in {"mean","median"} additionally requires allow_full_matrix_fit=True (ADR-0001 leakage gate).
Returns:

A pyarrow.Table for a single-group config, otherwise an OrderedDict[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.
def to_parquet( self, path: str, *, connection: Any = None, numeric_as_float: bool = True, impute: bool = False, **impute_kwargs: Any) -> None:
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 .parquet file (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.
def to_tables( self, schema: str, *, connection: Any = None, table_prefix: str | None = None, create_schema: bool = True) -> List[featurizer.sharding.FeatureGroupTable]:
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 None a 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 EXISTS first.
Returns:

The ordered manifest of created FeatureGroupTables.

Raises:
  • ValueError: If the target entity does not define a primary id.
class FeaturizerViz:
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.
FeaturizerViz( df: pandas.core.frame.DataFrame, *, as_of_col: str = 'as_of_date', entity_col: str = 'entity_id')
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
df
as_of_col
entity_col
@classmethod
def from_featurizer( cls, featurizer: Featurizer, df: pandas.core.frame.DataFrame | None = None) -> FeaturizerViz:
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.
feature_cols: list[str]
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
def plot_correlation_clustermap( self, method: str = 'spearman', threshold: float | None = 0.95, figsize: tuple[int, int] = (14, 12), cmap: str = 'RdBu_r') -> 'matplotlib.figure.Figure':
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.

def plot_redundancy_graph(self, threshold: float = 0.95, method: str = 'spearman') -> object:
 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).

def feature_summary_table(self) -> 'pd.DataFrame':
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.

def plot_feature_distributions( self, features: list[str] | None = None, kind: str = 'violin', figsize: tuple[int, int] = (14, 8)) -> 'matplotlib.figure.Figure':
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.

def plot_feature_importance( self, target_col: str, kind: str = 'mutual_info', top_n: int = 30, figsize: tuple[int, int] = (12, 10)) -> 'matplotlib.figure.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.

def plot_feature_variance( self, top_n: int = 50, figsize: tuple[int, int] = (12, 10)) -> 'matplotlib.figure.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.

def plot_missing_heatmap( self, as_of_date: str | None = None, figsize: tuple[int, int] = (16, 10), cmap: str = 'YlOrRd', max_entities: int = 50) -> 'matplotlib.figure.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.

def plot_missing_over_time( self, figsize: tuple[int, int] = (14, 8), top_n: int = 20) -> 'matplotlib.figure.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.

def plot_entity_dendrogram( self, as_of_date: str | None = None, figsize: tuple[int, int] = (14, 8)) -> 'matplotlib.figure.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.

def plot_entity_embedding( self, as_of_date: str | None = None, method: str = 'umap', color_by: str | None = None, figsize: tuple[int, int] = (10, 8)) -> 'matplotlib.figure.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.

def plot_entity_feature_heatmap( self, entity_id: str | int, figsize: tuple[int, int] = (16, 10)) -> 'matplotlib.figure.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.

def plot_feature_timeseries( self, entity_id: str | int, features: list[str] | None = None, normalize: bool = False, figsize: tuple[int, int] = (14, 8)) -> 'matplotlib.figure.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.

def impute_features( df: pandas.core.frame.DataFrame, *, count_fill_zero: bool = True, measure_strategy: str = 'none', add_missing_indicators: bool = True, count_like_prefixes: Sequence[str] = ('COUNT', 'SUM', 'NUNIQUE', 'N_', 'EVENT_RATE'), columns: Sequence[str] | None = None) -> pandas.core.frame.DataFrame:
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_dataframe shape); pass columns to 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>__missing 0/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).

def impute_arrow( table: pyarrow.lib.Table, *, key_columns: Sequence[str] = (), count_fill_zero: bool = True, measure_strategy: str = 'none', add_missing_indicators: bool = True, count_like_prefixes: Sequence[str] = ('COUNT', 'SUM', 'NUNIQUE', 'N_', 'EVENT_RATE')) -> pyarrow.lib.Table:
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 / median fit over the whole table; the engine path gates this behind guard_full_matrix_fit() (ADR-0001 leakage rule).
  • add_missing_indicators: Emit a <feature>__missing 0/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).

MISSING_INDICATOR_SUFFIX = '__missing'
class ColumnGroupSharder:
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.

ColumnGroupSharder( plan: featurizer.planner.PlannerResult, *, max_columns_per_group: int = 1400, materialize_threshold: int = 1664, max_window_fns_per_group: int = 500)
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
plan
max_columns_per_group
materialize_threshold
max_window_fns_per_group
target_alias
transform_name
synth_name
def materialization(self) -> featurizer.sharding.MaterializationPlan | 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.

key_columns: List[str]
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>, …]).

fits_single_group: bool
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.

n_groups: int
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.

def group_queries(self) -> collections.OrderedDict[str, str]:
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.

def column_groups(self) -> collections.OrderedDict[str, typing.List[str]]:
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.

def plan_size_report(self) -> collections.OrderedDict[str, int]:
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().

def warn_plan_size(self) -> None:
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.

def build(self) -> GroupedQueries:
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).

def warn_oversized(self) -> None:
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.

@dataclass(frozen=True)
class GroupedQueries:
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.

GroupedQueries( queries: collections.OrderedDict[str, str], key_columns: List[str], fits_single: bool, materialization: featurizer.sharding.MaterializationPlan | None = None)
queries: collections.OrderedDict[str, str]
key_columns: List[str]
fits_single: bool
materialization: featurizer.sharding.MaterializationPlan | None = None
DEFAULT_MAX_COLUMNS_PER_GROUP = 1400
def validate_config( config_path: str, mode: str = 'strict') -> ValidationResult:
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
@dataclass
class ValidationResult:
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.

ValidationResult( errors: List[ValidationError] = <factory>, warnings: List[ValidationWarning] = <factory>)
errors: List[ValidationError]
warnings: List[ValidationWarning]
is_valid: bool
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).

def format_errors(self) -> str:
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.

@dataclass
class ValidationError:
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.

ValidationError( message: str, location: Optional[str] = None, suggestion: Optional[str] = None, line_number: Optional[int] = None)
message: str
location: Optional[str] = None
suggestion: Optional[str] = None
line_number: Optional[int] = None
def format(self) -> str:
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.

@dataclass
class ValidationWarning:
45@dataclass
46class ValidationWarning:
47    """A validation warning (non-fatal)."""
48
49    message: str
50    location: Optional[str] = None

A validation warning (non-fatal).

ValidationWarning(message: str, location: Optional[str] = None)
message: str
location: Optional[str] = None