Source code for hypertools.align.align

"""hyp.align dispatcher: resolve an aligner spec and fit_transform it.

Wrapped by datawrangler's funnel so any input (array / DataFrame / list / text /
polars) arrives as DataFrame(s); the resolved Aligner (list-based, sklearn-
compatible) is applied directly. NOT routed through core.apply_model, whose
stack-and-fit-once recipe is wrong for aligning a *list* to a shared template.
"""
import warnings

import numpy as np
import pandas as pd
import datawrangler as dw

from .common import Aligner
from .hyperalign import HyperAlign
from .procrustes import Procrustes
from .srm import SharedResponseModel, DeterministicSharedResponseModel, RobustSharedResponseModel
from .null import NullAlign
from ..core.shared import unpack_model
from ..core.model import external_stacklevel


ALIGNERS = [HyperAlign, SharedResponseModel, DeterministicSharedResponseModel,
            RobustSharedResponseModel, Procrustes, NullAlign]

#: legacy (pre-1.0 / dev-1.0) string spellings -> canonical registry names.
#: `hypertools.tools.align.align` (the classic API shim) used to translate
#: these itself before calling this dispatcher; now the dispatcher accepts
#: them directly (as `model=`, or via the deprecated `align=` kwarg alias
#: below) so both entry points share one translation table.
_ALIAS = {
    'hyper': 'HyperAlign',
    'HyperAlign': 'HyperAlign',
    'SRM': 'SharedResponseModel',
    'SharedResponseModel': 'SharedResponseModel',
    'DetSRM': 'DeterministicSharedResponseModel',
    'DeterministicSharedResponseModel': 'DeterministicSharedResponseModel',
    'RSRM': 'RobustSharedResponseModel',
    'RobustSharedResponseModel': 'RobustSharedResponseModel',
    'Procrustes': 'Procrustes',
    'NullAlign': 'NullAlign',
}


def _warn_deprecated_alias(name):
    """Emit the promised DeprecationWarning for the documented-deprecated
    `'hyper'` alias (release-1.0 audit, X1-api-consistency-020: the
    docstring called it deprecated but no warning ever fired, so users
    could never learn the canonical name). The other aliases ('SRM',
    'DetSRM', 'RSRM') are documented as plain -- non-deprecated --
    aliases and stay silent."""
    if name == 'hyper':
        warnings.warn(
            "model='hyper' is a deprecated alias for 'HyperAlign'; pass "
            "model='HyperAlign' instead.",
            DeprecationWarning, stacklevel=external_stacklevel())


def _reject_unknown_aligner(name):
    """Raise a clear error for an unknown align-model name. `unpack_model`
    passes unrecognized strings through unchanged, so without this the string
    later hit "'str' object has no attribute 'fit_transform'" (QC 2026-07).
    Matches the clear "unknown X model" errors reduce/cluster/predict give."""
    raise ValueError(
        f"unknown align model {name!r}; supported names: "
        f"{', '.join(sorted(a.__name__ for a in ALIGNERS))} (or pass an "
        "Aligner subclass or instance directly).")


def _resolve_align_spec(model, extra_kwargs):
    """Resolve a `model=` spec (+ leftover `**kwargs`) into an unfitted (or,
    for an already-fitted/-constructed instance, passed-through-unchanged)
    `Aligner`.

    Mirrors `hypertools.reduce.reduce`'s inline dict-spec handling (rather
    than delegating dict specs to `core.shared.unpack_model`, whose
    canonical-dict branch requires ALL of `'model'`/`'args'`/`'kwargs'` to
    be present) so `{'model': ..., 'kwargs': {...}}` (no `'args'`) -- as
    well as the canonical `{'model': ..., 'args': [...], 'kwargs': {...}}`
    and the LEGACY `{'model': ..., 'params': {...}}` (accepted with a
    `DeprecationWarning`) -- all work.

    Parameters
    ----------
    model : str, class, instance, dict, or None
        The `model=` spec (see `align`'s docstring for the full grammar).
    extra_kwargs : dict
        Leftover `**kwargs` from the `align()` call, forwarded to the
        resolved class's constructor -- but only when `model` is a bare
        registry name/class (a dict spec's own `'args'`/`'kwargs'` take
        precedence over these, and an already-constructed instance ignores
        them entirely, matching `hyp.reduce`/`hyp.cluster`).

    Returns
    -------
    Aligner or None
        `None` when `model` is `None` or `False` (i.e. skip alignment
        entirely -- docstrings across the toolbox promise `align=False`
        as a no-op, matching `normalize=False`).
    """
    if model is None or model is False:
        return None
    if model is True:
        raise ValueError(
            "model=True is not a valid align spec; pass the algorithm name "
            "instead (e.g. model='HyperAlign'), or False/None to skip "
            "alignment.")

    if isinstance(model, str):
        _warn_deprecated_alias(model)
        model = _ALIAS.get(model, model)

    if isinstance(model, dict):
        try:
            c_model = model['model']
        except KeyError:
            raise ValueError(
                "If passing a dictionary, pass the model as the value of "
                "the 'model' key and a dictionary of custom params as the "
                "value of the 'kwargs' key (the legacy 'params' key is "
                "also accepted)."
            )
        if c_model is None or c_model is False:
            return None
        if 'args' in model or 'kwargs' in model:
            c_args = list(model.get('args', []))
            c_kwargs = dict(model.get('kwargs', {}))
        elif 'params' in model:
            # LEGACY form (dev-1.0/fork): accepted for backward
            # compatibility, but deprecated in favor of the canonical
            # {'model', 'args', 'kwargs'} triple above.
            warnings.warn(
                "{'model': ..., 'params': {...}} is deprecated; use "
                "{'model': ..., 'args': [...], 'kwargs': {...}} instead",
                DeprecationWarning, stacklevel=external_stacklevel())
            c_args, c_kwargs = [], dict(model['params'])
        else:
            c_args, c_kwargs = [], {}
        if isinstance(c_model, str):
            _warn_deprecated_alias(c_model)
            c_model = _ALIAS.get(c_model, c_model)
        resolved_inner = unpack_model(c_model, valid=ALIGNERS, parent_class=Aligner)
        if isinstance(resolved_inner, str):
            # unknown name inside the DICT form -- previously slipped past the
            # bare-string guard below and hit the cryptic AttributeError
            # (QC 2026-07 red-team: align={'model': 'Nope'}).
            _reject_unknown_aligner(resolved_inner)
        if isinstance(resolved_inner, type):
            return resolved_inner(*c_args, **c_kwargs)
        # already-constructed (or already-fitted) instance: params ignored,
        # used as-is
        return resolved_inner

    resolved = unpack_model(model, valid=ALIGNERS, parent_class=Aligner)
    if isinstance(resolved, str):
        _reject_unknown_aligner(resolved)
    if isinstance(resolved, type):
        return resolved(**extra_kwargs)
    # an already-constructed (unfitted) or already-fitted instance is
    # passed through unchanged (the caller checks `.is_fitted` to decide
    # whether to fit_transform or reuse via transform)
    return resolved


def _apply_format_data(data):
    """Run the missing-data-fill / text-to-matrix pass
    (`hypertools.tools.format_data.format_data`) on already-DataFrame'd
    `data` (from the `@dw.decorate.funnel` wrapper below), then re-wrap the
    resulting arrays as DataFrames -- preserving each original dataset's row
    index -- since `Aligner.fit`/`transform` require DataFrame(s) for
    `datawrangler.unstack`/`trim_and_pad`.
    """
    from ..tools.format_data import format_data as formatter

    was_list = isinstance(data, list)
    items = data if was_list else [data]
    formatted = formatter(items, ppca=True)
    rewrapped = [
        pd.DataFrame(np.asarray(arr), index=getattr(orig, 'index', None))
        for arr, orig in zip(formatted, items)
    ]
    return rewrapped if was_list else rewrapped[0]


@dw.decorate.funnel
def _align(data, model='HyperAlign', return_model=False,
           manip=None, normalize=None, reduce=None, ndims=None, cluster=None,
           format_data=True, **kwargs):
    """`@dw.decorate.funnel`-wrapped implementation of `align` (below).

    Split out so the public `align()` can normalize list-SUBCLASS input to
    a plain `list` (GH #209: `datawrangler.wrangle`'s own `type(x) is
    list` check -- rather than `isinstance` -- silently mistreats a list
    subclass as a single opaque object) BEFORE the funnel decorator's
    `wrangle()` call runs, since a decorator cannot be bypassed once
    invoked. See `align`'s docstring for the full parameter/return
    documentation.
    """
    # deprecated align= alias for model= (kept for hyp.plot(align=...) and
    # any other pre-1.0-style caller that never migrated to model=). Since
    # `model` keeps its literal 'HyperAlign' default (required by the
    # signature), "both given" is detected as align= together with a
    # NON-default model= -- passing align= alongside the (unchanged)
    # default is treated as the caller only using the deprecated kwarg.
    if 'align' in kwargs:
        legacy_model = kwargs.pop('align')
        if model != 'HyperAlign':
            raise ValueError(
                "cannot pass both model= and the deprecated align= kwarg; "
                "use model= only (align= is a deprecated alias for it)."
            )
        warnings.warn(
            "align= is deprecated as a model-spec kwarg name on "
            "hypertools.align.align.align; use model= instead (e.g. "
            "hyp.align(data, model='hyper')).",
            DeprecationWarning, stacklevel=2,
        )
        model = legacy_model

    # the funnel coerces a single (non-list) input to a single DataFrame and
    # keeps lists as lists; remember which so the output can match the
    # input's list/single-item shape (like hyp.reduce -- the Aligner always
    # builds a list internally, which used to up-promote single inputs to a
    # list-of-one, F12-align-004)
    was_list = isinstance(data, list)

    # a whole already-fitted Pipeline handed back as model= (e.g. the model
    # from an earlier cross-module return_model=True call) is reused as-is via
    # .transform, BEFORE _resolve_align_spec below -- otherwise unpack_model
    # raises "unknown model: Pipeline" (QC 2026-07). Redundant stage kwargs are
    # warned + ignored (the Pipeline already encodes them).
    from ..core.shared import is_reused_pipeline
    if is_reused_pipeline(model, {'manip': manip, 'normalize': normalize,
                                  'reduce': reduce, 'cluster': cluster}, 'model'):
        result = _match_input_shape(_to_arrays(model.transform(data)), was_list)
        return (result, model) if return_model else result

    resolved = _resolve_align_spec(model, kwargs)

    # cross-module kwargs (#138): assemble and run a Pipeline (in canonical
    # order, #153) instead of the single-stage path below whenever another
    # stage is requested. Lazy import avoids an align<->core.pipeline cycle
    # (core.pipeline itself lazily imports align.align).
    if any(stage is not None for stage in (manip, normalize, reduce, cluster)):
        from ..core.pipeline import build_pipeline
        pipeline = build_pipeline(manip=manip, normalize=normalize,
                                   reduce=reduce, ndims=ndims,
                                   align=resolved, cluster=cluster)
        result = _match_input_shape(pipeline.fit_transform(data), was_list)
        return (result, pipeline) if return_model else result

    if resolved is None:
        # no-op (model=None or model=False): hand the data back unchanged
        # (numerically identical, converted to the array/list shape align
        # always returns) -- no trimming, padding, or format_data pass
        result = _match_input_shape(_to_arrays(data), was_list)
        return (result, None) if return_model else result

    if format_data:
        data = _apply_format_data(data)

    # an already-fitted Aligner (returned from an earlier
    # return_model=True call) is reused via `transform`, never refit
    if isinstance(resolved, Aligner) and resolved.is_fitted:
        result = _match_input_shape(_to_arrays(resolved.transform(data)), was_list)
        return (result, resolved) if return_model else result

    result = _match_input_shape(_to_arrays(resolved.fit_transform(data)), was_list)
    return (result, resolved) if return_model else result


def _match_input_shape(result, was_list):
    """Unwrap a list-of-one `result` when the ORIGINAL input was a single
    (non-list) dataset, so `align(bare_array)` returns a bare array like
    `hyp.reduce`/`hyp.cluster` and align's own Returns docstring promise
    (F12-align-004). List inputs (of any length) are returned untouched."""
    if not was_list and isinstance(result, list) and len(result) == 1:
        return result[0]
    return result


def _to_arrays(result):
    """Convert an `Aligner`'s DataFrame(s) output to numpy array(s) -- the
    classic `hyp.align` API has always returned arrays (see
    `hypertools.tools.align.align`), and every OTHER dispatcher (`reduce`,
    `cluster`, `manip`) returns arrays too."""
    if isinstance(result, list):
        return [np.asarray(r) for r in result]
    return np.asarray(result)


[docs] def align(data, model='HyperAlign', return_model=False, manip=None, normalize=None, reduce=None, ndims=None, cluster=None, format_data=True, **kwargs): """ Aligns a list of datasets into a shared coordinate space. Resolves `model` (and any cross-module stage kwargs) into a fitted `Aligner` and applies it, following the same model-spec grammar as `hypertools.reduce.reduce.reduce`/`hypertools.cluster.cluster.cluster`. Parameters ---------- data : numpy array, pandas/polars DataFrame, or list/tuple of these The datasets to align. Any input format is funneled into DataFrame(s) before dispatch (a tuple of datasets is treated exactly like a list). Rows are matched across datasets by index value and returned in the FIRST dataset's index order; datasets with DUPLICATED index labels keep only the first row per label (with a `UserWarning`), so output rows always match one-to-one across datasets. `None` raises a `TypeError`; an empty list raises a `ValueError`. model : str, class, instance, dict, fitted Aligner, False, or None Alignment algorithm to use. Supported names: `'HyperAlign'` (hyperalignment, Haxby et al. 2011; `'hyper'` is a deprecated alias and emits a `DeprecationWarning` naming the canonical spelling), `'SharedResponseModel'` (`'SRM'` alias), `'DeterministicSharedResponseModel'` (`'DetSRM'` alias), `'RobustSharedResponseModel'` (`'RSRM'` alias), `'Procrustes'`, and `'NullAlign'` (returns the trimmed/padded data unchanged). Can be passed as a string, a bare (uninstantiated) `Aligner` subclass, an already-constructed instance (the classes are importable as e.g. `from hypertools.align import HyperAlign, Procrustes, SharedResponseModel, NullAlign`), the canonical dict spec `{'model': ..., 'args': [...], 'kwargs': {...}}`, or the LEGACY dict spec `{'model': ..., 'params': {...}}` (accepted for backward compatibility, but emits a `DeprecationWarning`). A previously-fitted `Aligner` (as returned by `return_model=True`) is applied via `.transform` instead of being refit. `False` or `None` skips alignment entirely and returns the data unchanged (`model=True` raises a `ValueError` -- name an algorithm instead). (default: `'HyperAlign'`). return_model : bool If True, also return the fitted model: the fitted `Aligner` when only the `align` stage ran, or a fitted `hypertools.Pipeline` when `manip=`/`normalize=`/`reduce=`/`cluster=` made multiple stages run (default: False). manip, normalize, reduce, cluster : model spec or None Cross-module stage kwargs (GH #138): when any of these is given, the other stages also run (via `hypertools.core.pipeline.build_pipeline`), in the canonical order `manip -> normalize -> reduce -> align -> cluster` (GH #153), with this function's own `model=`/`n_iter=`/etc. slotted in at the align stage (default: None for all four, i.e. only `align` runs). ndims : int or None Passed through to the `reduce` stage (as `ndims=`) when `reduce=` is also given. format_data : bool Whether or not to first run the missing-data-fill / text-to-matrix `format_data` pass (default: True). **kwargs Extra keyword arguments forwarded to `model`'s constructor when `model` is a bare registry name/class (e.g. `n_iter=` for `'HyperAlign'`, `features=` for the SRM family). Keyword arguments the model does not accept raise a `TypeError` naming them (they used to be silently ignored, so a typo'd parameter went unnoticed). `align=` is also accepted here as a DEPRECATED alias for `model=` (emits a `DeprecationWarning`; passing both `model=` -- with a non-default value -- and `align=` raises `ValueError`), preserving the classic `hyp.plot(..., align='hyper')`-style call. Returns ------- aligned : list of numpy arrays (or a single array) The aligned data, in the same list/single-item shape as `data` (matching `hyp.reduce`/`hyp.cluster` and the classic `hyp.align` API, which return numpy arrays; note that `hyp.manip` instead returns pandas DataFrames): a list input returns a list, a single bare array/DataFrame returns a single array. Output rows follow the FIRST dataset's index order. If `return_model=True`, an `(aligned, model)` tuple is returned instead. Raises ------ ValueError If `data` is an empty list (nothing to align), if any dataset has more than 2 dimensions (pass a list of 2-D observations-by- features datasets, not a 3-D stack), if the datasets share no common row-index values, or if `model` is an unknown name / `True`. TypeError If `data` is `None`, or a keyword argument is not accepted by the resolved model's constructor (e.g. a misspelled parameter name). Examples -------- >>> import numpy as np >>> import hypertools as hyp >>> rng = np.random.default_rng(0) >>> a = rng.standard_normal((30, 4)) >>> b = a @ np.linalg.qr(rng.standard_normal((4, 4)))[0] # rotated copy >>> aligned = hyp.align([a, b], model='HyperAlign') >>> [d.shape for d in aligned] [(30, 4), (30, 4)] """ from ..core.shared import require_data, no_observations_message # None always raises the unified dispatcher TypeError (it used to leak a # misleading "input has no observations (0 rows)" from format_data -- # 2026-07 release audit, final wave item 9) require_data(data, 'align') if isinstance(data, tuple): # a tuple of datasets is accepted exactly like a list (final wave # item 15: it used to be funneled as one opaque object and die with # the same misleading no-observations error) data = list(data) if isinstance(data, list) and len(data) == 0: # an empty list used to fall through to the TEXT input funnel, # downloading the minipedia corpus and dying with a cryptic # LatentDirichletAllocation error (QC 2026-07, F12-align-006) raise ValueError( no_observations_message('align', 'got an empty list') + ' Pass a list of one or more numeric arrays/DataFrames to ' 'align.') if isinstance(data, list) and type(data) is not list: # GH #209: normalize a list SUBCLASS to a plain `list` before the # funnel-decorated `_align` runs -- see `_align`'s docstring. data = list(data) # reject 3-D (or higher) array input up front (release-1.0 audit, # X3-performance-004): datawrangler's funnel silently FLATTENED a # (slices x time x channels) stack into (slices, time*channels), so a # natural mistake -- passing a 3-D array instead of a list of 2-D # datasets -- ground through a huge dense alignment and returned # meaningless output. Matches reduce/cluster's fail-fast shape checks. _datasets = data if isinstance(data, list) else [data] for _i, _d in enumerate(_datasets): _ndim = getattr(_d, 'ndim', None) if _ndim is None and not isinstance(_d, (str, bytes)): try: _ndim = np.ndim(_d) except Exception: _ndim = None if _ndim is not None and _ndim > 2: _which = f'dataset {_i}' if isinstance(data, list) else 'data' raise ValueError( f'align expects 2-D (observations x features) datasets, ' f'but {_which} has {_ndim} dimensions ' f'(shape {tuple(np.shape(_d))}). Pass a LIST of 2-D ' 'arrays/DataFrames (e.g. one per subject) instead of a ' 'higher-dimensional stack -- e.g. list(x) for a 3-D ' 'array x.') return _align(data, model=model, return_model=return_model, manip=manip, normalize=normalize, reduce=reduce, ndims=ndims, cluster=cluster, format_data=format_data, **kwargs)