Source code for pudl.analysis.ml_tools.models

"""Provides tooling for developing/tracking ML models within PUDL.

The ML pipelines here use Dagster's ``@op`` and ``@graph`` primitives rather than
``@asset``. Each pipeline (e.g. ``ferc_to_ferc``, ``ferc_to_eia``) is a multi-step
computation — embedding, clustering, matching — where the intermediate outputs (distance
matrices, cluster assignments, etc.) are not meaningful PUDL data products. They are
implementation details of the model.  Converting each ``@op`` to an ``@asset`` would
pollute the asset catalog with tables that have no meaning outside the model.

``graph_asset`` is the Dagster idiom for exactly this use case: a complex computation
with internal steps that nevertheless produces a single named asset visible in the
catalog. Do not refactor these to chains of ``@asset``.

The ``@pudl_model`` decorator
------------------------------
:func:`pudl_model` is a decorator factory that wraps a Dagster ``@graph`` and
converts it into a ``graph_asset``. Applying it to a ``@graph`` function does
three things:

1. **Collects configuration.** It walks the graph's op tree, harvesting default
   config values from each op's :class:`~dagster.Config` subclass. If
   ``config_from_yaml=True``, it also merges overrides from
   ``pudl.package_data.settings.pudl_models.yml``. The merged config is stored
   in the module-level ``MODEL_CONFIGURATION`` dict, which
   :func:`~pudl.dagster.config.get_ml_models_config` later folds into the
   default job config so Dagster knows the defaults at launch time.

2. **Injects an ExperimentTracker.** An :class:`~pudl.analysis.ml_tools.experiment_tracking.ExperimentTracker`
   op is synthesized and called first inside the ``graph_asset``, then passed as
   the first argument to the wrapped graph. Ops that want to log metrics receive
   it as an input parameter named ``experiment_tracker``. The tracker input is
   excluded from the asset's ``ins`` mapping so Dagster does not treat it as a
   dependency on an upstream asset.

3. **Returns a graph_asset.** The decorated function is replaced by a
   ``graph_asset`` whose name is ``asset_name`` and whose upstream asset
   dependencies are inferred from the graph's remaining inputs.

Configuration precedence (lowest → highest):

* Default values on each op's ``Config`` subclass (code)
* Entries in ``pudl_models.yml`` (repo-level YAML, only when ``config_from_yaml=True``)
* Values entered in the Dagster UI Launchpad (single-run override)
"""

import importlib

import yaml
from dagster import (
    AssetIn,
    AssetsDefinition,
    GraphDefinition,
    OpDefinition,
    graph_asset,
)

import pudl.logging_helpers

from . import experiment_tracking

[docs] logger = pudl.logging_helpers.get_logger(__name__)
[docs] MODEL_CONFIGURATION = {}
[docs] def get_yml_config(experiment_name: str) -> dict: """Load model configuration from yaml file.""" config_file = ( importlib.resources.files("pudl.package_data.settings") / "pudl_models.yml" ) config = yaml.safe_load(config_file.open("r")) if not (model_config := config.get(experiment_name)): raise RuntimeError(f"No {experiment_name} entry in {config_file}") return {experiment_name: model_config}
[docs] def get_default_config(model_graph: GraphDefinition) -> dict: """Get default config values for model.""" def _get_default_from_ops(node: OpDefinition | GraphDefinition): config = {} if isinstance(node, GraphDefinition): config = { "ops": { child_node.name: _get_default_from_ops(child_node) for child_node in node.node_defs } } else: if node.config_schema.default_provided: config = {"config": node.config_schema.default_value} else: config = {"config": None} return config config = {model_graph.name: _get_default_from_ops(model_graph)} config[f"{model_graph.name}_tracker"] = { "config": experiment_tracking.ExperimentTrackerConfig().model_dump() } return config
[docs] def pudl_model(asset_name: str, config_from_yaml: bool = False) -> AssetsDefinition: """Decorator for an ML model that will handle providing configuration to dagster.""" def _decorator(model_graph: GraphDefinition): model_config = get_default_config(model_graph) if config_from_yaml: model_config |= get_yml_config(model_graph.name) MODEL_CONFIGURATION[asset_name] = {"ops": model_config} # Inputs should come from assets except experiment tracker ins = { key: AssetIn(key) for key in model_graph.input_dict if key != "experiment_tracker" } @graph_asset(name=asset_name, ins=ins) def model_asset(**kwargs): experiment_tracker = experiment_tracking.experiment_tracker_factory( experiment_name=model_graph.name, model_config=model_config, )() return model_graph(experiment_tracker, **kwargs) return model_asset return _decorator