# Copyright (c) 2025 Sigrun May,
# Ostfalia Hochschule für angewandte Wissenschaften
#
# This software is distributed under the terms of the MIT license
# which is available at https://opensource.org/licenses/MIT
"""Generation of standalone informative features and class separation.
This module builds numeric class labels from ``DatasetConfig.class_configs``,
samples base values for standalone informative features according to per-class
distributions, and applies class-wise mean shifts controlled by the
per-group ``class_sep`` on ``DatasetConfig.standalone_informative_groups``.
Scope:
Only *standalone* informative features are produced here (i.e. informative
features that are not cluster anchors). Correlated clusters, including
their anchors and the attenuated proxy shifts, are handled in
``correlated.py``. Independent noise features are sampled directly in
``generator.py`` via ``utils.sampling.sample_distribution``.
The standalone-informative block is partitioned into contiguous groups, each
carrying its own ``class_sep``. The class-wise offsets are derived per group
from ``class_sep`` by ``_class_offsets_from_sep`` and applied as pure mean
shifts, so the per-class distribution shape is preserved.
"""
from __future__ import annotations
from collections.abc import Sequence
import numpy as np
from biomedical_data_generator.config import DatasetConfig
from biomedical_data_generator.utils.sampling import sample_distribution
__all__ = [
"generate_informative_features",
"resolve_standalone_groups",
]
def _resolve_group_sep(class_sep: float | Sequence[float], n_classes: int) -> list[float]:
"""Resolve a group's ``class_sep`` to a length ``n_classes - 1`` vector.
A scalar broadcasts to ``n_classes - 1`` equal pairwise separations; a
sequence is returned as-is (its length is validated on ``DatasetConfig``).
Args:
class_sep: Scalar separation or an explicit sequence of pairwise
separations.
n_classes: Number of classes.
Returns:
list[float]: Pairwise separations of length ``n_classes - 1``.
"""
if isinstance(class_sep, Sequence):
return [float(s) for s in class_sep]
return [float(class_sep)] * (n_classes - 1)
def _class_offsets_from_sep(sep_vec: list[float]) -> np.ndarray:
"""Construct centered class-wise offsets from a (K-1,) separation vector.
The returned offsets have length K where K = len(sep_vec) + 1. Offsets
are cumulative sums of the separation entries and are mean-centered.
Args:
sep_vec: 1-D array of length K-1 representing pairwise separations.
Returns:
np.ndarray: 1-D array of length K with class offsets whose mean is zero.
"""
sep = np.asarray(sep_vec, dtype=float).ravel()
offsets = np.concatenate(([0.0], np.cumsum(sep)))
offsets -= offsets.mean()
return offsets
[docs]
def resolve_standalone_groups(cfg: DatasetConfig) -> list[tuple[tuple[int, ...], tuple[float, ...]]]:
"""Resolve the column layout and centered per-class offsets of each group.
Mirrors exactly the contiguous, declaration-order layout and the per-class
mean shifts applied by :func:`generate_informative_features`: group ``g``
occupies the next ``n_features`` columns at the front of the matrix, and its
offset vector (length ``n_classes``) is derived from the group's ``class_sep``
by the same ``_resolve_group_sep`` / ``_class_offsets_from_sep`` path. Because
every value is read from the resolved config, the result is reproducible
without the feature matrix.
Args:
cfg: Resolved DatasetConfig.
Returns:
One ``(column_indices, per_class_offset)`` pair per group in
``cfg.standalone_informative_groups``, in declaration order.
"""
groups: list[tuple[tuple[int, ...], tuple[float, ...]]] = []
col = 0
for group in cfg.standalone_informative_groups:
col_stop = col + int(group.n_features)
column_indices = tuple(range(col, col_stop))
offsets = _class_offsets_from_sep(_resolve_group_sep(group.class_sep, cfg.n_classes))
groups.append((column_indices, tuple(float(o) for o in offsets)))
col = col_stop
return groups
# ---------------------------------------------------------------------------
# Label construction
# ---------------------------------------------------------------------------
def _build_class_labels(cfg: DatasetConfig) -> np.ndarray:
"""Build numeric class labels 0..K-1 from DatasetConfig.class_configs.
Args:
cfg: DatasetConfig containing class_configs with per-class n_samples.
Returns:
np.ndarray: 1-D integer array of length cfg.n_samples with labels in
{0, ..., K-1}.
Raises:
RuntimeError: If the concatenated label length does not match cfg.n_samples.
"""
labels: list[np.ndarray] = []
for idx, cls_cfg in enumerate(cfg.class_configs):
labels.append(np.full(cls_cfg.n_samples, idx, dtype=int))
y = np.concatenate(labels, axis=0)
if y.shape[0] != cfg.n_samples:
raise RuntimeError(f"Inconsistent label construction: got {y.shape[0]} labels, expected {cfg.n_samples}.")
return y
# ---------------------------------------------------------------------------
# Public API: generate_informative_features
# ---------------------------------------------------------------------------