Source code for interpretune.session

from typing import Any, Dict, Callable, Mapping, Type, Sequence
import os
import importlib
from dataclasses import dataclass, field

from interpretune.adapter_registry import ADAPTER_REGISTRY
from interpretune.config import ITDataModuleConfig, ITConfig, ITSerializableCfg, ITSharedConfig
from interpretune.protocol import Adapter, DataModuleInitable, ModuleSteppable, ITModuleProtocol, ITDataModuleProtocol
from interpretune.utils import unexpected_state_msg_suffix, rank_zero_warn
from interpretune.metadata import ITClassMetadata


class NamedWrapper:
    def __repr__(self) -> str:
        orig_module = getattr(self, "_orig_module_name", "Original module attribute not set, instantiation incomplete.")
        composed_classes = getattr(self, "_composed_classes", "N/A")
        enriched_mod_str = f"Original module: {orig_module} {os.linesep}"
        enriched_mod_str += f"Now {self.__class__.__name__} composing {orig_module} with: {os.linesep}  - "
        composed_mod_lines = [c.__name__ for c in composed_classes] if not isinstance(composed_classes, str) else "N/A"
        enriched_mod_str += f"{os.linesep}  - ".join(composed_mod_lines) + f"{os.linesep}"
        return enriched_mod_str + super().__repr__()


class ITMeta(type):
    supported_component_keys = ("datamodule", "dm", "module", "m")

    def __new__(mcs, name, bases, classdict, **kwargs):
        component, input_cls, ctx = mcs._validate_build_ctx(kwargs)
        # TODO: add runtime checks for adherence to IT protocol here?
        composition_classes = mcs._map_composition_target(component, ctx)
        new_bases: tuple[type, ...] = (NamedWrapper, input_cls, *composition_classes)  # type: ignore[misc]
        built_class = super().__new__(mcs, name, new_bases, classdict)
        built_class._orig_module_name = input_cls.__qualname__  # type: ignore[attr-defined]  # dynamic attribute for session tracking
        built_class._composed_classes = composition_classes  # type: ignore[attr-defined]  # dynamic attribute for session tracking
        return built_class

    @staticmethod
    def _validate_build_ctx(kwargs: dict) -> tuple[str, Callable, tuple]:
        required_kwargs = ("component", "input", "ctx")
        for kwarg in required_kwargs:
            if kwarg not in kwargs:
                raise ValueError(f"{kwarg} must be provided")
        if (component := kwargs.get("component")) not in ITMeta.supported_component_keys:
            raise ValueError(f"Specified component was {component}, should be either 'module' or 'datamodule'")
        if not callable(input := kwargs.get("input")):
            raise ValueError(f"Specified input {input} is not a callable, it should be the class to be enriched.")
        if not isinstance(ctx := kwargs.get("ctx"), tuple):
            raise ValueError(f"Specified ctx {ctx} must be a tuple specifying the desired class enrichment")
        return component, input, ctx

    @staticmethod
    def _map_composition_target(component, ctx):
        component_key = None
        match component:
            case "datamodule" | "dm":
                component_key = "datamodule"
            case "module" | "m":
                component_key = "module"
        assert component_key is not None, f"invalid component, should be in: {ITMeta.supported_component_keys}"
        return ADAPTER_REGISTRY.get((component_key, *ctx))


[docs] @dataclass(kw_only=True) class UnencapsulatedArgs(ITSerializableCfg): # Most use cases will encapsulate datamodule/module config by subclassing the relevant dataclasses for a given # experiment/application but we also allow unencapsulated args/kwargs to be passed to the datamodule and module dm_args: tuple = () dm_kwargs: dict[str, Any] = field(default_factory=dict) module_args: tuple = () module_kwargs: dict[str, Any] = field(default_factory=dict)
[docs] @dataclass(kw_only=True) class ITSessionConfig(UnencapsulatedArgs): adapter_ctx: Sequence[Adapter | str] = (Adapter.core,) datamodule_cfg: ITDataModuleConfig module_cfg: ITConfig shared_cfg: ITSharedConfig | Dict | None = None datamodule_cls: Type[DataModuleInitable] | str | None = None module_cls: Type[ModuleSteppable] | str | None = None datamodule: ITDataModuleProtocol | None = None module: ITModuleProtocol | None = None def __post_init__(self): self.adapter_ctx = ADAPTER_REGISTRY.canonicalize_composition(self.adapter_ctx) # TODO: add checks validating supported adapter combinations are provided # N.B. we must defer validating data/module_cls against their respective protocols until after composing # TODO: add warnings/errors here in case provided classes cannot be resolved or are invalid for attr_k in ["datamodule_cls", "module_cls"]: if isinstance(getattr(self, attr_k), str): module, module_cls = getattr(self, attr_k).rsplit(".", 1) mod = importlib.import_module(module) setattr(self, attr_k, getattr(mod, module_cls, None)) if self.shared_cfg: if isinstance(self.shared_cfg, dict): self.shared_cfg = ITSharedConfig(**self.shared_cfg) for attr, value in self.shared_cfg.__dict__.items(): for cfg in [self.datamodule_cfg, self.module_cfg]: if getattr(cfg, attr, None) not in (None, {}, "", value): rank_zero_warn( f"Overriding `{attr}` from `{cfg.__class__.__name__}` with value from `shared_cfg`" ) setattr(cfg, attr, value) cfg._validate_on_session_cfg_init()
[docs] class ITSession(Mapping): # Consolidated class-level metadata to reduce attribute clutter _it_cls_metadata = ITClassMetadata( base_attrs={Adapter.core: ("datamodule", "module"), Adapter.lightning: ("datamodule", "model")}, ready_attrs=("datamodule", "module"), composition_target_attrs=("datamodule_cls", "module_cls"), ready_protocols=(ITDataModuleProtocol, ITModuleProtocol), ) def __init__(self, session_cfg: ITSessionConfig, *args, **kwargs): super().__init__(*args, **kwargs) self.datamodule = None self.module = None # to improve usability, run a datamodule cross-validation hook to allow auto-reconfiguration prior to # session instantiation session_cfg.datamodule_cfg._cross_validate(session_cfg.module_cfg) self.compose_interpretunable(session_cfg) # TODO: filter adapter_ctx to find first adapter w custom BASE_ATTRS in the future (only lightning right now) base_attrs = type(self)._it_cls_metadata.base_attrs self._ctx = ( base_attrs[Adapter.lightning] if Adapter.lightning in session_cfg.adapter_ctx else base_attrs[Adapter.core] ) def __getitem__(self, key): return self.to_dict()[key] def __iter__(self): return iter(self.to_dict()) def __len__(self): return len(self.to_dict()) def to_dict(self) -> dict[str, Any]: return {self._ctx[0]: self.datamodule, self._ctx[1]: self.module} def __repr__(self) -> str: try: dm = getattr(self, "datamodule", None) m = getattr(self, "module", None) parts = [] parts.append(f"datamodule={dm.__class__.__name__ if dm is not None else None}") parts.append(f"module={m.__class__.__name__ if m is not None else None}") # include module._it_state summary if available if m is not None and hasattr(m, "_it_state"): try: parts.append(m._it_state.to_summary()) except Exception: parts.append("ITState(<unavailable>)") return f"ITSession({', '.join(parts)})" except Exception: return super().__repr__() def _check_ready(self, session_cfg: ITSessionConfig) -> None: meta = type(self)._it_cls_metadata for ready, tocompose, ready_type in zip(meta.ready_attrs, meta.composition_target_attrs, meta.ready_protocols): if ready_mod := getattr(session_cfg, ready): # if a module is ready, ensure we don't try to transform it if not isinstance(ready_mod, ready_type): raise ValueError( f"{ready_mod} is not a {ready_type}, {ready_mod} should either be left unset (to" f" enable auto-composition of {getattr(session_cfg, tocompose)}) or be a" f" {ready_type}." ) setattr(session_cfg, tocompose, None) def compose_interpretunable(self, session_cfg: ITSessionConfig) -> None: dm_cls, m_cls = None, None # 1. We first check to see if the datamodule and module classes are already defined and adhere to the relevant # protocol. self._check_ready(session_cfg) # 1.1. If so, we can skip composition meta = type(self)._it_cls_metadata for ready, tocompose in zip(meta.ready_attrs, meta.composition_target_attrs): if getattr(session_cfg, tocompose) is not None: continue setattr(self, ready, getattr(session_cfg, ready)) # 2. For the components (i.e. datamodule and module) that aren't ready, we compose the provided input component # with the relevant classes based on the specified execution context and instantiate our enriched components! if session_cfg.datamodule_cls: # 2.1 Compose datamodule if necessary dm_cls = ITMeta( "InterpretunableDataModule", (), {}, component="dm", input=session_cfg.datamodule_cls, ctx=session_cfg.adapter_ctx, ) self.datamodule = dm_cls(itdm_cfg=session_cfg.datamodule_cfg, *session_cfg.dm_args, **session_cfg.dm_kwargs) # type: ignore[call-arg] self._set_dm_handles_for_instantiation(session_cfg) if session_cfg.module_cls: # 2.2 Compose module if necessary m_cls = ITMeta( "InterpretunableModule", (), {}, component="m", input=session_cfg.module_cls, ctx=session_cfg.adapter_ctx, ) self.module = m_cls(it_cfg=session_cfg.module_cfg, *session_cfg.module_args, **session_cfg.module_kwargs) # type: ignore[call-arg] self._set_model_handles_for_instantiation() self._validate_session(dm_cls, m_cls, session_cfg) def _set_dm_handles_for_instantiation(self, session_cfg: ITSessionConfig): # some datamodule handles may be required for module init, we update the module_cfg to provide them here supported_dm_handles_for_module = {"tokenizer": "tokenizer"} for m_attr, dm_handle in supported_dm_handles_for_module.items(): # attach directly to module if it's ready, otherwise pass the handles to our module_cfg attr_target_obj = getattr(self, "module", None) or session_cfg.module_cfg setattr(attr_target_obj, m_attr, getattr(self.datamodule, dm_handle)) def _set_model_handles_for_instantiation(self): # having access to a model handle may be useful in some pre-setup hook steps (e.g. signature inspection in # `prepare_data`) we provide early access to the model handle for datamodule # TODO: for Lightning, since we're setting _module before trainer.model is set, we double check attr coherency # TODO: wrt Lightning compatibility, since we're providing access to the model handle before `setup_environment` # or `configure_model` hooks are executed, need to evaluate the the validity/need to update this reference # after subsequent model hooks are called supported_module_handles_for_datamodule = {"_module": self.module} for dm_attr, m_handle in supported_module_handles_for_datamodule.items(): setattr(self.datamodule, dm_attr, m_handle) def _issue_warnings(self, dm_composed, m_composed, session_cfg, warn_datamodule, warn_module): if warn_datamodule: dm_warn_name = getattr(self.datamodule, "_orig_module_name", self.datamodule.__class__.__name__) dm_warn_base = f"{dm_warn_name} is not {ITDataModuleProtocol}" dm_warn_composed = ( f" even after composing {session_cfg.datamodule_cls} with {dm_composed}. {unexpected_state_msg_suffix}" ) not_ready_dm_warn = dm_warn_base + dm_warn_composed if dm_composed else dm_warn_base rank_zero_warn(not_ready_dm_warn) if warn_module: m_warn_name = getattr(self.module, "_orig_module_name", self.module.__class__.__name__) m_warn_base = f"{m_warn_name} is not {ITModuleProtocol}" m_warn_composed = ( f" even after composing {session_cfg.module_cls} with {m_composed}. {unexpected_state_msg_suffix}" ) not_ready_m_warn = m_warn_base + m_warn_composed if m_composed else m_warn_base rank_zero_warn(not_ready_m_warn) def _validate_session(self, dm_composed, m_composed, session_cfg): warn_datamodule = not isinstance(self.datamodule, ITDataModuleProtocol) warn_module = not isinstance(self.module, ITModuleProtocol) if any([warn_datamodule, warn_module]): self._issue_warnings(dm_composed, m_composed, session_cfg, warn_datamodule, warn_module)