Changepoints#

Changepoint detection finds candidate behavioural boundaries in kinematic, audio, and spectral time series. Ethograph exposes detectors (binary masks aligned to the signal’s time axis), merging and snapping utilities, and feature generators that turn changepoints into model-ready inputs.

See Changepoints for the conceptual overview and the changepoint features section below for the representations used by downstream segmentation models.


Detection#

Detectors consume a 1-D signal and return a binary (0/1) mask of the same length. NaN boundaries in the input are always added as changepoints so that valid/invalid transitions are not lost.

ethograph.features.changepoints.find_peaks_binary(x, **kwargs)[source]#

scipy.signal.find_peaks + NaN boundaries -> binary mask.

ethograph.features.changepoints.find_troughs_binary(x, **kwargs)[source]#

Find troughs (local minima) + NaN boundaries -> binary mask.

ethograph.features.changepoints.find_nearest_turning_points_binary(x, threshold=1, max_value=None, prominence=0.5, distance=2, **kwargs)[source]#

Convert a 1D signal into a binary mask marking boundaries of peak regions.

Identifies peaks in the signal, then finds the nearest “turning points” (where the gradient is near zero) on either side of each peak. These turning points define the boundaries of peak regions. The result is a binary mask where 1 indicates a turning-point boundary.

The algorithm works in four steps:
  1. Compute the gradient of x and find indices where |gradient| < threshold, treating these as candidate turning points (near-stationary regions).

  2. Find peaks in x using scipy.signal.find_peaks with any additional kwargs.

  3. For each peak, select the closest turning point to its left and right.

  4. Add boundaries at NaN transitions in the original signal.

Parameters:
  • x – Input 1D signal.

  • threshold – Maximum absolute gradient value to qualify as a turning point. Lower values select only very flat regions. Default is 1.

  • max_value – If set, discard turning points where x exceeds this value. Useful for ignoring turning points on high plateaus.

  • **kwargs – Passed to scipy.signal.find_peaks (e.g. height, distance, prominence).

Returns:

Binary array of same length as x, with 1 at turning-point boundaries and NaN-transition boundaries, 0 elsewhere.

ethograph.features.changepoints.add_NaN_boundaries(arr, changepoints)[source]#

Merge NaN-transition boundaries with other changepoints -> binary mask.

To attach detectors to a xarray.Dataset or a pynapple object:

ethograph.io.dataset.add_changepoints_to_ds(ds, target_feature, changepoint_name, changepoint_func, **func_kwargs)[source]#

Detect changepoints in a feature and store them in the dataset.

Applies changepoint_func independently along every non-time dimension (e.g. per keypoint, per individual) using xarray.apply_ufunc() with vectorize=True. The result is an int8 binary array (1 = changepoint, 0 = not) stored as ds["{target_feature}_{changepoint_name}"].

Parameters:
  • ds (xarray.Dataset) – Trial dataset containing target_feature.

  • target_feature (str) – Name of the variable to run detection on (e.g. "speed").

  • changepoint_name (str) – Suffix for the output variable name. The stored variable will be called "{target_feature}_{changepoint_name}" (e.g. "speed_troughs").

  • changepoint_func (callable) – A function f(x, **kwargs) -> array[int8] that takes a 1-D numpy array and returns a same-length binary indicator.

  • **func_kwargs – Forwarded to changepoint_func.

Returns:

The input dataset with the changepoint variable added in place.

Return type:

xarray.Dataset

Examples

>>> import ethograph as eto
>>> from ethograph.features.changepoints import find_troughs_binary
>>> dt = eto.open("experiment.nc")
>>> ds = dt.itrial(0)
>>> ds = eto.add_changepoints_to_ds(
...     ds,
...     target_feature="speed",
...     changepoint_name="troughs",
...     changepoint_func=find_troughs_binary,
...     prominence=0.3,
... )
>>> ds["speed_troughs"]
<xarray.DataArray 'speed_troughs' (time: 9000, keypoint: 7)>
ethograph.io.pynapple.add_changepoints_to_nap(data, target_feature, changepoint_func, **func_kwargs)[source]#

Detect changepoints in a pynapple time series and return them as a TsGroup.

Applies changepoint_func independently to each unit or column in data. Returns a nap.TsGroup where each unit contains the changepoint timestamps for one source series. Source metadata (label, feature name, type) is stored as group metadata columns; if data is itself a TsGroup, its metadata columns are forwarded as well.

Parameters:
  • data (nap.Tsd | nap.TsdFrame | nap.TsGroup) – Input time series. For a Tsd a single unit is produced; for a TsdFrame one unit per column; for a TsGroup one unit per neuron/unit.

  • target_feature (str) – Human-readable label recorded in the output metadata (e.g. "speed").

  • changepoint_func (callable) – A function f(x, **kwargs) -> array that accepts a 1-D numpy array of values and returns an array of changepoint times (or a binary indicator of the same length).

  • **func_kwargs – Forwarded to changepoint_func.

Returns:

One unit per input series, containing changepoint timestamps.

Return type:

nap.TsGroup

Examples

>>> import ethograph as eto
>>> from ethograph.features.changepoints import find_troughs_binary
>>> data = eto.load_nap_data("experiment.nwb")
>>> cp_group = eto.add_changepoints_to_nap(
...     data["speed"],
...     target_feature="speed",
...     changepoint_func=find_troughs_binary,
...     prominence=0.3,
... )

Merging and time extraction#

Changepoints stored as kind="changepoint_feature" masks can be merged into a single boolean mask, or read as times (seconds) at a given keypoint / individual selection. changepoint_fired is the one reading the GUI has: the lineplot draws it and a click snaps to it, so drawn and snapped agree.

ethograph.features.changepoints.merge_changepoints(ds, vars=None, keep_dims=None)[source]#

Merge changepoint variables in a dataset into a single boolean mask.

Combines every raw changepoint mask (attrs["changepoint_mask"]; see ethograph.io.schema.is_changepoint()) using logical OR across all non-time dimensions — the smooth expansions of a mask are ordinary features and are not merged. All masks must share the same target_feature attribute.

Parameters:
  • ds (xr.Dataset) – Dataset containing one or more changepoint variables.

  • vars (sequence of str, optional) – Which changepoint masks to merge; default merges every one changepoint_vars() finds. Naming a variable that is not a changepoint mask is an error.

  • keep_dims (sequence of str, optional) – Dims to leave standing instead of ORing across — typically the individual dim, so one animal’s changepoints do not leak into another’s. Default collapses every non-time dim.

Returns:

  • ds (xr.Dataset) – Copy of the input with a new "changepoints" DataArray (float 0/1) replacing the merged changepoint variables.

  • target_feature (str) – The shared target_feature attribute from the input variables.

Raises:

ValueError – If changepoint variables reference different target features.

ethograph.features.changepoints.changepoint_fired(mask, selections=None)[source]#

Where mask fires at selections: a boolean (T,) array on the mask’s own time axis.

This is the one reading of a changepoint mask the GUI has. The lineplot draws it (XarrayLoader.select) and a click snaps to it (XarrayLoader.get_cp_times), so what is drawn and what is snapped to are the same set by construction.

A selection key that is a dim of the mask pins that dim — .sel where the dim has a coordinate, .isel where it does not (the rule of ethograph.utils.xr_utils.sel_valid()). Every other key is ignored, and every non-time dim left free is OR’d across.

Return type:

ndarray

ethograph.features.changepoints.changepoint_mask_times(mask, selections=None)[source]#

Times at which mask fires at selections, read off the mask’s own time coordinate.

Return type:

ndarray

ethograph.features.changepoints.dataset_changepoint_times(ds, feature=None, selections=None)[source]#

Sorted, unique times of every changepoint mask in ds — of feature only when given.

Masks that target different features are simply unioned; there is no merge step to refuse them. Empty when nothing matches.

Return type:

ndarray


Label correction#

Snap interval-based labels to nearby changepoints. The full pipeline (correct_changepoints) runs purge → stitch → snap → purge; see Changepoint correction for parameter guidance.

ethograph.features.changepoints.correct_changepoints(df, cp_times, min_duration_s, stitch_gap_s, max_expansion_s, max_shrink_s, label_thresholds_s=None, do_purge=True, do_stitch=True, do_snap=True, do_purge_after=True)[source]#

Full interval-native correction pipeline.

Return type:

DataFrame

Steps:
  1. purge_short_intervals — pre-cleanup (do_purge)

  2. stitch_intervals — merge same-label across small gaps (do_stitch)

  3. snap_boundaries — snap to changepoint times (do_snap)

  4. purge_short_intervals — post-cleanup (do_purge_after)

ethograph.features.changepoints.correct_changepoints_automatic(df, min_duration_s=0.001, stitch_gap_s=0.0)[source]#

Lightweight cleanup used while manually creating labels.

Return type:

DataFrame


Changepoint features#

Once changepoints have been curated, they are converted into learnable features for action- and audio-segmentation models (transformers, MS-TCN, DLC2Action, ASFormer, …). Three complementary representations let the model pick up changepoint structure at different scales:

Feature

Example (changepoints at t=4 and t=8)

Binary changepoints — exact positions

0 0 0 0 1 0 0 0 1 0 0 0 0 0

Smooth changepoints — proximity to nearest changepoint

0 0 0 .3 1 .3 0 .3 1 .3 0 0 0 0

Segment IDs — unique ID per inter-changepoint region

0 0 0 0 1 1 1 1 2 2 2 2 2 2

Smooth changepoints use a Laplacian kernel centred at each changepoint index \(i\):

\[\text{smooth}(t) = \sum_i \exp\!\left(-\frac{|t - i|}{\sigma}\right)\]

Laplacian peaks are narrow (so they pinpoint the changepoint) but have long tails (so they remain visible from far away). Passing several sigmas — e.g. [0.5, 3, 5] — yields a multi-scale view.

Weighted variants emphasise changepoints where a target signal x (typically speed) is low, via \(\exp(-x / (\bar{x} + \epsilon))\). This helps models distinguish speed minima before/after movements from minima occurring within a movement.

ethograph.features.changepoints.more_changepoint_features(changepoint_binary, sigmas, distribution='laplacian', horizon=None, scale=None, max_length=None)[source]#

Create changepoint-based features from a binary changepoint array.

Four column groups, in this order (see CP_TRANSFORMS):

  • Binary — the exact changepoint positions (0/1 mask). Example: 0 0 0 0 1 0 0 0 1 0 0 0 0 0

  • Proximity — one column per sigma: a Laplacian (or Gaussian) kernel centred at each changepoint, summed. An isolated changepoint reads 1 at its frame; overlapping kernels add, so a cluster of candidates reads above 1. Nothing is normalised per trial. Example (one sigma): 0 0 0 .3 1 .3 0 .3 1 .3 0 0 0 0

  • Offset — two columns: samples since the previous changepoint and until the next, each clipped at horizon and scaled to [0, 1]. A frame reads which side of its nearest candidate it is on, which the symmetric kernels cannot say; before the first / after the last changepoint the column is saturated (no candidate in reach). Example (horizon 4): since 1 1 1 1 0 .25 .5 .75 0 .25 .5 .75 1 1, until 1 .75 .5 .25 0 .75 .5 .25 0 1 1 1 1 1

  • Length — one column: the length of the candidate segment the frame sits in (cut at every changepoint and at the trial’s ends), as log1p(length) / log1p(max_length), clipped at 1. The offsets already resolve anything shorter than two horizons; this is the long range, where a fragment-prone short segment and a whole bout of rest should not read alike.

Proximity uses a Laplacian kernel by default:

\[\text{prox}(t) = \sum_i \exp\!\left(-\frac{|t - i|}{\sigma}\right)\]

where \(i\) are the changepoint indices and \(\sigma\) controls the peak width. Laplacians have a narrow peak that points directly at the changepoint while their long tails remain visible from far away. Passing multiple sigmas (e.g. [0.5, 3, 5]) yields features at several scales.

scale multiplies every proximity column by \(\exp(-x / \bar{x})\) of that signal, emphasising changepoints where it is low — with speed, the troughs before and after a movement rather than a dip inside one; with an amplitude envelope, the silences between calls. Frames where scale is NaN read 0.

Parameters:
  • changepoint_binary (np.ndarray) – Binary (0/1) array marking changepoint locations.

  • sigmas (Sequence[float]) – Kernel widths (samples) for the proximity columns.

  • distribution (Literal[‘gaussian’, ‘laplacian’]) – "laplacian" (default) or "gaussian" kernel.

  • horizon (float | None) – Reach of the offset columns, in samples; None is default_horizon().

  • scale (np.ndarray | None) – Optional signal on the same time axis that scales the proximity columns.

  • max_length (float | None) – Where the length column saturates, in samples; None is LENGTH_HORIZONS horizons.

Returns:

the binary mask, one proximity column per sigma, since, until, then length.

Return type:

2D array of shape (T, 1 + len(sigmas) + 3)


Storage format#

Kinematic changepoints are stored as binary (int8) DataArrays sharing their feature’s time axis, tagged with kind = "changepoint_feature" (plus the legacy attrs["type"] = "changepoints") and attrs["target_feature"]. Audio changepoints, which would be prohibitively large at audio sample rates, are stored instead as audio_cp_onsets / audio_cp_offsets float pairs. See Kinematic changepoints and Audio changepoints for examples.