Source code for psp.clobe.brutes

"""The patterns a measure with given values can produce, by brute force."""

from __future__ import annotations

from collections.abc import Sequence
from dataclasses import dataclass

import torch

from ._utils import (
    _CHUNK,
    _DIGITS_PER_KEY,
    _MAX_CELLS,
    TOLERANCE,
    Ordering,
    _check_thresholds,
    _classify,
    _is_weak,
    _pack,
    _to_matrices,
    _tolerance,
    _unique_keys,
    _unpack,
)

__all__ = ["brutes", "BrutesResult"]


[docs] @dataclass(frozen=True) class BrutesResult: """The result of :func:`brutes`. Attributes ---------- n Number of unique patterns. inequality_matrices The unique patterns, ``(n, k, k)`` int8, or ``None`` if not requested. permutations Every assignment of the dependent values to the stimuli, ``(m^k, k)`` float64, the last stimulus changing fastest, or ``None`` if not requested. """ n: int inequality_matrices: torch.Tensor | None permutations: torch.Tensor | None
[docs] def brutes( dependent: torch.Tensor | Sequence[float], length: int, thresholds: Sequence[float] | torch.Tensor, ordering: Ordering = "semiorder", *, return_matrices: bool = True, return_permutations: bool = False, inclusive: bool = False, device: torch.device | str | None = None, chunk_size: int = _CHUNK, ) -> BrutesResult: """The patterns a measure with the given values can produce, by brute force. Assigns the ``m`` values of ``dependent`` to ``length`` stimuli in every one of the ``m^length`` ways, with repetition, and collects the unique patterns :func:`imac` gives them. This is the universal set when the measurement scale is too coarse to realise every semiorder (or weak ordering). Parameters ---------- dependent The values the measure can take, e.g. ``torch.linspace(0, 1, 11)``. Compared in float64, with the tolerance of their own dtype (see :func:`imac`). length Number of stimuli. thresholds, ordering, inclusive As in :func:`imac`. return_matrices Return the patterns; otherwise only their number. return_permutations Also return every assignment of values to stimuli. device Where to do the work, e.g. ``"cuda"``; the results are returned on the CPU. chunk_size Assignments evaluated at once; lower it if the device runs out of memory. Examples -------- >>> [brutes(torch.linspace(0, 1, 11), k, (0.1, -0.1)).n for k in range(1, 5)] [1, 3, 19, 183] >>> brutes(torch.tensor([0.0, 1.0, 2.0]), 4, (0.5, -0.5), "weak").n # at most 3 levels 51 """ values = torch.as_tensor(dependent).reshape(-1) tolerance = _tolerance(values.dtype) if values.dtype.is_floating_point else TOLERANCE values = values.to(torch.float64) if values.numel() == 0: raise ValueError("'dependent' must not be empty") if isinstance(length, bool) or not isinstance(length, int) or length < 1: raise ValueError(f"'length' must be a whole number of at least 1, got {length!r}") upper, lower = _check_thresholds(thresholds) weak = _is_weak(ordering, inclusive) if chunk_size < 1: raise ValueError("'chunk_size' must be at least 1") m, k = values.numel(), length total = m**k if total >= 2**63: raise ValueError("There are too many permutations (len(dependent) ** length) to enumerate") if return_permutations and total * k > _MAX_CELLS: raise ValueError("There are too many permutations to return; use return_permutations=False") # classify every pair of dependent values once, on the CPU in float64, so that the # patterns do not depend on the device difference = values.unsqueeze(1) - values.unsqueeze(0) classes = _classify(difference, upper, lower, weak, tolerance, inclusive) classes = classes.to(torch.int64).to(device) cells = k * (k - 1) // 2 if cells <= _DIGITS_PER_KEY: found = _keys_by_stimulus(classes, k, chunk_size) else: found = _keys_by_pair(classes, k, chunk_size) permutations = None if return_permutations: permutations = torch.cat( [values[_digits(begin, min(begin + chunk_size, total), m, k, values.device)] for begin in range(0, total, chunk_size)] ) # fmt: skip return BrutesResult( n=int(found.shape[0]), inequality_matrices=_to_matrices(_unpack(found, cells), k).cpu() if return_matrices else None, permutations=permutations, )
def _digits(begin: int, end: int, m: int, k: int, device: torch.device) -> torch.Tensor: """Assignments ``begin`` to ``end - 1`` as ``(end - begin, k)`` indices of values: the assignment's number written in base ``m``, the last stimulus the least significant digit.""" rest = torch.arange(begin, end, device=device) digits = torch.empty((rest.numel(), k), dtype=torch.int64, device=device) for column in reversed(range(k)): digits[:, column] = rest % m rest = rest // m return digits def _keys_by_pair(classes: torch.Tensor, k: int, chunk_size: int) -> torch.Tensor: """Sorted unique keys of every assignment, classifying all pairs of each; any ``k``.""" m = classes.shape[0] i, j = torch.triu_indices(k, k, offset=1, device=classes.device) found = None for begin in range(0, m**k, chunk_size): digits = _digits(begin, min(begin + chunk_size, m**k), m, k, classes.device) keys = _unique_keys(_pack(classes[digits[:, i], digits[:, j]])) found = keys if found is None else _unique_keys(torch.cat([found, keys])) assert found is not None return found def _not_in(keys: torch.Tensor, known: torch.Tensor) -> torch.Tensor: """The ``keys`` that are not in the sorted ``known``, by binary search.""" if known.numel() == 0: return keys at = torch.searchsorted(known, keys).clamp(max=known.numel() - 1) return keys[known[at] != keys] def _keys_by_stimulus(classes: torch.Tensor, k: int, chunk_size: int) -> torch.Tensor: """Sorted unique keys of every assignment, for patterns that fit one key (``k <= 9``). Builds the keys one stimulus at a time, with the cells grouped by the later stimulus of each pair: adding stimulus ``j`` with value ``v`` appends the ``j`` cells ``classes[d_0, v], ..., classes[d_{j-1}, v]``. For each prefix of an assignment, ``ahead`` holds those digits for every possible ``v``, so extending a prefix costs one multiply-add instead of classifying ``j`` pairs. The keys are put in the R package's row by row order at the end. """ m, device = classes.shape[0], classes.device digit = classes + 1 # digit[a, b]: the cell of a pair whose values are a, b # the last ``tail`` stimuli are expanded as tensors, the first ``k - tail`` are looped over tail = 1 while tail < k and m ** (tail + 1) <= chunk_size: tail += 1 head = k - tail batch = max(1, chunk_size // m**tail) known = torch.empty(0, dtype=torch.int64, device=device) # sorted pending: list[torch.Tensor] = [] waiting = 0 for begin in range(0, m**head, batch): prefix = _digits(begin, min(begin + batch, m**head), m, head, device) key = torch.zeros(prefix.shape[0], dtype=torch.int64, device=device) ahead = torch.zeros((prefix.shape[0], m), dtype=torch.int64, device=device) for j in range(head): # stimuli whose values are given by the prefix v = prefix[:, j] key = key * 3**j + ahead.gather(1, v[:, None])[:, 0] ahead = ahead * 3 + digit[v] for j in range(head, k): # every value of each remaining stimulus key = (key[:, None] * 3**j + ahead).reshape(-1) if j < k - 1: ahead = (ahead[:, None, :] * 3 + digit[None]).reshape(-1, m) new = torch.unique(_not_in(key, known)) pending.append(new) waiting += new.numel() # most keys are already known once the search has gone a while, so merges are rare if waiting >= max(chunk_size, known.numel()): known = torch.unique(torch.cat([known, *pending])) pending, waiting = [], 0 grouped = torch.unique(torch.cat([known, *pending])) # from cells grouped by the later stimulus to the R package's row by row order by_later = [(i, j) for j in range(k) for i in range(j)] by_row = [(i, j) for i in range(k) for j in range(i + 1, k)] order = torch.tensor( [by_later.index(pair) for pair in by_row], dtype=torch.int64, device=device ) return _unique_keys(_pack(_unpack(grouped[:, None], len(by_later))[:, order]))