Source code for andrey.metrics.latent

"""Latent-variable metrics: how well an inferred latent clustering recovers the true one.

Latent-cluster methods (GIN) group observed variables under shared latent parents. These score that
grouping with the adjusted Rand index, plus a count of recovered latents. Latents are the nodes a
structure flags ``LATENT`` (``node_types``); each observed node's cluster is its latent parent.
"""

from __future__ import annotations

import numpy as np

from andrey.core.output import StructureOutput
from andrey.core.structure import LATENT, GraphStructure

from ._common import directed_adjacency


def _graph(x: object) -> GraphStructure:
    """Unwrap a StructureOutput to its GraphStructure (latent info is in ``node_types``)."""
    if isinstance(x, StructureOutput):
        x = x.structure
    if not isinstance(x, GraphStructure):
        raise TypeError("latent metrics need a GraphStructure with node_types")
    return x


def _clusters(g: GraphStructure) -> dict[int, object]:
    """Map each observed node to a cluster key: the frozenset of its latent parents.

    A merged cluster (an observed node under two latents) keys on the whole parent set, so it stays
    a single well-defined cluster. An observed node with no latent parent gets a unique singleton
    key, so unrelated unclustered nodes are not conflated.
    """
    marks = g.to_numpy()
    types = g.node_types
    latent = types == LATENT if types is not None else np.zeros(g.n_nodes, dtype=bool)
    parents_of = directed_adjacency(marks).T  # parents_of[o] = nodes pointing into o
    clusters: dict[int, object] = {}
    for o in np.nonzero(~latent)[0]:
        latent_parents = np.nonzero(parents_of[o] & latent)[0]
        clusters[int(o)] = (
            frozenset(int(p) for p in latent_parents)
            if latent_parents.size
            else ("singleton", int(o))
        )
    return clusters


[docs] def adjusted_rand_index(labels_a: object, labels_b: object) -> float: """Adjusted Rand index between two label vectors (chance-corrected; 1.0 is identical). Examples -------- >>> adjusted_rand_index([0, 0, 1, 1], [1, 1, 0, 0]) 1.0 """ a = np.asarray(labels_a) b = np.asarray(labels_b) if a.shape != b.shape: raise ValueError(f"label-length mismatch: {a.shape} vs {b.shape}") n = a.shape[0] if n < 2: # 0 or 1 elements: the partition is trivially perfect (matches sklearn) return 1.0 _, ai = np.unique(a, return_inverse=True) _, bi = np.unique(b, return_inverse=True) contingency = np.zeros((ai.max() + 1, bi.max() + 1), dtype=np.int64) np.add.at(contingency, (ai, bi), 1) def comb2(counts: np.ndarray) -> np.int64: return (counts * (counts - 1) // 2).sum() sum_cells = comb2(contingency) sum_rows = comb2(contingency.sum(axis=1)) sum_cols = comb2(contingency.sum(axis=0)) total = n * (n - 1) // 2 expected = sum_rows * sum_cols / total maximum = (sum_rows + sum_cols) / 2 if maximum == expected: # both clusterings trivial (all-together or all-singletons) return 1.0 return float((sum_cells - expected) / (maximum - expected))
[docs] def latent_cluster_ari(estimated: object, true: object) -> float: """Adjusted Rand index of the latent clustering of the observed variables. Clusters each observed node by its latent parent, then scores the two partitions. The two graphs must describe the same observed variables (same indices and labels), though their latent counts may differ; a mismatch raises. Returns 1.0 when there are no observed nodes. Examples -------- >>> from andrey.core import GraphStructure, LATENT, OBSERVED >>> import numpy as np >>> # node 2 is a latent parent of observed 0 and 1 >>> m = np.array([[0, 0, 2], [0, 0, 2], [1, 1, 0]], dtype=np.int8) >>> types = np.array([OBSERVED, OBSERVED, LATENT], dtype=np.int8) >>> g = GraphStructure.from_numpy(m, kind="dag", node_types=types) >>> latent_cluster_ari(g, g) 1.0 """ graph_e, graph_t = _graph(estimated), _graph(true) clusters_e = _clusters(graph_e) clusters_t = _clusters(graph_t) observed = sorted(clusters_e) if set(clusters_e) != set(clusters_t): raise ValueError("latent graphs describe different observed variables") _check_observed_labels(graph_e, graph_t, observed) if not observed: return 1.0 return adjusted_rand_index(_int_labels(clusters_e, observed), _int_labels(clusters_t, observed))
def _check_observed_labels(ge: GraphStructure, gt: GraphStructure, observed: list[int]) -> None: """Raise if the two graphs label a shared observed variable differently.""" if ge.labels is None or gt.labels is None: return if any(ge.labels[i] != gt.labels[i] for i in observed): raise ValueError("latent graphs label observed variables differently") def _int_labels(clusters: dict[int, object], nodes: list[int]) -> list[int]: """Integer partition labels for ``nodes``: one id per distinct cluster key (ARI ignores ids).""" ids: dict[object, int] = {} return [ids.setdefault(clusters[node], len(ids)) for node in nodes]
[docs] def number_of_latents(graph: object) -> int: """Count of nodes flagged ``LATENT`` (0 when ``node_types`` is unset). Examples -------- >>> import numpy as np >>> from andrey.core import GraphStructure, LATENT, OBSERVED >>> m = np.array([[0, 0, 2], [0, 0, 2], [1, 1, 0]], dtype=np.int8) >>> types = np.array([OBSERVED, OBSERVED, LATENT], dtype=np.int8) >>> number_of_latents(GraphStructure.from_numpy(m, kind="dag", node_types=types)) 1 """ g = _graph(graph) return 0 if g.node_types is None else int((g.node_types == LATENT).sum())