spine¶

Partition data into scale strata. One call: raw data in, strata out.

You supply a measure and an orientation. Those are the scientific input, and neither has a default. Everything after them is computed.

Syntax and options¶

Read from the function itself, so this page can never disagree with help(spine).

from entropy_spine import spine
help(spine)
Help on function spine in module entropy_spine.spine:

spine(
    data,
    measure=None,
    orientation=None,
    *,
    objects: 'str' = 'rows',
    k: 'int' = 5,
    object_ids=None,
    missing: 'str' = 'error',
    thin: 'bool' = False,
    **core_options
)
    spine: apply SPINE to raw data in one call.

        scores, result = spine(data, measure, orientation)
        scores, result = spine(data, measure, orientation, k=8, missing="omit")

    ``measure`` and ``orientation`` are both required. They are the scientific
    input: SPINE does not know what your data means, and choosing them is the
    modelling step. Neither has a default.

    Accepted data
        1-D array or list          one value per object
        2-D array                  rows are objects (``objects="columns"`` to flip)
        sequence of dicts          one dict per object; numeric scalar values are
                                   the features, a text value the identifiers
        pandas DataFrame           numeric columns are the features, the index or
                                   a text column the identifiers (pandas optional)
        text sequence              statefrequency only: each entry one observed state

    Options
        objects           "rows" (default) or "columns", for 2-D arrays
        k                 neighbour count for the knn measure (default 5)
        object_ids        identifiers, overriding anything found in the data
        missing           "error" (default) or "omit"
        thin              locate the cut on a deterministically thinned copy
        boundary_mode, profile_weights, bilateral_anchor
                          passed to the reference core unchanged

    Returns ``(scores, result)``: one score per object in YOUR object order, and a
    ``Result`` record. ``result.boundary_values`` is the transferable result — a
    value in your own measure's units, applied to new data or quoted in a paper.
    ``result.info`` is the complete reference record, for auditing; its ranks are
    one-based (the cross-language schema), while Result-level indices such as
    ``result.order`` and ``result.omitted`` are zero-based Python indices.

A first call¶

Two hundred objects from a standard normal cloud, and twelve sitting a long way out. Nothing tells SPINE that there are two groups.

%matplotlib inline
import numpy as np
from entropy_spine import spine, spine_summary

rng = np.random.default_rng(11)
X = np.vstack([rng.standard_normal((200, 2)),
               rng.standard_normal((12, 2)) + 9])

scores, result = spine(X, "dist2mean", "upper")
spine_summary(result);
SPINE   212 objects, 2 strata        measure dist2mean | orientation upper

  stratum  objects        measure range        score
        1      200      0.01647 to 3.423        0.000
        2       12        10.96 to 13.68        1.000

  cut at 3.423
  result.boundary_values applies these cuts to new data.

scores runs from 0 for the typical stratum to 1 for the most atypical, one value per object, in your object order.

from entropy_spine import spine_data_plot
spine_data_plot(result, title="a bulk and a far group");
No description has been provided for this image

How it works, in one picture¶

SPINE sorts your measure, then walks up it accumulating an incremental nested entropy: at each rank, how much uncertainty the prefix of objects seen so far carries. Adding another object of the same scale increases it. Adding an object from a genuinely larger scale does not: it concentrates the distribution, and the profile turns over.

A cut is placed where the profile turns over. That is the whole idea. There is no threshold and no target number of groups; the entropy-supported transition either happens or it does not.

import matplotlib.pyplot as plt

info = result.info
E = info["E"]
C = np.asarray(info["candidates"])
B = np.asarray(info["boundaries"])

fig, ax = plt.subplots(figsize=(6.4, 3.1))
ax.plot(np.arange(1, E.size + 1), E, lw=1.4, color="#0072B2", label="profile")
ax.plot(C, E[C - 1], "o", ms=7, mfc="none", mec="#D55E00", mew=1.4,
        label="candidate transition")
ax.plot(B, E[B - 1], "o", ms=7, color="#D55E00", label="retained cut")
ax.set_xlabel("rank in the sorted measure")
ax.set_ylabel("incremental nested entropy")
ax.legend(loc="lower right", frameon=False)
ax.spines[["top", "right"]].set_visible(False);
No description has been provided for this image

The turnover sits at rank 200 of 212, which is exactly where the twelve planted far objects begin. Nothing told SPINE there were two groups, or where to look.

print(f"cut placed at rank {info['boundaries'][0]} of {E.size}")
cut placed at rank 200 of 212

The cut is the transferable result¶

result.boundary_values is a value in your own measure's units, so it can be applied to new data or quoted in a paper. info["boundaries"], by contrast, are ranks into an internally sorted copy and mean nothing outside the call.

result.boundary_values
array([3.42303034])

Any container you already have¶

A numeric array, a sequence of dicts, or a pandas DataFrame. Identifiers carried by a DataFrame's index or a dict's text field come back in result.object_ids.

import pandas as pd

rng = np.random.default_rng(8)
sites = np.vstack([rng.standard_normal((55, 3)),
                   rng.standard_normal((5, 3)) + 7])
df = pd.DataFrame(sites, columns=["x", "y", "z"],
                  index=[f"site{i:02d}" for i in range(1, 61)])

s, r = spine(df, "robustrownorm", "upper")
[r.object_ids[i] for i in np.flatnonzero(s > 0)]
['site56', 'site57', 'site58', 'site59', 'site60']

Missing values¶

Real data has gaps. The default refuses rather than guessing.

Xm = X.copy()
Xm[3, 1] = np.nan
Xm[150, 0] = np.nan
try:
    spine(Xm, "dist2mean", "upper")
except Exception as err:
    print(err)
X must be finite (no NaN or Inf).

missing="omit" drops those objects and reports them. Their scores come back as NaN, so the result still lines up with your rows: row 150 is still row 150.

sm, rm = spine(Xm, "dist2mean", "upper", missing="omit")
print(f"omitted rows {rm.omitted.tolist()}, "
      f"scores returned {sm.size} for {Xm.shape[0]} input rows")
omitted rows [3, 150], scores returned 212 for 212 input rows

Very large collections¶

A profile that cannot be resolved at binary64 precision is refused rather than approximated. This follows the particular values, not the object count. When it happens, thin=True locates the cut on a deterministically thinned copy and applies it to every object; result.thinned records how many values were retained.

See also¶

spine_summary for what a partition found, spine_compare to check whether a different measure would have said something different, spine_measure for the ten named measures, and spine_data_plot and spine_plot for the figures.