Source code for skfolio.containers._asset_panel._panel

"""Asset Panel container."""

# Copyright (c) 2023-2026
# Author: Hugo Delatte <hugo.delatte@skfoliolabs.com>
# SPDX-License-Identifier: BSD-3-Clause

from __future__ import annotations

import json
import shutil
from collections.abc import Generator, Iterable, Mapping
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Literal

import numpy as np
import pandas as pd

from skfolio.containers._asset_panel._base import _BaseAssetPanel
from skfolio.containers._asset_panel._fields import (
    MISSING_CATEGORY_CODE,
    BaseField,
    Field2D,
    Field3D,
    FieldCategorical,
    InactivePolicy,
    _as_observation_array,
    _as_pickle_safe_array,
    _validate_unique_labels,
)
from skfolio.containers._asset_panel._utils import (
    _as_field,
    _fill_2d,
    _format_observation_range,
    _materialize_selector,
    _normalize_field_names,
    _normalize_positional_selector,
    _positions_from_unique_labels,
    _raise_if_raw_replaces_typed_field,
    _selector_length,
    _slice_field_values,
    _to_dataframe,
    _validate_active_observations,
    _validate_field_against_axes,
    _validate_field_name,
)
from skfolio.containers._asset_panel._view import AssetPanelView
from skfolio.typing import AnyArray, ArrayLike, BoolArray, IntArray, StrArray

__all__ = ["AssetPanel"]


_METADATA_FILENAME = "_metadata.json"
_SAVE_VERSION = 1


[docs] @dataclass(slots=True) class AssetPanel(_BaseAssetPanel): """Container for aligned cross-sectional asset data. `AssetPanel` stores asset-level fields (e.g. returns, volumes, industry classification, factor exposure), over shared observation and asset axes. Every field uses `observations` as the first axis and `assets` as the second axis. These two axes always have shape (n_observations, n_assets). Three kinds of fields are supported: - 2D numeric fields (e.g. `returns`, `volume`, `market_cap`). These are stored as 2D numpy array in a `Field2D`. - 2D categorical fields (e.g. `country`, `industry`). These are stored as 2D numpy array of integer codes in a `FieldCategorical`, together with the category labels (e.g. "bank", "technology") - 3D numeric fields (e.g. `factor_exposures`). These are stored in a 3D numpy array in a `Field3D` with shape (n_observations, n_assets, n_third_axis), together with the third axis labels such as factor names (e.g. "size", "momentum") and optional group labels such as factor families (e.g. "style", "industry") The container is scikit-learn compatible: `len(panel)` returns `n_observations` and `panel[start:stop]` returns an `AssetPanelView` that can be passed to cross-validation and hyper-parameter tuning utilities. View creation is zero-copy when the observation selector is a slice or a contiguous index. Parameters ---------- fields : dict[str, BaseField or ndarray] Field mapping. Raw arrays must be 2D and are converted to `Field2D`. Use `FieldCategorical` for integer-coded categorical fields and `Field3D` for 3D fields; both carry the metadata needed to interpret their codes or third axes. observations : ndarray of shape (n_observations,) Unique observation labels for the sample axis. NumPy dtypes are preserved. Object-dtype datetime-like labels are converted with NumPy, object-dtype string labels are converted to strings and mixed object labels are rejected. asset_names : ndarray of shape (n_assets,) Unique asset labels. Object-dtype labels are converted to strings. active_mask : boolean ndarray of shape (n_observations, n_assets), optional Boolean mask indicating whether each asset belongs to the universe at each observation. This separates assets that are outside the universe (e.g. before listing, after delisting) from assets that are in the universe but have a missing observation (e.g. holiday, missing quote). If `None`, all pairs are active. estimation_mask : boolean ndarray of shape (n_observations, n_assets), optional Boolean mask indicating which active `(observation, asset)` pairs should be used for estimator-specific statistics by `skfolio` estimators that support it (e.g. :class:`~skfolio.preprocessing.CSStandardScaler`, :class:`~skfolio.moments.RegimeAdjustedEWCovariance` If `None`, all active pairs are eligible for estimation. Values are always enforced as a subset of `active_mask`. Attributes ---------- n_observations : int Number of observations. n_assets : int Number of assets. n_fields : int Number of fields. Notes ----- `AssetPanel` is an optimized middle ground between raw NumPy arrays and general-purpose labeled containers (e.g. pandas, polars, xarray). It is optimized for portfolio, factor and alpha workflows: - observations are the sample axis, so scikit-learn cross-validation can slice over time without grouping rows - assets are fixed on axis 1, while `active_mask` represents listings, delistings and other universe changes - payload arrays remain numeric, with categorical fields stored as integer codes - categorical levels, third-axis labels and third-axis groups are stored with their fields - shape, mask and universe invariants are validated by the container - estimators can rely on validated axes, masks and float-field universe invariants without repeating full container validation Compared with DataFrames, this avoids repeated `groupby`, `pivot` and index-alignment work while keeping the arrays ready for vectorized cross-sectional and time-series operations. Compared with xarray, it keeps a smaller API optimized for quant workflows. Performance benefits come from the same layout: - observation slices return `AssetPanelView` objects, so walk-forward folds can reuse field arrays - native `Field3D` fields avoid restacking large lists of 2D arrays - integer-coded categoricals and dense boolean masks keep memory and conversion overhead low - with `Parallel(..., prefer="threads")`, workers can read the same big panel in memory instead of receiving separate process copies. This is useful for NumPy-heavy computations where the numeric kernels release the GIL. - saved panels use `.npy` files and support memory-mapped loading with `AssetPanel.load(..., mmap_mode=...)`. **Indexing.** - `panel["name"]` returns the underlying field array. - `panel.fields["name"]` returns the field object with its metadata. - `panel[start:stop]` returns an `AssetPanelView` with shared field arrays. - `panel.isel(...)` and `panel.sel(...)` select observations and assets by position or label. Examples -------- >>> import numpy as np >>> from skfolio.containers import AssetPanel, concat >>> >>> n_observations = 252 >>> observations = np.arange(n_observations) >>> assets = ["AAPL", "MSFT", "GOOG", "AMZN"] >>> n_assets = len(assets) >>> >>> panel = AssetPanel( ... fields={ ... "returns": np.random.randn(n_observations, n_assets), ... "volume": np.random.lognormal(size=(n_observations, n_assets)), ... "market_cap": np.random.lognormal(size=(n_observations, n_assets)), ... }, ... observations=observations, ... asset_names=assets, ... ) >>> panel.add_categorical_field( ... name="industry", ... values=np.random.randint(0, 3, size=(n_observations, n_assets)), ... levels=["energy", "bank", "technology"], ... ) AssetPanel(n_observations=252, n_assets=4, n_fields=4) >>> factor_labels = ["size", "momentum", "value"] >>> panel.add_3d_field( ... name="factor_exposure", ... values=np.random.randn(n_observations, n_assets, len(factor_labels)), ... third_axis_name="factor", ... third_axis_labels=factor_labels, ... third_axis_groups=["style", "style", "style"], ... ) AssetPanel(n_observations=252, n_assets=4, n_fields=5) >>> panel.n_observations, panel.n_assets, panel.n_fields (252, 4, 5) Access raw NumPy arrays: >>> returns = panel["returns"] >>> industry_codes = panel["industry"] >>> factor_exposure = panel["factor_exposure"] Use field objects or decoding helpers when labels or metadata are needed: >>> industry_labels = panel.decode_categorical_field("industry") >>> exposure_field = panel.fields["factor_exposure"] >>> exposure_field.third_axis_labels array(['size', 'momentum', 'value'], dtype='<U8') Slice observations: >>> view = panel[100:200] >>> view.n_observations 100 Select observations and assets by position or label: >>> panel.isel(observations=slice(0, 60), assets=[0, 1]) AssetPanel(n_observations=60, n_assets=2, n_fields=5) >>> panel.sel(observations=slice(0, 59), assets=["AAPL", "MSFT"]) AssetPanel(n_observations=60, n_assets=2, n_fields=5) Select entries from a 3D field by third-axis label or group: >>> panel.sel_3d("factor_exposure", labels="momentum").shape (252, 4) >>> panel.sel_3d("factor_exposure", groups="style").shape (252, 4, 3) Convert to pandas: >>> df = panel.to_dataframe(fields=["returns", "industry"], output_format="wide") Get summary and inspect missingness: >>> summary = panel.describe(by="industry") >>> report = panel.info() Clean selected fields: >>> panel.ffill("returns", inplace=False) AssetPanel(n_observations=252, n_assets=4, n_fields=5) >>> panel.bfill("returns", inplace=False) AssetPanel(n_observations=252, n_assets=4, n_fields=5) >>> panel.align_active_mask_to("returns") 0 Rename field and drop assets: >>> panel.rename({"market_cap": "capitalization"}) AssetPanel(n_observations=252, n_assets=4, n_fields=5) >>> panel.drop(assets=["AMZN"]) AssetPanel(n_observations=252, n_assets=3, n_fields=5) Concatenate, copy, save and load panels: >>> concat([panel[:126], panel[126:]]) AssetPanel(n_observations=252, n_assets=4, n_fields=5) >>> panel.copy(deep=True) AssetPanel(n_observations=252, n_assets=4, n_fields=5) >>> panel.save("asset_panel") >>> loaded = AssetPanel.load("asset_panel", mmap_mode="r") """ fields: dict[str, BaseField] observations: AnyArray asset_names: StrArray | list[str] active_mask: BoolArray = None estimation_mask: BoolArray = None _validate_on_init: bool = True def __post_init__(self) -> None: """Normalize fields, labels and masks after dataclass initialization.""" if not self.fields: raise ValueError("`fields` must contain at least one entry.") self.fields = { name: _as_field(field) for name, field in dict(self.fields).items() } for name in self.fields: _validate_field_name(name) self.observations = _as_observation_array(self.observations) self.asset_names = _as_pickle_safe_array(self.asset_names) expected_shape = (self.n_observations, self.n_assets) if self.active_mask is None: self.active_mask = np.ones(expected_shape, dtype=np.bool_) else: self.active_mask = np.asarray(self.active_mask) if self.estimation_mask is None: self.estimation_mask = np.ones(expected_shape, dtype=np.bool_) else: self.estimation_mask = np.asarray(self.estimation_mask) # validate observations and assets if self.observations.ndim != 1: raise ValueError("observations must be a 1D array.") if self.asset_names.ndim != 1: raise ValueError("assets must be a 1D array.") if self.observations.shape != (self.n_observations,): raise ValueError(f"observations must have shape ({self.n_observations},).") if self.asset_names.shape != (self.n_assets,): raise ValueError(f"assets must have shape ({self.n_assets},).") _validate_unique_labels(self.observations, name="observations") _validate_unique_labels(self.asset_names, name="assets") self._validate_masks_shape_and_type() self._enforce_estimation_mask_subset() self._validate_masks_invariants() if self._validate_on_init: for name, field in self.fields.items(): self._validate_field(name, field) self._lock_masks() def __len__(self) -> int: """Return the number of observations.""" return self.n_observations def __getitem__(self, key: Any) -> AnyArray | AssetPanelView: """Return field values or an observation view. Slice selectors are zero-copy. Integer or boolean array selectors follow NumPy fancy-indexing semantics on access and may copy. Parameters ---------- key : str, int, slice or array-like If `key` is a string, return the underlying field array. Otherwise, interpret `key` as an observation selector and return an `AssetPanelView`. Returns ------- values or view : ndarray or AssetPanelView For a string key, the underlying field array (no copy). For an observation selector, an `AssetPanelView` whose field arrays are views into this panel. """ if isinstance(key, tuple): raise TypeError( "AssetPanel supports field access by name or one-dimensional " "observation selectors. Use isel(..., assets=...) or " "sel(..., assets=...) to select assets." ) if isinstance(key, str): return self.fields[key].values selector = _normalize_positional_selector(self.n_observations, key) return AssetPanelView(owner=self, observation_selector=selector) def __setitem__(self, name: str, value: BaseField | ArrayLike) -> None: """Add or replace a field. Raw numpy arrays are coerced to `Field2D`. Pass `FieldCategorical` or `Field3D` explicitly when metadata is required. Replacing an existing `FieldCategorical` or `Field3D` with a raw numpy array raises `TypeError`, since this would silently discard categorical levels or third-axis metadata. Parameters ---------- name : str Field name. value : BaseField or array-like Field object or raw 2D array with shape (n_observations, n_assets). Raw arrays use `inactive_policy=InactivePolicy.MISSING`, so floating entries outside `active_mask` must be NaN. """ _validate_field_name(name) if name in self.fields: _raise_if_raw_replaces_typed_field( name=name, value=value, existing_field=self.fields[name] ) field = _as_field(value) self._validate_field(name, field) self.fields[name] = field def __delitem__(self, name: str) -> None: """Delete a field. Parameters ---------- name : str Field name to remove. Raises ------ KeyError If `name` is not a known field. ValueError If `name` is the only field. An `AssetPanel` must contain at least one field so that observation and asset axes remain defined. """ if name not in self.fields: raise KeyError(name) if len(self.fields) == 1: raise ValueError("Cannot delete the last field from an AssetPanel.") del self.fields[name] def __repr__(self) -> str: """Return a compact representation with panel dimensions.""" return ( f"AssetPanel(n_observations={self.n_observations}, " f"n_assets={self.n_assets}, n_fields={self.n_fields})" )
[docs] @contextmanager def edit_masks(self, *, _validate: bool = True) -> Generator[None, None, None]: """Temporarily make masks editable. On exit, `estimation_mask` is re-enforced as a subset of `active_mask`, field inactive policies are applied, and both masks are locked again. Parameters ---------- _validate : bool, default=True Internal flag controlling the per-observation non-empty mask check after the context exits. Yields ------ None The panel with editable mask arrays. """ self.active_mask.flags.writeable = True self.estimation_mask.flags.writeable = True try: yield finally: self._enforce_estimation_mask_subset() self._apply_inactive_field_policies() self._lock_masks() if _validate: self._validate_masks_shape_and_type() self._validate_masks_invariants()
@property def n_observations(self) -> int: """Number of observations.""" return self._first_field().n_observations @property def n_assets(self) -> int: """Number of assets.""" return self._first_field().n_assets @property def n_fields(self) -> int: """Number of fields.""" return len(self.fields)
[docs] def keys(self) -> Iterable[str]: """Return field names. Returns ------- names : KeysView of str Names of all fields in the panel. """ return self.fields.keys()
[docs] def get_field(self, name: str) -> BaseField: """Return a field object. Parameters ---------- name : str Field name. Returns ------- field : BaseField Field object that owns the field values and metadata. """ return self.fields[name]
[docs] def isel( self, *, observations: Any = None, assets: Any = None ) -> AssetPanel | AssetPanelView: """Select observations and assets by integer position. Parameters ---------- observations : int, slice, array-like, or None, optional Positional selector for the observation axis. If `None`, all observations are selected. assets : int, slice, array-like, or None, optional Positional selector for the asset axis. If `None`, assets are not sliced and an `AssetPanelView` is returned. Returns ------- panel or view : AssetPanel or AssetPanelView Observation-only selections return a view. Selections that slice assets return a new panel. """ observation_selector = _normalize_positional_selector( self.n_observations, slice(None) if observations is None else observations ) if assets is None: return AssetPanelView(owner=self, observation_selector=observation_selector) asset_selector = _normalize_positional_selector(self.n_assets, assets) return self._subset( observation_selector=observation_selector, asset_selector=asset_selector )
[docs] def sel( self, *, observations: Any = None, assets: Any = None, fields: str | Iterable[str] | None = None, ) -> AssetPanel | AssetPanelView: """Select observations, assets and fields by label. Parameters ---------- observations : scalar, slice, iterable, or None, optional Observation labels to select. If `None`, all observations are selected. assets : scalar, slice, iterable, or None, optional Asset labels to select. If `None`, assets are not sliced and an `AssetPanelView` is returned. fields : str, iterable of str, or None, optional Field names to select. If `None`, all fields are selected. Returns ------- panel or view : AssetPanel or AssetPanelView Observation-only selections without field filtering return a view. Selections that slice assets or fields return a new panel. """ observation_selector = ( slice(None) if observations is None else _positions_from_unique_labels(self.observations, observations) ) field_names = _normalize_field_names(self.fields, fields) if assets is None and fields is None: return AssetPanelView(owner=self, observation_selector=observation_selector) asset_selector = ( slice(None) if assets is None else _positions_from_unique_labels(self.asset_names, assets) ) return self._subset( observation_selector=observation_selector, asset_selector=asset_selector, fields=field_names, )
[docs] def drop(self, *, observations: Any = None, assets: Any = None) -> AssetPanel: """Return a panel with selected labels removed. Parameters ---------- observations : scalar, iterable, or None, optional Observation labels to remove. assets : scalar, iterable, or None, optional Asset labels to remove. Returns ------- panel : AssetPanel New panel with the selected observations or assets removed. """ if observations is None: observation_keep = slice(None) else: drop_positions = _materialize_selector( self.n_observations, _positions_from_unique_labels(self.observations, observations), ) keep = np.ones(self.n_observations, dtype=np.bool_) keep[drop_positions] = False observation_keep = np.flatnonzero(keep) if assets is None: asset_keep = slice(None) else: drop_positions = _materialize_selector( self.n_assets, _positions_from_unique_labels(self.asset_names, assets), ) keep = np.ones(self.n_assets, dtype=np.bool_) keep[drop_positions] = False asset_keep = np.flatnonzero(keep) return self._subset( observation_selector=observation_keep, asset_selector=asset_keep )
[docs] def rename( self, fields: Mapping[str, str] | None = None, *, overwrite: bool = False ) -> AssetPanel: """Rename fields in place. Parameters ---------- fields : mapping of str to str or None, optional Mapping from existing field names to replacement names. overwrite : bool, default=False If `True`, an existing target field can be replaced by a renamed field. If `False`, name conflicts raise `KeyError`. Returns ------- self : AssetPanel The modified panel. """ if fields is None: return self missing = [old for old in fields if old not in self.fields] if missing: raise KeyError(f"Fields not found: {missing}") existing = set(self.fields) targets = list(fields.values()) for name in targets: _validate_field_name(name) duplicates = {name for name in targets if targets.count(name) > 1} if duplicates: raise ValueError(f"Duplicate target field names: {sorted(duplicates)}") conflicts = [ new for old, new in fields.items() if new in existing and new != old ] if conflicts and not overwrite: raise KeyError( f"Target field names already exist: {conflicts}. " "Use overwrite=True to replace them." ) renamed = {} for name, field in self.fields.items(): if name in fields: renamed[fields[name]] = field elif overwrite and name in targets: continue else: renamed[name] = field self.fields = renamed return self
[docs] def to_dataframe( self, *, fields: str | Iterable[str] | None = None, assets: str | Iterable[str] | None = None, output_format: Literal["long", "wide"] = "long", decode_categoricals: bool = True, ) -> pd.DataFrame: """Convert 2D fields to a pandas DataFrame. `Field3D` entries are skipped with a warning when multiple fields are converted. Selecting a single `Field3D` raises `ValueError`. Parameters ---------- fields : str, iterable of str, or None, optional Field names to include. If a single string is passed, the result is a simple field DataFrame with observations as index and assets as columns. If `None`, all 2D fields are included. assets : str, iterable of str, or None, optional Asset labels to include. If `None`, all assets are included. output_format : {"long", "wide"}, default="long" Output format used when `fields` is not a single string. In long format, rows are indexed by `(observation, asset)` and filtered by `active_mask`. In wide format, columns are indexed by `(field, asset)`. decode_categoricals : bool, default=True If `True`, categorical codes are decoded to labels. Returns ------- df : pandas.DataFrame DataFrame representation of the selected 2D fields. """ return _to_dataframe( self, fields=fields, assets=assets, output_format=output_format, decode_categoricals=decode_categoricals, )
[docs] def describe(self, *, by: str | None = None) -> pd.DataFrame: """Return a structured missingness summary. Parameters ---------- by : str or None, optional Categorical field used to group missingness statistics. If `None`, missingness is summarized by field. Returns ------- summary : pandas.DataFrame Missingness summary indexed by field, or by `(field, category)` when `by` is provided. """ if by is None: rows = [] active_entries_2d = int(self.active_mask.sum()) for name, field in self.fields.items(): missing = field.missing_mask active = self.active_mask if isinstance(field, Field3D): active = active[:, :, np.newaxis] type_name = "3D" elif isinstance(field, FieldCategorical): type_name = "categorical" else: type_name = "2D" total_entries = int(np.prod(field.values.shape)) active_entries = ( active_entries_2d * field.values.shape[2] if isinstance(field, Field3D) else active_entries_2d ) missing_total = int(missing.sum()) missing_active = int((missing & active).sum()) rows.append( { "field": name, "type": type_name, "dtype": str(field.values.dtype), "missing_pct": missing_total / total_entries * 100, "missing_pct_active": ( missing_active / active_entries * 100 if active_entries else 0.0 ), } ) return pd.DataFrame(rows).set_index("field") category_field = self.fields[by] if not isinstance(category_field, FieldCategorical): raise TypeError(f"Field '{by}' is not categorical.") rows = [] for code, label in enumerate(category_field.levels): category_mask = (category_field.values == code) & self.active_mask for name, field in self.fields.items(): if name == by: continue missing = field.missing_mask mask = category_mask if isinstance(field, Field3D): mask = mask[:, :, np.newaxis] n_entries = int(category_mask.sum()) * field.values.shape[2] else: n_entries = int(mask.sum()) n_missing = int((missing & mask).sum()) rows.append( { "field": name, by: label, "n_entries": n_entries, "missing_pct": n_missing / n_entries * 100 if n_entries else 0.0, } ) return pd.DataFrame(rows).set_index(["field", by])
[docs] def info(self) -> str: """Multi-line report with panel dimensions, mask coverage, field missingness and categorical field level coverage. Returns ------- report : str Multi-line report. """ n_assets = self.n_assets n_entries = self.n_observations * self.n_assets lines = [ "AssetPanel Info", "=" * 60, f"Observations : {self.n_observations:,}{_format_observation_range(self.observations)}", f"Assets : {n_assets:,}", f"Fields : {self.n_fields}", f"Panel entries : {n_entries:,} (observations x assets)", ] active = self.active_mask active_entries = int(active.sum()) if active is not None else n_entries total_entries = 0 headline_missing = 0 total_active_entries = 0 headline_missing_active = 0 for field in self.fields.values(): if field.ndim != 2: continue total_entries += n_entries total_active_entries += active_entries missing = field.missing_mask missing_count = int(missing.sum()) missing_active = ( int((missing & active).sum()) if active is not None else missing_count ) headline_missing += missing_count headline_missing_active += missing_active if total_entries > 0: pct_miss = headline_missing / total_entries * 100 pct_miss_u = ( headline_missing_active / total_active_entries * 100 if total_active_entries > 0 else 0.0 ) lines.append( f"Missing : {pct_miss:.1f}% total, {pct_miss_u:.1f}% in Active Mask" ) def add_mask_block( title: str, mask: BoolArray | None, *, user_set: bool ) -> None: lines.append("") lines.append(title) lines.append("-" * len(title)) if mask is None or not user_set: lines.append("Not set.") return in_mask = int(mask.sum()) pct = in_mask / n_entries * 100 if n_entries > 0 else 0.0 lines.append( f"In mask : {in_mask:,} / {n_entries:,} entries" f" ({pct:.1f}%)" ) per_obs = mask.sum(axis=1) obs_min = int(per_obs.min()) obs_med = int(np.median(per_obs)) obs_max = int(per_obs.max()) lines.append( f"Assets per obs : min={obs_min:,}, " f"median={obs_med:,}, max={obs_max:,}" ) per_asset = mask.sum(axis=0) n_in = int((per_asset > 0).sum()) lines.append(f"Assets in mask : {n_in:,} / {n_assets:,}") if n_in > 0: live_duration = per_asset[per_asset > 0] med = int(np.median(live_duration)) lo = int(live_duration.min()) hi = int(live_duration.max()) lines.append(f" median duration : {med:,} observations") lines.append(f" shortest / longest : {lo:,} / {hi:,} observations") active_set = active is not None and not np.all(active) add_mask_block("Active Mask", active, user_set=active_set) est = self.estimation_mask if est is not None and active is not None and np.array_equal(est, active): lines.append("") lines.append("Estimation Mask") lines.append("-" * len("Estimation Mask")) lines.append("Same as Active Mask.") else: est_set = est is not None and not np.all(est) add_mask_block("Estimation Mask", est, user_set=est_set) lines.append("") lines.append("Field Coverage") lines.append("-" * 60) name_w = max((min(len(n), 28) for n in self.fields), default=20) name_w = max(name_w, 8) hdr = ( f"{'':>{name_w}s} {'dtype':>8s} {'% missing':>9s} {'% missing':>9s}" f" {'fully missing':>14s}" ) sub = ( f"{'':>{name_w}s} {'':>8s} {'total':>9s} {'in Active Mask':>11s}" f" {'assets (active)':>14s}" ) lines.append(hdr) lines.append(sub) for name, field in self.fields.items(): display_name = name if len(name) <= 28 else name[:25] + "..." arr = field.values if arr.ndim == 3: lines.append( f"{display_name:>{name_w}s} {'3D':>8s}" f" {'--':>9s} {'--':>11s} {'--':>14s}" ) continue dtype_s = str(arr.dtype) missing = field.missing_mask field_missing = int(missing.sum()) pct_total = field_missing / n_entries * 100 if n_entries > 0 else 0.0 if active is not None: active_entries = int(active.sum()) miss_in_active = int((missing & active).sum()) pct_active = ( miss_in_active / active_entries * 100 if active_entries > 0 else 0.0 ) per_asset_active = active.sum(axis=0).astype(float) per_asset_miss = (missing & active).sum(axis=0).astype(float) live = per_asset_active > 0 fully_miss = int(((per_asset_miss == per_asset_active) & live).sum()) else: pct_active = pct_total fully_miss = 0 lines.append( f"{display_name:>{name_w}s} {dtype_s:>8s} {pct_total:>8.1f}%" f" {pct_active:>10.1f}% {fully_miss:>14d}" ) categorical_fields = { name: field for name, field in self.fields.items() if isinstance(field, FieldCategorical) } if categorical_fields: lines.append("") lines.append("Categorical Fields") lines.append("-" * 60) buckets = [(0, 10), (10, 20), (20, 50), (50, None)] for name, field in categorical_fields.items(): arr = field.values levels = field.levels n_levels = len(levels) lines.append(f"{name} : {n_levels} levels") valid_mask = arr != MISSING_CATEGORY_CODE if active is not None: valid_mask = valid_mask & active counts = np.zeros((arr.shape[0], n_levels), dtype=np.int64) for level_idx in range(n_levels): counts[:, level_idx] = ((arr == level_idx) & valid_mask).sum(axis=1) min_counts = counts.min(axis=0) lines.append(" Min number of assets per level (over time):") for lo, hi in buckets: if lo == 0: in_bucket = min_counts < hi label = f"< {hi}" elif hi is not None: in_bucket = (min_counts >= lo) & (min_counts < hi) label = f"{lo} - {hi}" else: in_bucket = min_counts >= lo label = f"> {lo}" n_in = int(in_bucket.sum()) bucket_names = levels[in_bucket] if n_in == 0: lines.append(f" {label:>7s} : {n_in} levels") elif n_in <= 6: names_str = ", ".join(str(s) for s in bucket_names) lines.append(f" {label:>7s} : {n_in} levels ({names_str})") else: shown = ", ".join(str(s) for s in bucket_names[:4]) lines.append( f" {label:>7s} : {n_in} levels" f" ({shown}, ... +{n_in - 4} more)" ) lines.append("") return "\n".join(lines)
[docs] def ffill( self, fields: str | Iterable[str], *, limit: int | None = None, inplace: bool = True, ) -> AssetPanel: """Forward fill NaN values along the observation axis. Parameters ---------- fields : str or iterable of str Numeric `Field2D` names to fill. limit : int or None, optional Maximum number of consecutive NaN values to fill. If `None`, all consecutive NaN values are eligible. inplace : bool, default=True If `True`, modify this panel. If `False`, return a shallow copy with filled fields. Returns ------- panel : AssetPanel Modified panel or copied panel. """ return self._fill( fields, method="ffill", limit=limit, inplace=inplace, )
[docs] def bfill( self, fields: str | Iterable[str], *, limit: int | None = None, inplace: bool = True, ) -> AssetPanel: """Backward fill NaN values along the observation axis. Parameters ---------- fields : str or iterable of str Numeric `Field2D` names to fill. limit : int or None, optional Maximum number of consecutive NaN values to fill. If `None`, all consecutive NaN values are eligible. inplace : bool, default=True If `True`, modify this panel. If `False`, return a shallow copy with filled fields. Returns ------- panel : AssetPanel Modified panel or copied panel. """ return self._fill( fields, method="bfill", limit=limit, inplace=inplace, )
[docs] def align_active_mask_to(self, fields: str | Iterable[str]) -> int: """Align active periods to valid field values. For each asset, remove leading `active_mask` entries until the selected fields have valid values in the remaining active history. If an asset has no valid active value for a selected field, all active entries for that asset are removed. Only leading active entries are removed. Missing values after the first valid active value are left unchanged. Parameters ---------- fields : str or iterable of str Field names used to determine when each asset can become active. Floating values must be finite, categorical values must not be missing, and 3D floating values must be finite across the third axis. Returns ------- n_removed : int Number of `(observation, asset)` entries removed from `active_mask`. """ field_names = [fields] if isinstance(fields, str) else list(fields) for name in field_names: if name not in self.fields: raise KeyError(f"Field '{name}' not found.") n_removed = 0 new_active_mask = self.active_mask.copy() rows = np.arange(self.n_observations)[:, np.newaxis] for name in field_names: valid = ~self.fields[name].missing_mask valid_active = valid & new_active_mask has_active = new_active_mask.any(axis=0) has_valid = valid_active.any(axis=0) first_active = np.argmax(new_active_mask, axis=0) first_valid = np.argmax(valid_active, axis=0) remove = ( new_active_mask & has_active & ((~has_valid) | ((first_valid > first_active) & (rows < first_valid))) & (rows >= first_active) ) n_removed += int(remove.sum()) new_active_mask[remove] = False _validate_active_observations(new_active_mask) with self.edit_masks(): self.active_mask[:] = new_active_mask return n_removed
[docs] def copy(self, *, deep: bool = False) -> AssetPanel: """Return a copy of the panel. Parameters ---------- deep : bool, default=False If `True`, copy field arrays and label arrays. If `False`, field arrays and labels are shared. Masks are always copied so the copy owns independent lockable mask arrays. Returns ------- panel : AssetPanel Copied panel. """ return AssetPanel( fields={name: field.copy(deep=deep) for name, field in self.fields.items()}, observations=self.observations.copy() if deep else self.observations, asset_names=self.asset_names.copy() if deep else self.asset_names, active_mask=self.active_mask.copy(), estimation_mask=self.estimation_mask.copy(), _validate_on_init=False, )
[docs] def save(self, path: str | Path, *, overwrite: bool = False) -> None: """Save the panel to a directory of `.npy` files. The directory contains one `.npy` file per field, small metadata files for categorical and third-axis labels and a `_metadata.json` manifest. Object-dtype axis labels and field metadata labels are converted to strings so the panel is loaded with `allow_pickle=False`. Parameters ---------- path : str or pathlib.Path Destination directory. overwrite : bool, default=False If `True`, replace an existing saved panel at `path`. Existing directories that do not contain `_metadata.json` are never overwritten. """ path = Path(path) if path.exists(): if path.is_file(): raise ValueError(f"Path '{path}' is a file, not a directory.") if not (path / _METADATA_FILENAME).exists(): raise ValueError( f"Directory '{path}' exists but is not a saved AssetPanel " f"(missing {_METADATA_FILENAME})." ) if not overwrite: raise FileExistsError( f"Directory '{path}' already contains a saved AssetPanel. " "Use overwrite=True to replace it." ) shutil.rmtree(path) for name in self.fields: _validate_field_name(name) path.mkdir(parents=True) fields_dir = path / "fields" fields_dir.mkdir() levels_dir = path / "levels" third_axis_dir = path / "third_axis" np.save(path / "observations.npy", _as_pickle_safe_array(self.observations)) np.save(path / "assets.npy", _as_pickle_safe_array(self.asset_names)) active_mask_storage = "all_true" if not self.active_mask.all(): np.save(path / "active_mask.npy", self.active_mask) active_mask_storage = "file" estimation_mask_storage = "all_true" if not self.estimation_mask.all(): np.save(path / "estimation_mask.npy", self.estimation_mask) estimation_mask_storage = "file" field_metadata: dict[str, dict[str, Any]] = {} for name, field in self.fields.items(): np.save(fields_dir / f"{name}.npy", field.values) entry: dict[str, Any] = { "type": type(field).__name__, "dtype": str(field.values.dtype), "shape": list(field.values.shape), "inactive_policy": field.inactive_policy.value, } if isinstance(field, FieldCategorical): levels_dir.mkdir(exist_ok=True) np.save(levels_dir / f"{name}.npy", _as_pickle_safe_array(field.levels)) if isinstance(field, Field3D): third_axis_dir.mkdir(exist_ok=True) np.save( third_axis_dir / f"{name}_labels.npy", _as_pickle_safe_array(field.third_axis_labels), ) entry["third_axis_name"] = field.third_axis_name if field.third_axis_groups is not None: np.save( third_axis_dir / f"{name}_groups.npy", _as_pickle_safe_array(field.third_axis_groups), ) field_metadata[name] = entry metadata = { "version": _SAVE_VERSION, "n_observations": self.n_observations, "n_assets": self.n_assets, "active_mask": active_mask_storage, "estimation_mask": estimation_mask_storage, "fields": field_metadata, } with open(path / _METADATA_FILENAME, "w", encoding="utf-8") as f: json.dump(metadata, f, indent=2)
[docs] @classmethod def load( cls, path: str | Path, *, mmap_mode: str | None = None, fields: list[str] | None = None, ) -> AssetPanel: """Load a panel saved with `save`. Parameters ---------- path : str or pathlib.Path Directory containing a saved panel. mmap_mode : str or None, optional Memory-mapping mode passed to `numpy.load` for field and mask arrays. Use `r` for read-only memory maps. fields : list of str or None, optional Field names to load. If `None`, all fields are loaded. Returns ------- panel : AssetPanel Loaded panel. """ path = Path(path) metadata_path = path / _METADATA_FILENAME if not metadata_path.exists(): raise FileNotFoundError( f"Not a saved AssetPanel: '{path}' (missing {_METADATA_FILENAME})." ) with open(metadata_path, encoding="utf-8") as f: metadata = json.load(f) if metadata["version"] > _SAVE_VERSION: raise ValueError( f"Unsupported AssetPanel format version {metadata['version']}." ) n_observations = metadata["n_observations"] n_assets = metadata["n_assets"] observations = _as_observation_array( np.load(path / "observations.npy", allow_pickle=False) ) assets = _as_pickle_safe_array(np.load(path / "assets.npy", allow_pickle=False)) if metadata["active_mask"] == "all_true": active_mask = np.ones((n_observations, n_assets), dtype=np.bool_) else: active_mask = np.load( path / "active_mask.npy", allow_pickle=False, mmap_mode=mmap_mode, ) if metadata["estimation_mask"] == "all_true": estimation_mask = np.ones((n_observations, n_assets), dtype=np.bool_) else: estimation_mask = np.load( path / "estimation_mask.npy", allow_pickle=False, mmap_mode=mmap_mode, ) available = list(metadata["fields"]) available_set = set(available) requested = available if fields is None else list(fields) missing = [name for name in requested if name not in available_set] if missing: raise KeyError(f"Requested fields not found: {sorted(missing)}") loaded_fields: dict[str, BaseField] = {} fields_dir = path / "fields" levels_dir = path / "levels" third_axis_dir = path / "third_axis" for name in requested: entry = metadata["fields"][name] values = np.load( fields_dir / f"{name}.npy", allow_pickle=False, mmap_mode=mmap_mode, ) field_type = entry["type"] inactive_policy = InactivePolicy( entry.get("inactive_policy", InactivePolicy.MISSING.value) ) if field_type == "Field2D": loaded_fields[name] = Field2D( values, inactive_policy=inactive_policy, ) elif field_type == "FieldCategorical": levels = _as_pickle_safe_array( np.load(levels_dir / f"{name}.npy", allow_pickle=False) ) loaded_fields[name] = FieldCategorical( values, levels=levels, inactive_policy=inactive_policy, ) elif field_type == "Field3D": labels = _as_pickle_safe_array( np.load(third_axis_dir / f"{name}_labels.npy", allow_pickle=False) ) groups = None if (third_axis_dir / f"{name}_groups.npy").exists(): groups = _as_pickle_safe_array( np.load( third_axis_dir / f"{name}_groups.npy", allow_pickle=False, ) ) loaded_fields[name] = Field3D( values, third_axis_name=entry["third_axis_name"], third_axis_labels=labels, third_axis_groups=groups, inactive_policy=inactive_policy, ) else: raise ValueError(f"Unsupported field type '{field_type}'.") return cls( fields=loaded_fields, observations=observations, asset_names=assets, active_mask=active_mask, estimation_mask=estimation_mask, _validate_on_init=False, )
def _first_field(self) -> BaseField: """Return the first field used to derive panel dimensions.""" return next(iter(self.fields.values())) def _lock_masks(self) -> None: """Make mask arrays read-only outside `edit_masks`.""" self.active_mask.flags.writeable = False self.estimation_mask.flags.writeable = False def _validate_masks_shape_and_type(self) -> None: """Validate mask shape and dtype.""" expected_mask_shape = (self.n_observations, self.n_assets) if self.active_mask.shape != expected_mask_shape: raise ValueError(f"active_mask must have shape {expected_mask_shape}.") if self.estimation_mask.shape != expected_mask_shape: raise ValueError(f"estimation_mask must have shape {expected_mask_shape}.") if self.active_mask.dtype != np.bool_: raise ValueError("active_mask must have dtype bool.") if self.estimation_mask.dtype != np.bool_: raise ValueError("estimation_mask must have dtype bool.") def _validate_masks_invariants(self) -> None: """Validate mask subset relation and non-empty observations.""" if (self.estimation_mask & ~self.active_mask).any(): raise ValueError("estimation_mask must be a subset of active_mask.") _validate_active_observations(self.active_mask) if not self.estimation_mask.any(axis=1).all(): empty_idx = np.where(~self.estimation_mask.any(axis=1))[0] raise ValueError( "`estimation_mask` must contain at least one estimable asset for every " "observation after intersection with `active_mask`. " "`estimation_mask` is always enforced as a subset of `active_mask`; " "entries set to True where `active_mask` is False are ignored. " f"Found {len(empty_idx)} observation(s) with none; " f"first failing position is {empty_idx[0]}." ) def _enforce_estimation_mask_subset(self) -> None: """Enforce `estimation_mask` as a subset of `active_mask`.""" # Avoid mutating read-only inputs when load(..., mmap_mode="r"). if self.estimation_mask.flags.writeable: self.estimation_mask &= self.active_mask else: self.estimation_mask = np.asarray(self.estimation_mask & self.active_mask) def _validate_field(self, name: str, field: BaseField) -> None: """Validate a field against panel shape and active-mask invariants.""" _validate_field_against_axes( name=name, field=field, expected_shape=(self.n_observations, self.n_assets), active_mask=self.active_mask, ) def _apply_inactive_field_policies(self) -> None: """Apply each field's inactive policy outside `active_mask`.""" out = ~self.active_mask if not out.any(): return for field in self.fields.values(): if field.inactive_policy == InactivePolicy.IGNORE: continue if field.inactive_policy == InactivePolicy.ZERO: if isinstance(field, Field3D): field.values[out, :] = 0 else: field.values[out] = 0 continue if isinstance(field, FieldCategorical): field.values[out] = MISSING_CATEGORY_CODE elif np.issubdtype(field.values.dtype, np.floating): if isinstance(field, Field3D): field.values[out, :] = np.nan else: field.values[out] = np.nan def _subset( self, *, observation_selector: slice | IntArray, asset_selector: slice | IntArray, fields: Iterable[str] | None = None, ) -> AssetPanel: """Return a panel restricted to selected observations and assets.""" if _selector_length(self.n_observations, observation_selector) == 0: raise ValueError( "Cannot remove all observations; at least one must remain." ) if _selector_length(self.n_assets, asset_selector) == 0: raise ValueError("Cannot remove all assets; at least one must remain.") field_names = _normalize_field_names(self.fields, fields) new_fields = { name: self.fields[name].with_values( _slice_field_values( self.fields[name], observation_selector, asset_selector ) ) for name in field_names } return AssetPanel( fields=new_fields, observations=self.observations[observation_selector], asset_names=self.asset_names[asset_selector], active_mask=self.active_mask[observation_selector, :][:, asset_selector], estimation_mask=( self.estimation_mask[observation_selector, :][:, asset_selector] ), _validate_on_init=False, ) def _fill( self, fields: str | Iterable[str], method: Literal["ffill", "bfill"], *, limit: int | None, inplace: bool, ) -> AssetPanel: """Fill floating 2D fields along observations.""" field_names = [fields] if isinstance(fields, str) else list(fields) panel = self if inplace else self.copy() for name in field_names: if name not in panel.fields: raise KeyError(f"Field '{name}' not found.") field = panel.fields[name] if not isinstance(field, Field2D) or isinstance(field, FieldCategorical): raise TypeError(f"{method} only supports numeric Field2D entries.") if not np.issubdtype(field.values.dtype, np.floating): raise TypeError(f"{method} only supports floating Field2D entries.") values = _fill_2d( field.values, method=method, limit=limit, mask=panel.active_mask, ) values[~panel.active_mask] = np.nan panel.fields[name] = field.with_values(values) return panel