Skip to content

Public API

For mathematical definitions of the main estimands, transformations, studentization rules, and calibration statistics, see the function → equation index and implementation-matched mathematical reference. For decision flow, use the workflow atlas; for representative rendered outputs, see the visual gallery.

Multivariate surrogate testing

eyetrajectoriespy.MultivariateIAAFTResult dataclass

Cross-spectrum-aware multivariate IAAFT surrogate ensemble.

Source code in src/eyetrajectoriespy/nonlinear_types.py
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
@dataclass(frozen=True)
class MultivariateIAAFTResult:
    """Cross-spectrum-aware multivariate IAAFT surrogate ensemble."""

    surrogates: np.ndarray
    source_values: np.ndarray
    curve_id: str
    dimension_names: tuple[str, ...]
    reference_dimension: str
    dimension_pairs: tuple[tuple[str, str], ...]
    convergence_iterations: np.ndarray
    spectral_errors: np.ndarray
    cross_spectral_errors: np.ndarray
    n_surrogates: int
    max_iterations: int
    tolerance: float
    random_state: int | None
    provenance: Mapping[str, Any] = field(default_factory=dict)

    @property
    def n_time(self) -> int:
        return self.surrogates.shape[1]

    @property
    def n_dimensions(self) -> int:
        return self.surrogates.shape[2]

eyetrajectoriespy.MultivariateSurrogateNonlinearityResult dataclass

Monte Carlo nonlinear-statistic test using multivariate IAAFT surrogates.

Source code in src/eyetrajectoriespy/nonlinear_types.py
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
@dataclass(frozen=True)
class MultivariateSurrogateNonlinearityResult:
    """Monte Carlo nonlinear-statistic test using multivariate IAAFT surrogates."""

    observed_statistic: float
    surrogate_statistics: np.ndarray
    p_value: float
    alternative: str
    statistic: str
    surrogate_result: MultivariateIAAFTResult
    provenance: Mapping[str, Any] = field(default_factory=dict)

    @property
    def n_surrogates(self) -> int:
        return self.surrogate_result.n_surrogates

eyetrajectoriespy.generate_multivariate_iaaft_surrogates

generate_multivariate_iaaft_surrogates(trajectories: TrajectorySet, *, curve: int | str, dimensions: Sequence[str], reference_dimension: str, n_surrogates: int = 199, max_iterations: int = 1000, tolerance: float = 1e-08, random_state: int | None = None) -> MultivariateIAAFTResult

Generate cross-spectrum-aware multivariate IAAFT surrogates.

The implementation follows the Prichard-Theiler multivariate phase constraint extended through an IAAFT rank-remapping loop. At each Fourier adjustment, the original inter-channel phase differences are imposed relative to an explicitly declared reference dimension while every channel receives its original Fourier amplitudes. Rank remapping then restores each channel's empirical marginal distribution exactly.

Because rank remapping perturbs the spectrum, the final power spectra and cross-spectra are approximate and their relative errors are retained for every surrogate. The reference dimension is never selected automatically.

Source code in src/eyetrajectoriespy/multivariate_surrogates.py
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
def generate_multivariate_iaaft_surrogates(
    trajectories: TrajectorySet,
    *,
    curve: int | str,
    dimensions: Sequence[str],
    reference_dimension: str,
    n_surrogates: int = 199,
    max_iterations: int = 1000,
    tolerance: float = 1e-8,
    random_state: int | None = None,
) -> MultivariateIAAFTResult:
    """Generate cross-spectrum-aware multivariate IAAFT surrogates.

    The implementation follows the Prichard-Theiler multivariate phase
    constraint extended through an IAAFT rank-remapping loop. At each Fourier
    adjustment, the original inter-channel phase differences are imposed
    relative to an explicitly declared reference dimension while every channel
    receives its original Fourier amplitudes. Rank remapping then restores each
    channel's empirical marginal distribution exactly.

    Because rank remapping perturbs the spectrum, the final power spectra and
    cross-spectra are approximate and their relative errors are retained for
    every surrogate. The reference dimension is never selected automatically.
    """

    validate_trajectory_set(trajectories, require_complete=True)
    regular_step = _regular_step(trajectories.time)
    dimension_names, dimension_indices = _validate_dimensions(
        trajectories,
        dimensions,
    )
    if reference_dimension not in dimension_names:
        raise ValueError(
            "reference_dimension must be one of the declared dimensions"
        )
    if (
        isinstance(n_surrogates, bool)
        or not isinstance(n_surrogates, (int, np.integer))
        or int(n_surrogates) < 1
    ):
        raise ValueError("n_surrogates must be a positive integer")
    if (
        isinstance(max_iterations, bool)
        or not isinstance(max_iterations, (int, np.integer))
        or int(max_iterations) < 1
    ):
        raise ValueError("max_iterations must be a positive integer")
    if (
        isinstance(tolerance, bool)
        or not isinstance(
            tolerance,
            (int, float, np.integer, np.floating),
        )
        or not np.isfinite(tolerance)
        or float(tolerance) <= 0
    ):
        raise ValueError("tolerance must be positive and finite")

    curve_index = _curve_index(trajectories, curve)
    values = trajectories.values[curve_index][
        :,
        dimension_indices,
    ].astype(float, copy=False)
    _require_finite(
        values,
        context="multivariate IAAFT surrogate generation",
    )
    zero_variance = [
        dimension_names[index]
        for index in range(values.shape[1])
        if np.std(values[:, index], ddof=0) <= 0
    ]
    if zero_variance:
        raise ValueError(
            "multivariate IAAFT requires non-constant dimensions; "
            f"constant dimensions: {zero_variance}"
        )

    reference_index = dimension_names.index(reference_dimension)
    pairs = tuple(
        (left, right)
        for left in range(len(dimension_names))
        for right in range(left + 1, len(dimension_names))
    )
    pair_names = tuple(
        (dimension_names[left], dimension_names[right])
        for left, right in pairs
    )

    rng = np.random.default_rng(random_state)
    surrogates = np.empty(
        (
            int(n_surrogates),
            values.shape[0],
            values.shape[1],
        ),
        dtype=float,
    )
    iterations = np.empty(int(n_surrogates), dtype=int)
    spectral_errors = np.empty(
        (int(n_surrogates), values.shape[1]),
        dtype=float,
    )
    cross_spectral_errors = np.empty(
        (int(n_surrogates), len(pairs)),
        dtype=float,
    )

    for surrogate_index in range(int(n_surrogates)):
        try:
            (
                surrogate,
                n_iterations,
                spectrum_error,
                cross_error,
            ) = _multivariate_iaaft_one(
                values,
                reference_index=reference_index,
                rng=rng,
                max_iterations=int(max_iterations),
                tolerance=float(tolerance),
            )
        except Exception as exc:
            raise RuntimeError(
                f"multivariate surrogate {surrogate_index} failed under "
                "the declared MIAAFT contract"
            ) from exc

        surrogates[surrogate_index] = surrogate
        iterations[surrogate_index] = n_iterations
        spectral_errors[surrogate_index] = spectrum_error
        cross_spectral_errors[surrogate_index] = cross_error

    return MultivariateIAAFTResult(
        surrogates=surrogates,
        source_values=values.copy(),
        curve_id=trajectories.curve_ids[curve_index],
        dimension_names=dimension_names,
        reference_dimension=reference_dimension,
        dimension_pairs=pair_names,
        convergence_iterations=iterations,
        spectral_errors=spectral_errors,
        cross_spectral_errors=cross_spectral_errors,
        n_surrogates=int(n_surrogates),
        max_iterations=int(max_iterations),
        tolerance=float(tolerance),
        random_state=random_state,
        provenance={
            "operation": "generate_multivariate_iaaft_surrogates",
            "source_provenance": dict(trajectories.provenance),
            "curve_id": trajectories.curve_ids[curve_index],
            "dimensions": list(dimension_names),
            "reference_dimension": reference_dimension,
            "method": "reference_anchored_multivariate_IAAFT",
            "fourier_constraint": (
                "original_per_dimension_amplitudes_plus_original_"
                "inter_dimension_phase_differences"
            ),
            "marginal_constraint": (
                "exact_per_dimension_empirical_rank_distribution"
            ),
            "cross_spectral_preservation": (
                "approximate_after_final_rank_remapping_with_error_retained"
            ),
            "reference_dimension_selected_automatically": False,
            "sampling_grid": "regular_common_grid",
            "sampling_step": float(regular_step),
            "time_unit": trajectories.time_unit,
            "dimension_scaling": False,
            "smoothing": False,
            "interpolation": False,
            "failed_surrogate_policy": "raise",
            "interpretation_boundary": (
                "surrogates target a multivariate linear-stochastic null "
                "with retained marginal distributions and approximate "
                "auto/cross-spectral structure; they do not preserve "
                "nonlinear cross-dependence by construction"
            ),
        },
    )

eyetrajectoriespy.multivariate_iaaft_diagnostics_frame

multivariate_iaaft_diagnostics_frame(result: MultivariateIAAFTResult) -> pd.DataFrame

Return convergence and spectral diagnostics for every surrogate.

Source code in src/eyetrajectoriespy/multivariate_surrogates.py
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
def multivariate_iaaft_diagnostics_frame(
    result: MultivariateIAAFTResult,
) -> pd.DataFrame:
    """Return convergence and spectral diagnostics for every surrogate."""

    if not isinstance(result, MultivariateIAAFTResult):
        raise TypeError("result must be a MultivariateIAAFTResult")

    rows: list[dict[str, float | int]] = []
    for surrogate_index in range(result.n_surrogates):
        rows.append(
            {
                "surrogate": surrogate_index,
                "iterations": int(
                    result.convergence_iterations[surrogate_index]
                ),
                "max_spectral_error": float(
                    np.max(result.spectral_errors[surrogate_index])
                ),
                "max_cross_spectral_error": float(
                    np.max(
                        result.cross_spectral_errors[surrogate_index]
                    )
                ),
                "mean_spectral_error": float(
                    np.mean(result.spectral_errors[surrogate_index])
                ),
                "mean_cross_spectral_error": float(
                    np.mean(
                        result.cross_spectral_errors[surrogate_index]
                    )
                ),
            }
        )
    return pd.DataFrame(rows)

eyetrajectoriespy.multivariate_surrogate_nonlinearity_test

multivariate_surrogate_nonlinearity_test(trajectories: TrajectorySet, *, curve: int | str, dimensions: Sequence[str], reference_dimension: str, statistic: str, embedding_dimension: int, delay: float | int, theiler_window: float | int, max_horizon: float | int, fit_start: float | int, fit_end: float | int, delay_units: str = 'samples', theiler_window_units: str = 'samples', max_horizon_units: str = 'samples', fit_units: str = 'samples', n_surrogates: int = 199, alternative: str = 'greater', max_iterations: int = 1000, tolerance: float = 1e-08, random_state: int | None = None) -> MultivariateSurrogateNonlinearityResult

Test multichannel LLE against cross-spectrum-aware MIAAFT surrogates.

Source code in src/eyetrajectoriespy/multivariate_surrogates.py
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
def multivariate_surrogate_nonlinearity_test(
    trajectories: TrajectorySet,
    *,
    curve: int | str,
    dimensions: Sequence[str],
    reference_dimension: str,
    statistic: str,
    embedding_dimension: int,
    delay: float | int,
    theiler_window: float | int,
    max_horizon: float | int,
    fit_start: float | int,
    fit_end: float | int,
    delay_units: str = "samples",
    theiler_window_units: str = "samples",
    max_horizon_units: str = "samples",
    fit_units: str = "samples",
    n_surrogates: int = 199,
    alternative: str = "greater",
    max_iterations: int = 1000,
    tolerance: float = 1e-8,
    random_state: int | None = None,
) -> MultivariateSurrogateNonlinearityResult:
    """Test multichannel LLE against cross-spectrum-aware MIAAFT surrogates."""

    if statistic != "largest_lyapunov":
        raise ValueError(
            "0.37 supports statistic='largest_lyapunov' only"
        )
    if alternative not in {"greater", "less", "two-sided"}:
        raise ValueError(
            "alternative must be 'greater', 'less', or 'two-sided'"
        )

    dimension_names, dimension_indices = _validate_dimensions(
        trajectories,
        dimensions,
    )
    curve_index = _curve_index(trajectories, curve)
    observed_values = trajectories.values[curve_index][
        :,
        dimension_indices,
    ]
    observed_trajectory = _multivariate_surrogate_trajectory(
        trajectories,
        curve_index=curve_index,
        dimension_names=dimension_names,
        values=observed_values,
    )

    common = {
        "embedding_dimension": embedding_dimension,
        "delay": delay,
        "delay_units": delay_units,
        "theiler_window": theiler_window,
        "theiler_window_units": theiler_window_units,
        "max_horizon": max_horizon,
        "max_horizon_units": max_horizon_units,
        "fit_start": fit_start,
        "fit_end": fit_end,
        "fit_units": fit_units,
    }
    observed = _lle_statistic(observed_trajectory, **common)

    surrogate_result = generate_multivariate_iaaft_surrogates(
        trajectories,
        curve=curve,
        dimensions=dimension_names,
        reference_dimension=reference_dimension,
        n_surrogates=n_surrogates,
        max_iterations=max_iterations,
        tolerance=tolerance,
        random_state=random_state,
    )

    surrogate_statistics = np.empty(
        surrogate_result.n_surrogates,
        dtype=float,
    )
    for surrogate_index in range(surrogate_result.n_surrogates):
        try:
            surrogate_trajectory = _multivariate_surrogate_trajectory(
                trajectories,
                curve_index=curve_index,
                dimension_names=dimension_names,
                values=surrogate_result.surrogates[surrogate_index],
            )
            surrogate_statistics[surrogate_index] = _lle_statistic(
                surrogate_trajectory,
                **common,
            )
        except Exception as exc:
            raise RuntimeError(
                f"multivariate surrogate statistic {surrogate_index} "
                "failed under the declared analysis contract"
            ) from exc

    upper = (
        int(np.sum(surrogate_statistics >= observed)) + 1.0
    ) / (surrogate_result.n_surrogates + 1.0)
    lower = (
        int(np.sum(surrogate_statistics <= observed)) + 1.0
    ) / (surrogate_result.n_surrogates + 1.0)
    if alternative == "greater":
        p_value = upper
    elif alternative == "less":
        p_value = lower
    else:
        p_value = min(1.0, 2.0 * min(upper, lower))

    return MultivariateSurrogateNonlinearityResult(
        observed_statistic=float(observed),
        surrogate_statistics=surrogate_statistics,
        p_value=float(p_value),
        alternative=alternative,
        statistic=statistic,
        surrogate_result=surrogate_result,
        provenance={
            "operation": "multivariate_surrogate_nonlinearity_test",
            "source_provenance": dict(trajectories.provenance),
            "curve_id": trajectories.curve_ids[curve_index],
            "dimensions": list(dimension_names),
            "reference_dimension": reference_dimension,
            "surrogate_method": "multivariate_IAAFT",
            "common_statistic_settings": common,
            "p_value_correction": (
                "plus_one"
                if alternative in {"greater", "less"}
                else "two_sided_double_min_plus_one_tails"
            ),
            "failed_surrogate_policy": "raise",
            "interpretation_boundary": (
                "rejection is evidence against the declared multivariate "
                "linear-stochastic surrogate null; it does not establish "
                "deterministic chaos or a unique nonlinear mechanism"
            ),
        },
    )

eyetrajectoriespy.plot_multivariate_iaaft_diagnostics

plot_multivariate_iaaft_diagnostics(result: MultivariateIAAFTResult, *, ax=None)

Plot per-surrogate power- and cross-spectrum preservation errors.

Source code in src/eyetrajectoriespy/nonlinear_plotting.py
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
def plot_multivariate_iaaft_diagnostics(
    result: MultivariateIAAFTResult,
    *,
    ax=None,
):
    """Plot per-surrogate power- and cross-spectrum preservation errors."""

    if not isinstance(result, MultivariateIAAFTResult):
        raise TypeError("result must be a MultivariateIAAFTResult")
    if ax is None:
        _, ax = plt.subplots()

    surrogate_index = np.arange(result.n_surrogates, dtype=int)
    ax.plot(
        surrogate_index,
        np.max(result.spectral_errors, axis=1),
        marker="o",
        label="Max power-spectrum error",
    )
    ax.plot(
        surrogate_index,
        np.max(result.cross_spectral_errors, axis=1),
        marker="o",
        label="Max cross-spectrum error",
    )
    ax.set_xlabel("Surrogate index")
    ax.set_ylabel("Relative mismatch")
    ax.set_title(
        "Multivariate IAAFT preservation diagnostics: "
        f"{result.reference_dimension} reference"
    )
    ax.legend()
    return ax

eyetrajectoriespy.plot_multivariate_surrogate_nonlinearity

plot_multivariate_surrogate_nonlinearity(result: MultivariateSurrogateNonlinearityResult, *, bins: int = 20, ax=None)

Plot a multivariate-surrogate statistic distribution.

Source code in src/eyetrajectoriespy/nonlinear_plotting.py
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
def plot_multivariate_surrogate_nonlinearity(
    result: MultivariateSurrogateNonlinearityResult,
    *,
    bins: int = 20,
    ax=None,
):
    """Plot a multivariate-surrogate statistic distribution."""

    if not isinstance(result, MultivariateSurrogateNonlinearityResult):
        raise TypeError(
            "result must be a MultivariateSurrogateNonlinearityResult"
        )
    if not isinstance(bins, int) or bins < 2:
        raise ValueError("bins must be an integer >= 2")
    if ax is None:
        _, ax = plt.subplots()
    ax.hist(result.surrogate_statistics, bins=bins, alpha=0.7)
    ax.axvline(
        result.observed_statistic,
        linestyle="--",
        label="Observed",
    )
    ax.set_xlabel(result.statistic)
    ax.set_ylabel("Surrogate count")
    ax.set_title(
        "Multivariate IAAFT surrogate test "
        f"(p={result.p_value:.3g})"
    )
    ax.legend()
    return ax

eyetrajectoriespy.multivariate_iaaft_reporting_text

multivariate_iaaft_reporting_text(result: MultivariateIAAFTResult) -> str

Return manuscript-ready wording for multivariate IAAFT generation.

Source code in src/eyetrajectoriespy/nonlinear_reporting.py
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
def multivariate_iaaft_reporting_text(
    result: MultivariateIAAFTResult,
) -> str:
    """Return manuscript-ready wording for multivariate IAAFT generation."""

    if not isinstance(result, MultivariateIAAFTResult):
        raise TypeError("result must be a MultivariateIAAFTResult")
    return (
        f"{result.n_surrogates} multivariate IAAFT surrogates were generated "
        f"for dimensions {', '.join(result.dimension_names)} using "
        f"{result.reference_dimension!r} as the explicitly declared "
        "phase-reference dimension. Empirical marginal distributions were "
        "restored exactly by rank remapping after each Fourier adjustment, "
        "while per-dimension Fourier amplitudes and inter-dimension phase "
        "differences were targeted jointly. Final spectral preservation was "
        "therefore approximate after rank remapping and was retained "
        f"diagnostically (maximum relative power-spectrum mismatch="
        f"{np.max(result.spectral_errors):.4g}; maximum relative "
        f"cross-spectrum mismatch={np.max(result.cross_spectral_errors):.4g}). "
        "No dimension scaling, smoothing, interpolation, or automatic "
        "reference-dimension selection was performed."
    )

eyetrajectoriespy.multivariate_surrogate_nonlinearity_reporting_text

multivariate_surrogate_nonlinearity_reporting_text(result: MultivariateSurrogateNonlinearityResult) -> str

Return manuscript-ready wording for multivariate surrogate testing.

Source code in src/eyetrajectoriespy/nonlinear_reporting.py
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
def multivariate_surrogate_nonlinearity_reporting_text(
    result: MultivariateSurrogateNonlinearityResult,
) -> str:
    """Return manuscript-ready wording for multivariate surrogate testing."""

    if not isinstance(result, MultivariateSurrogateNonlinearityResult):
        raise TypeError(
            "result must be a MultivariateSurrogateNonlinearityResult"
        )
    finite = np.isfinite(result.surrogate_statistics)
    if not np.all(finite):
        raise ValueError(
            "surrogate statistics must all be finite for reporting"
        )
    surrogate = result.surrogate_result
    return (
        "Multivariate nonlinearity was assessed with "
        f"{surrogate.n_surrogates} cross-spectrum-aware MIAAFT surrogates "
        f"for dimensions {', '.join(surrogate.dimension_names)} using "
        f"{surrogate.reference_dimension!r} as the declared phase reference. "
        f"The statistic was {result.statistic.replace('_', ' ')} with a "
        f"{result.alternative} alternative and a plus-one Monte Carlo "
        f"p-value (p={result.p_value:.4g}; random_state="
        f"{surrogate.random_state}). The maximum retained final relative "
        f"power-spectrum mismatch was {np.max(surrogate.spectral_errors):.4g} "
        "and the maximum retained relative cross-spectrum mismatch was "
        f"{np.max(surrogate.cross_spectral_errors):.4g}. Rejection was "
        "interpreted as evidence against the declared multivariate "
        "linear-stochastic surrogate null, not as proof of deterministic "
        "chaos or a unique nonlinear mechanism."
    )

Recurrence networks

eyetrajectoriespy.RecurrenceNetworkResult dataclass

Sparse recurrence-network topology with explicit graph conventions.

Source code in src/eyetrajectoriespy/nonlinear_types.py
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
@dataclass(frozen=True)
class RecurrenceNetworkResult:
    """Sparse recurrence-network topology with explicit graph conventions."""

    adjacency: csr_matrix
    degree: np.ndarray
    normalized_degree: np.ndarray
    local_clustering: np.ndarray
    component_labels: np.ndarray
    component_sizes: np.ndarray
    edge_count: int
    graph_density: float
    transitivity: float
    mean_local_clustering: float
    n_connected_components: int
    largest_component_fraction: float
    isolated_node_fraction: float
    source_recurrence: "RecurrenceResult"
    provenance: Mapping[str, Any] = field(default_factory=dict)

    @property
    def n_nodes(self) -> int:
        return self.adjacency.shape[0]

eyetrajectoriespy.recurrence_network

recurrence_network(recurrence: RecurrenceResult) -> RecurrenceNetworkResult

Convert one auto-recurrence matrix into an undirected simple network.

Every recurrence state/time index becomes one network node. Every retained off-diagonal recurrence pair becomes one undirected edge.

The network inherits the recurrence threshold, state representation, metric, Theiler exclusion, and any target-recurrence-rate constraint from the source recurrence object. No edge weighting, temporal edge restoration, graph threshold tuning, community optimization, or dimensionality interpretation is introduced automatically.

Source code in src/eyetrajectoriespy/recurrence_networks.py
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
def recurrence_network(
    recurrence: RecurrenceResult,
) -> RecurrenceNetworkResult:
    """Convert one auto-recurrence matrix into an undirected simple network.

    Every recurrence state/time index becomes one network node. Every retained
    off-diagonal recurrence pair becomes one undirected edge.

    The network inherits the recurrence threshold, state representation,
    metric, Theiler exclusion, and any target-recurrence-rate constraint from
    the source recurrence object. No edge weighting, temporal edge restoration,
    graph threshold tuning, community optimization, or dimensionality
    interpretation is introduced automatically.
    """

    adjacency = _validate_recurrence_network_source(recurrence)
    n_nodes = adjacency.shape[0]
    upper = triu(adjacency, k=1).tocsr()
    edge_count = int(upper.nnz)
    total_pairs = n_nodes * (n_nodes - 1) // 2
    graph_density = float(edge_count / total_pairs)

    degree = np.asarray(
        adjacency.sum(axis=1)
    ).ravel().astype(np.int64)
    normalized_degree = degree.astype(float) / float(n_nodes - 1)

    triangle_count_by_node, n_triangles = _triangle_counts(adjacency)
    possible_neighbor_pairs = (
        degree.astype(np.int64) * (degree.astype(np.int64) - 1) // 2
    )
    local_clustering = np.zeros(n_nodes, dtype=float)
    eligible_local = possible_neighbor_pairs > 0
    local_clustering[eligible_local] = (
        triangle_count_by_node[eligible_local].astype(float)
        / possible_neighbor_pairs[eligible_local].astype(float)
    )
    mean_local_clustering = float(np.mean(local_clustering))

    connected_triples = int(np.sum(possible_neighbor_pairs))
    transitivity = (
        float(3 * n_triangles / connected_triples)
        if connected_triples > 0
        else float("nan")
    )

    n_components, labels = connected_components(
        adjacency,
        directed=False,
        return_labels=True,
    )
    component_sizes = np.bincount(
        labels,
        minlength=n_components,
    ).astype(np.int64)
    largest_component_fraction = float(
        component_sizes.max() / n_nodes
    )
    isolated_node_fraction = float(np.mean(degree == 0))

    return RecurrenceNetworkResult(
        adjacency=adjacency,
        degree=degree,
        normalized_degree=normalized_degree,
        local_clustering=local_clustering,
        component_labels=labels.astype(np.int64),
        component_sizes=component_sizes,
        edge_count=edge_count,
        graph_density=graph_density,
        transitivity=transitivity,
        mean_local_clustering=mean_local_clustering,
        n_connected_components=int(n_components),
        largest_component_fraction=largest_component_fraction,
        isolated_node_fraction=isolated_node_fraction,
        source_recurrence=recurrence,
        provenance={
            "operation": "recurrence_network",
            "network_type": "undirected_unweighted_simple_graph",
            "node_definition": "recurrence_state_time_index",
            "edge_definition": "retained_off_diagonal_recurrence_pair",
            "source_recurrence_provenance": dict(recurrence.provenance),
            "source_metric": recurrence.metric,
            "source_radius": float(recurrence.radius),
            "source_target_recurrence_rate": (
                recurrence.target_recurrence_rate
            ),
            "source_achieved_recurrence_rate": float(
                recurrence.achieved_recurrence_rate
            ),
            "source_theiler_window_samples": int(
                recurrence.theiler_window_samples
            ),
            "source_state_dimension": int(recurrence.state_dimension),
            "source_time_unit": recurrence.time_unit,
            "graph_density_denominator": "all_unordered_node_pairs",
            "source_recurrence_rate_denominator": (
                recurrence.provenance.get(
                    "recurrence_rate_denominator"
                )
            ),
            "local_clustering_degree_lt_2": 0.0,
            "transitivity_undefined_when_no_connected_triples": True,
            "triangle_count": int(n_triangles),
            "dense_adjacency_materialized": False,
            "automatic_threshold_selection": False,
            "automatic_community_detection": False,
            "automatic_dimension_interpretation": False,
            "interpretation_boundary": (
                "network topology describes the geometry induced by the "
                "declared recurrence relation; it remains conditional on the "
                "state representation, metric, threshold policy, Theiler "
                "window, and sampling design"
            ),
        },
    )

eyetrajectoriespy.recurrence_network_node_frame

recurrence_network_node_frame(result: RecurrenceNetworkResult) -> pd.DataFrame

Return one row per recurrence-network node/state index.

Source code in src/eyetrajectoriespy/recurrence_networks.py
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
def recurrence_network_node_frame(
    result: RecurrenceNetworkResult,
) -> pd.DataFrame:
    """Return one row per recurrence-network node/state index."""

    if not isinstance(result, RecurrenceNetworkResult):
        raise TypeError("result must be a RecurrenceNetworkResult")

    time = np.asarray(
        result.source_recurrence.time_a,
        dtype=float,
    )
    return pd.DataFrame(
        {
            "node_index": np.arange(result.n_nodes, dtype=int),
            "time": time,
            "degree": result.degree,
            "normalized_degree": result.normalized_degree,
            "local_clustering": result.local_clustering,
            "component": result.component_labels,
            "component_size": result.component_sizes[
                result.component_labels
            ],
        }
    )

eyetrajectoriespy.recurrence_network_summary_frame

recurrence_network_summary_frame(result: RecurrenceNetworkResult) -> pd.DataFrame

Return a one-row table of global recurrence-network summaries.

Source code in src/eyetrajectoriespy/recurrence_networks.py
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
def recurrence_network_summary_frame(
    result: RecurrenceNetworkResult,
) -> pd.DataFrame:
    """Return a one-row table of global recurrence-network summaries."""

    if not isinstance(result, RecurrenceNetworkResult):
        raise TypeError("result must be a RecurrenceNetworkResult")

    return pd.DataFrame(
        [
            {
                "n_nodes": result.n_nodes,
                "edge_count": result.edge_count,
                "graph_density": result.graph_density,
                "source_achieved_recurrence_rate": (
                    result.source_recurrence.achieved_recurrence_rate
                ),
                "mean_degree": float(np.mean(result.degree)),
                "mean_local_clustering": (
                    result.mean_local_clustering
                ),
                "transitivity": result.transitivity,
                "n_connected_components": (
                    result.n_connected_components
                ),
                "largest_component_fraction": (
                    result.largest_component_fraction
                ),
                "isolated_node_fraction": (
                    result.isolated_node_fraction
                ),
            }
        ]
    )

eyetrajectoriespy.plot_recurrence_network_degree

plot_recurrence_network_degree(result: RecurrenceNetworkResult, *, normalized: bool = True, ax=None)

Plot recurrence-network degree across source state indices.

Source code in src/eyetrajectoriespy/nonlinear_plotting.py
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
def plot_recurrence_network_degree(
    result: RecurrenceNetworkResult,
    *,
    normalized: bool = True,
    ax=None,
):
    """Plot recurrence-network degree across source state indices."""

    if not isinstance(result, RecurrenceNetworkResult):
        raise TypeError("result must be a RecurrenceNetworkResult")
    if not isinstance(normalized, (bool, np.bool_)):
        raise TypeError("normalized must be boolean")
    if ax is None:
        _, ax = plt.subplots()

    values = (
        result.normalized_degree
        if normalized
        else result.degree.astype(float)
    )
    ax.plot(np.arange(result.n_nodes), values)
    ax.set_xlabel("State index")
    ax.set_ylabel("Normalized degree" if normalized else "Degree")
    ax.set_title(
        "Recurrence-network degree "
        f"(density={result.graph_density:.3f})"
    )
    return ax

eyetrajectoriespy.recurrence_network_reporting_text

recurrence_network_reporting_text(result: RecurrenceNetworkResult) -> str

Return manuscript-ready wording for recurrence-network topology.

Source code in src/eyetrajectoriespy/nonlinear_reporting.py
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
def recurrence_network_reporting_text(
    result: RecurrenceNetworkResult,
) -> str:
    """Return manuscript-ready wording for recurrence-network topology."""

    if not isinstance(result, RecurrenceNetworkResult):
        raise TypeError("result must be a RecurrenceNetworkResult")

    transitivity = (
        "undefined because the network contained no connected triples"
        if not np.isfinite(result.transitivity)
        else f"{result.transitivity:.4g}"
    )
    source = result.source_recurrence
    policy = (
        f"target RR={source.target_recurrence_rate:.4g}, "
        f"achieved RR={source.achieved_recurrence_rate:.4g}"
        if source.target_recurrence_rate is not None
        else f"fixed radius={source.radius:.4g}, "
        f"achieved RR={source.achieved_recurrence_rate:.4g}"
    )

    return (
        f"A sparse undirected recurrence network was constructed from "
        f"{result.n_nodes} recurrence-state nodes using the source "
        f"{source.metric} recurrence relation ({policy}) and a Theiler "
        f"window of {source.theiler_window_samples} samples. The network "
        f"contained {result.edge_count} undirected edges, graph density "
        f"{result.graph_density:.4g}, mean local clustering "
        f"{result.mean_local_clustering:.4g}, and transitivity "
        f"{transitivity}. It had {result.n_connected_components} connected "
        f"component(s), with {100.0 * result.largest_component_fraction:.1f}% "
        f"of nodes in the largest component and "
        f"{100.0 * result.isolated_node_fraction:.1f}% isolated nodes. "
        "Graph density used all unordered node pairs, whereas the source "
        "recurrence rate used its declared eligible-pair denominator. "
        "No threshold tuning, community optimization, edge weighting, or "
        "automatic dimensionality interpretation was performed."
    )

Joint recurrence analysis

eyetrajectoriespy.JointRecurrenceResult dataclass

Sparse intersection of synchronized auto-recurrence matrices.

Source code in src/eyetrajectoriespy/nonlinear_types.py
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
@dataclass(frozen=True)
class JointRecurrenceResult:
    """Sparse intersection of synchronized auto-recurrence matrices."""

    matrix: csr_matrix
    time: np.ndarray
    component_recurrences: tuple["RecurrenceResult", ...]
    component_labels: tuple[str, ...]
    joint_recurrence_rate: float
    n_joint_recurrent_pairs: int
    eligible_pair_count: int
    theiler_window_samples: int
    provenance: Mapping[str, Any] = field(default_factory=dict)

    @property
    def shape(self) -> tuple[int, int]:
        return self.matrix.shape

    @property
    def n_components(self) -> int:
        return len(self.component_recurrences)

eyetrajectoriespy.joint_recurrence_matrix

joint_recurrence_matrix(recurrences: Sequence[RecurrenceResult], *, labels: Sequence[str] | None = None) -> JointRecurrenceResult

Intersect synchronized auto-recurrence matrices.

A joint recurrence is present at (i, j) only when every supplied subsystem is recurrent at the same pair of time indices. Each subsystem may use its own state dimension, distance metric, and radius policy.

Version 0.39 requires all component recurrence matrices to be auto recurrence results on the exact same time grid with the same Theiler exclusion. No resampling, lag shifting, synchronization, threshold harmonization, or target-rate re-estimation is performed.

Source code in src/eyetrajectoriespy/joint_recurrence.py
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
def joint_recurrence_matrix(
    recurrences: Sequence[RecurrenceResult],
    *,
    labels: Sequence[str] | None = None,
) -> JointRecurrenceResult:
    """Intersect synchronized auto-recurrence matrices.

    A joint recurrence is present at (i, j) only when every supplied subsystem
    is recurrent at the same pair of time indices. Each subsystem may use its
    own state dimension, distance metric, and radius policy.

    Version 0.39 requires all component recurrence matrices to be auto
    recurrence results on the exact same time grid with the same Theiler
    exclusion. No resampling, lag shifting, synchronization, threshold
    harmonization, or target-rate re-estimation is performed.
    """

    if isinstance(recurrences, (str, bytes)):
        raise TypeError("recurrences must be a non-string sequence")
    components = tuple(recurrences)
    if len(components) < 2:
        raise ValueError(
            "joint recurrence requires at least two recurrence components"
        )
    for index, recurrence in enumerate(components):
        if not isinstance(recurrence, RecurrenceResult):
            raise TypeError(
                f"recurrence component {index} must be a RecurrenceResult"
            )
        if recurrence.kind != "auto":
            raise ValueError(
                "joint recurrence components must be auto-recurrence results"
            )
        if recurrence.matrix.shape[0] != recurrence.matrix.shape[1]:
            raise ValueError(
                "joint recurrence requires square auto-recurrence matrices"
            )
        matrix = recurrence.matrix.astype(bool).tocsr()
        if (matrix != matrix.T).nnz:
            raise ValueError(
                "joint recurrence components must be symmetric auto-recurrence matrices"
            )
        if np.any(matrix.diagonal()):
            raise ValueError(
                "joint recurrence components must exclude the main diagonal"
            )
        coo = matrix.tocoo()
        if np.any(
            np.abs(coo.row - coo.col)
            <= int(recurrence.theiler_window_samples)
        ):
            raise ValueError(
                "joint recurrence component contains points inside its "
                "declared Theiler exclusion"
            )
        if recurrence.time_a.shape != recurrence.time_b.shape or not np.array_equal(
            recurrence.time_a,
            recurrence.time_b,
        ):
            raise ValueError(
                "each auto-recurrence component must use one identical "
                "time grid on both axes"
            )

    first = components[0]
    n_states = first.matrix.shape[0]
    time = np.asarray(first.time_a, dtype=float)
    time_unit = first.time_unit
    theiler = int(first.theiler_window_samples)
    if time_unit is None:
        raise ValueError(
            "joint recurrence requires component time_unit metadata"
        )

    if time.shape != (n_states,):
        raise ValueError(
            "recurrence time grid length must match matrix dimensions"
        )
    if theiler < 0:
        raise ValueError("theiler_window_samples must be non-negative")

    for recurrence in components[1:]:
        if recurrence.matrix.shape != first.matrix.shape:
            raise ValueError(
                "joint recurrence components must have identical matrix shapes"
            )
        if not np.array_equal(
            np.asarray(recurrence.time_a, dtype=float),
            time,
        ):
            raise ValueError(
                "joint recurrence components must use the exact same time grid; "
                "align or resample upstream explicitly"
            )
        if recurrence.theiler_window_samples != theiler:
            raise ValueError(
                "joint recurrence components must use the same Theiler window"
            )
        if recurrence.time_unit != time_unit:
            raise ValueError(
                "joint recurrence components must use the same time_unit"
            )

    eligible = _eligible_auto_pairs(n_states, theiler)
    if eligible <= 0:
        raise ValueError(
            "Theiler window leaves no eligible joint recurrence pairs"
        )

    resolved_labels = _validate_labels(labels, len(components))

    joint: csr_matrix = components[0].matrix.astype(bool).tocsr().copy()
    for recurrence in components[1:]:
        joint = joint.multiply(
            recurrence.matrix.astype(bool)
        ).tocsr()
    joint.eliminate_zeros()

    upper = triu(joint, k=1).tocsr()
    n_joint_pairs = int(upper.nnz)
    joint_rate = float(n_joint_pairs / eligible)

    return JointRecurrenceResult(
        matrix=joint,
        time=time.copy(),
        component_recurrences=components,
        component_labels=resolved_labels,
        joint_recurrence_rate=joint_rate,
        n_joint_recurrent_pairs=n_joint_pairs,
        eligible_pair_count=int(eligible),
        theiler_window_samples=theiler,
        provenance={
            "operation": "joint_recurrence_matrix",
            "definition": (
                "elementwise_logical_and_of_synchronized_auto_recurrence_matrices"
            ),
            "component_labels": list(resolved_labels),
            "component_count": len(components),
            "component_provenance": [
                dict(recurrence.provenance)
                for recurrence in components
            ],
            "component_metrics": [
                recurrence.metric
                for recurrence in components
            ],
            "component_radii": [
                float(recurrence.radius)
                for recurrence in components
            ],
            "component_target_recurrence_rates": [
                recurrence.target_recurrence_rate
                for recurrence in components
            ],
            "component_achieved_recurrence_rates": [
                float(recurrence.achieved_recurrence_rate)
                for recurrence in components
            ],
            "component_state_dimensions": [
                int(recurrence.state_dimension)
                for recurrence in components
            ],
            "theiler_window_samples": theiler,
            "time_unit": time_unit,
            "eligible_pair_count": int(eligible),
            "n_joint_recurrent_pairs": n_joint_pairs,
            "joint_recurrence_rate": joint_rate,
            "recurrence_rate_scale": "0_to_1",
            "recurrence_rate_denominator": (
                "eligible_off_diagonal_pairs_outside_shared_theiler_window"
            ),
            "time_alignment": "exact_grid_match_required",
            "resampling": False,
            "lag_shift": False,
            "threshold_harmonization": False,
            "automatic_threshold_selection": False,
            "interpretation_boundary": (
                "joint recurrence means simultaneous recurrence within every "
                "component system under its own declared recurrence contract; "
                "it is distinct from cross recurrence between states"
            ),
        },
    )

eyetrajectoriespy.joint_recurrence_component_frame

joint_recurrence_component_frame(result: JointRecurrenceResult) -> pd.DataFrame

Return one row per component recurrence contract.

Source code in src/eyetrajectoriespy/joint_recurrence.py
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
def joint_recurrence_component_frame(
    result: JointRecurrenceResult,
) -> pd.DataFrame:
    """Return one row per component recurrence contract."""

    if not isinstance(result, JointRecurrenceResult):
        raise TypeError("result must be a JointRecurrenceResult")

    rows: list[dict[str, object]] = []
    for label, recurrence in zip(
        result.component_labels,
        result.component_recurrences,
        strict=True,
    ):
        rows.append(
            {
                "label": label,
                "source_curve_ids": recurrence.source_curve_ids,
                "metric": recurrence.metric,
                "radius": float(recurrence.radius),
                "target_recurrence_rate": recurrence.target_recurrence_rate,
                "achieved_recurrence_rate": float(
                    recurrence.achieved_recurrence_rate
                ),
                "state_dimension": int(recurrence.state_dimension),
                "theiler_window_samples": int(
                    recurrence.theiler_window_samples
                ),
                "time_unit": recurrence.time_unit,
            }
        )
    return pd.DataFrame(rows)

eyetrajectoriespy.joint_rqa_metrics

joint_rqa_metrics(result: JointRecurrenceResult, *, min_diagonal_length: int = 2, min_vertical_length: int = 2) -> RQAResult

Compute standard line-based RQA metrics on a joint recurrence plot.

Source code in src/eyetrajectoriespy/joint_recurrence.py
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
def joint_rqa_metrics(
    result: JointRecurrenceResult,
    *,
    min_diagonal_length: int = 2,
    min_vertical_length: int = 2,
) -> RQAResult:
    """Compute standard line-based RQA metrics on a joint recurrence plot."""

    if not isinstance(result, JointRecurrenceResult):
        raise TypeError("result must be a JointRecurrenceResult")
    metrics = rqa_metrics(
        _as_recurrence_result(result),
        min_diagonal_length=min_diagonal_length,
        min_vertical_length=min_vertical_length,
    )
    return RQAResult(
        recurrence_rate=metrics.recurrence_rate,
        determinism=metrics.determinism,
        mean_diagonal_length=metrics.mean_diagonal_length,
        max_diagonal_length=metrics.max_diagonal_length,
        diagonal_entropy=metrics.diagonal_entropy,
        laminarity=metrics.laminarity,
        trapping_time=metrics.trapping_time,
        max_vertical_length=metrics.max_vertical_length,
        center_of_recurrence_mass=metrics.center_of_recurrence_mass,
        n_recurrence_points=metrics.n_recurrence_points,
        n_diagonal_lines=metrics.n_diagonal_lines,
        n_vertical_lines=metrics.n_vertical_lines,
        min_diagonal_length=metrics.min_diagonal_length,
        min_vertical_length=metrics.min_vertical_length,
        provenance={
            **dict(metrics.provenance),
            "operation": "joint_rqa_metrics",
            "joint_recurrence_provenance": dict(result.provenance),
            "interpretation_boundary": (
                "line statistics summarize coincident recurrence structure "
                "across the declared component systems; they are not "
                "cross-RQA between two state trajectories"
            ),
        },
    )

eyetrajectoriespy.plot_joint_recurrence

plot_joint_recurrence(result: JointRecurrenceResult, *, max_points: int | None = 200000, ax=None)

Plot a sparse joint recurrence matrix without densifying it.

Source code in src/eyetrajectoriespy/nonlinear_plotting.py
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
def plot_joint_recurrence(
    result: JointRecurrenceResult,
    *,
    max_points: int | None = 200_000,
    ax=None,
):
    """Plot a sparse joint recurrence matrix without densifying it."""

    if not isinstance(result, JointRecurrenceResult):
        raise TypeError("result must be a JointRecurrenceResult")
    if max_points is not None:
        if isinstance(max_points, (bool, np.bool_)) or not isinstance(
            max_points,
            (int, np.integer),
        ):
            raise TypeError("max_points must be an integer or None")
        max_points = int(max_points)
        if max_points < 1:
            raise ValueError("max_points must be positive or None")
        if result.matrix.nnz > max_points:
            raise ValueError(
                "joint recurrence matrix exceeds max_points; increase "
                "max_points explicitly rather than silently subsampling"
            )
    if ax is None:
        _, ax = plt.subplots()
    coo = result.matrix.tocoo()
    ax.scatter(coo.col, coo.row, s=4, marker="s")
    ax.set_xlabel("State index")
    ax.set_ylabel("State index")
    ax.invert_yaxis()
    ax.set_aspect("equal", adjustable="box")
    ax.set_title(
        "Joint recurrence "
        f"(JRR={result.joint_recurrence_rate:.3f}, "
        f"components={result.n_components})"
    )
    return ax

eyetrajectoriespy.joint_recurrence_reporting_text

joint_recurrence_reporting_text(result: JointRecurrenceResult, metrics: RQAResult | None = None) -> str

Return manuscript-ready wording for synchronized joint recurrence.

Source code in src/eyetrajectoriespy/nonlinear_reporting.py
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
def joint_recurrence_reporting_text(
    result: JointRecurrenceResult,
    metrics: RQAResult | None = None,
) -> str:
    """Return manuscript-ready wording for synchronized joint recurrence."""

    if not isinstance(result, JointRecurrenceResult):
        raise TypeError("result must be a JointRecurrenceResult")
    if metrics is not None and not isinstance(metrics, RQAResult):
        raise TypeError("metrics must be an RQAResult or None")

    components = []
    for label, recurrence in zip(
        result.component_labels,
        result.component_recurrences,
        strict=True,
    ):
        policy = (
            f"target RR={recurrence.target_recurrence_rate:.4g}"
            if recurrence.target_recurrence_rate is not None
            else f"fixed radius={recurrence.radius:.4g}"
        )
        components.append(
            f"{label}: {recurrence.metric}, {policy}, "
            f"achieved RR={recurrence.achieved_recurrence_rate:.4g}, "
            f"state dimension={recurrence.state_dimension}"
        )

    metric_text = ""
    if metrics is not None:
        metric_text = (
            f" Joint-RQA used minimum diagonal/vertical line lengths "
            f"{metrics.min_diagonal_length}/{metrics.min_vertical_length}; "
            f"DET={metrics.determinism:.4g}, "
            f"LAM={metrics.laminarity:.4g}, "
            f"trapping time={metrics.trapping_time:.4g}."
        )

    return (
        f"Joint recurrence was computed as the logical intersection of "
        f"{result.n_components} synchronized auto-recurrence matrices on the "
        f"same time grid with a shared Theiler window of "
        f"{result.theiler_window_samples} samples. Component contracts were "
        f"{'; '.join(components)}. The joint recurrence rate was "
        f"{result.joint_recurrence_rate:.4g} "
        f"({result.n_joint_recurrent_pairs}/{result.eligible_pair_count} "
        f"eligible unordered pairs). No resampling, lag shifting, threshold "
        f"harmonization, or automatic threshold selection was performed."
        + metric_text
        + " Joint recurrence was interpreted as coincident recurrence within "
        "the declared component systems, not as cross-recurrence between "
        "states or as evidence of causal coupling."
    )

Nonlinear trajectory dynamics

Delay reconstruction and embedding diagnostics

eyetrajectoriespy.DelayEmbeddingResult dataclass

Delay-coordinate state-space reconstruction for common-grid trajectories.

Source code in src/eyetrajectoriespy/nonlinear_types.py
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
@dataclass(frozen=True)
class DelayEmbeddingResult:
    """Delay-coordinate state-space reconstruction for common-grid trajectories."""

    values: np.ndarray
    time: np.ndarray
    curve_ids: tuple[str, ...]
    source_dimension_names: tuple[str, ...]
    state_names: tuple[str, ...]
    embedding_dimension: int
    delay_samples: int
    delay_time: float
    time_unit: str
    coordinate_system: str
    provenance: Mapping[str, Any] = field(default_factory=dict)

    @property
    def n_curves(self) -> int:
        return self.values.shape[0]

    @property
    def n_states(self) -> int:
        return self.values.shape[1]

    @property
    def state_dimension(self) -> int:
        return self.values.shape[2]

eyetrajectoriespy.EmbeddingDelayDiagnosticResult dataclass

Average-mutual-information and autocorrelation delay diagnostics.

Source code in src/eyetrajectoriespy/nonlinear_types.py
45
46
47
48
49
50
51
52
53
54
55
@dataclass(frozen=True)
class EmbeddingDelayDiagnosticResult:
    """Average-mutual-information and autocorrelation delay diagnostics."""

    table: pd.DataFrame
    curve_id: str
    dimension: str
    bins: int
    max_lag_samples: int
    time_unit: str
    provenance: Mapping[str, Any] = field(default_factory=dict)

eyetrajectoriespy.EmbeddingDimensionDiagnosticResult dataclass

False-nearest-neighbor embedding-dimension diagnostics.

Source code in src/eyetrajectoriespy/nonlinear_types.py
58
59
60
61
62
63
64
65
66
67
68
69
@dataclass(frozen=True)
class EmbeddingDimensionDiagnosticResult:
    """False-nearest-neighbor embedding-dimension diagnostics."""

    table: pd.DataFrame
    curve_id: str
    dimension: str
    delay_samples: int
    theiler_window_samples: int
    rtol: float
    atol: float
    provenance: Mapping[str, Any] = field(default_factory=dict)

eyetrajectoriespy.delay_embed_trajectory

delay_embed_trajectory(trajectories: TrajectorySet, *, embedding_dimension: int, delay: float | int, delay_units: str = 'samples', dimensions: Sequence[str] | None = None) -> DelayEmbeddingResult

Reconstruct a multivariate delay-coordinate state space.

No smoothing, interpolation, scaling, or parameter selection is performed. Time-based delays require a regular common grid and must map to an integer number of observed samples.

Source code in src/eyetrajectoriespy/embedding.py
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
def delay_embed_trajectory(
    trajectories: TrajectorySet,
    *,
    embedding_dimension: int,
    delay: float | int,
    delay_units: str = "samples",
    dimensions: Sequence[str] | None = None,
) -> DelayEmbeddingResult:
    """Reconstruct a multivariate delay-coordinate state space.

    No smoothing, interpolation, scaling, or parameter selection is performed.
    Time-based delays require a regular common grid and must map to an integer
    number of observed samples.
    """

    if not isinstance(embedding_dimension, (int, np.integer)) or embedding_dimension < 1:
        raise ValueError("embedding_dimension must be a positive integer")
    indices = _dimension_indices(trajectories, dimensions)
    source_names = tuple(trajectories.dimension_names[i] for i in indices)
    selected = trajectories.values[:, :, indices]
    _require_finite(selected, context="delay embedding")
    delay_samples = _resolve_samples(
        trajectories.time,
        delay,
        units=delay_units,
        time_unit=trajectories.time_unit,
        name="delay",
    )
    first = (embedding_dimension - 1) * delay_samples
    if first >= trajectories.n_time - 1:
        raise ValueError("embedding_dimension and delay leave fewer than two reconstructed states")

    endpoints = np.arange(first, trajectories.n_time)
    blocks = [
        selected[:, endpoints - lag * delay_samples, :]
        for lag in range(embedding_dimension)
    ]
    values = np.concatenate(blocks, axis=2)
    state_names = tuple(
        f"{name}[t-{lag}*delay]"
        for lag in range(embedding_dimension)
        for name in source_names
    )
    try:
        step = _regular_step(trajectories.time)
        delay_time = delay_samples * step
        constant_delay_time = True
    except ValueError:
        if delay_units != "samples":
            raise
        delay_time = float("nan")
        constant_delay_time = False
    return DelayEmbeddingResult(
        values=values,
        time=trajectories.time[endpoints].copy(),
        curve_ids=trajectories.curve_ids,
        source_dimension_names=source_names,
        state_names=state_names,
        embedding_dimension=int(embedding_dimension),
        delay_samples=delay_samples,
        delay_time=delay_time,
        time_unit=trajectories.time_unit,
        coordinate_system=trajectories.coordinate_system,
        provenance={
            "operation": "delay_embed_trajectory",
            "source_provenance": dict(trajectories.provenance),
            "dimensions": source_names,
            "embedding_dimension": int(embedding_dimension),
            "delay_samples": delay_samples,
            "delay_time": delay_time,
            "constant_delay_time": constant_delay_time,
            "delay_units_requested": delay_units,
            "automatic_parameter_selection": False,
            "missing_policy": "error",
            "scaling": "none",
        },
    )

eyetrajectoriespy.embedding_delay_diagnostics

embedding_delay_diagnostics(trajectories: TrajectorySet, *, curve: int | str, dimension: str, max_lag: float | int, max_lag_units: str = 'samples', bins: int = 16) -> EmbeddingDelayDiagnosticResult

Compute autocorrelation and average-mutual-information delay diagnostics.

The function marks the first interior AMI local minimum when one exists but does not select or return an analysis delay automatically.

Source code in src/eyetrajectoriespy/embedding.py
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
def embedding_delay_diagnostics(
    trajectories: TrajectorySet,
    *,
    curve: int | str,
    dimension: str,
    max_lag: float | int,
    max_lag_units: str = "samples",
    bins: int = 16,
) -> EmbeddingDelayDiagnosticResult:
    """Compute autocorrelation and average-mutual-information delay diagnostics.

    The function marks the first interior AMI local minimum when one exists but
    does not select or return an analysis delay automatically.
    """

    if dimension not in trajectories.dimension_names:
        raise KeyError(f"Unknown dimension {dimension!r}")
    if not isinstance(bins, (int, np.integer)) or bins < 2:
        raise ValueError("bins must be an integer >= 2")
    sampling_step = _require_regular_temporal_grid(
        trajectories.time,
        context="embedding delay diagnostics",
    )
    curve_index = _curve_index(trajectories, curve)
    signal = trajectories.dimension(dimension)[curve_index].astype(float, copy=False)
    _require_finite(signal, context="embedding delay diagnostics")
    if np.std(signal, ddof=0) <= 0:
        raise ValueError("embedding delay diagnostics require a non-constant signal")
    max_lag_samples = _resolve_samples(
        trajectories.time,
        max_lag,
        units=max_lag_units,
        time_unit=trajectories.time_unit,
        name="max_lag",
    )
    if max_lag_samples >= signal.size - 1:
        raise ValueError("max_lag must leave at least two paired observations")

    centered = signal - signal.mean()
    denominator = float(np.dot(centered, centered))
    edges = np.histogram_bin_edges(signal, bins=int(bins))
    if np.unique(edges).size < 3:
        raise ValueError("AMI histogram requires at least two non-degenerate bins")
    rows = []
    for lag in range(1, max_lag_samples + 1):
        x = signal[:-lag]
        y = signal[lag:]
        acf = float(np.dot(centered[:-lag], centered[lag:]) / denominator)
        ami = _average_mutual_information(x, y, edges)
        rows.append((lag, trajectories.time[lag] - trajectories.time[0], acf, ami))
    table = pd.DataFrame(
        rows,
        columns=["lag_samples", "lag_time", "autocorrelation", "average_mutual_information"],
    )
    local_min = np.zeros(len(table), dtype=bool)
    ami = table["average_mutual_information"].to_numpy()
    candidates = np.where((ami[1:-1] < ami[:-2]) & (ami[1:-1] <= ami[2:]))[0]
    if candidates.size:
        local_min[candidates[0] + 1] = True
    table["first_ami_local_minimum"] = local_min

    return EmbeddingDelayDiagnosticResult(
        table=table,
        curve_id=trajectories.curve_ids[curve_index],
        dimension=dimension,
        bins=int(bins),
        max_lag_samples=max_lag_samples,
        time_unit=trajectories.time_unit,
        provenance={
            "operation": "embedding_delay_diagnostics",
            "source_provenance": dict(trajectories.provenance),
            "criterion": "Fraser-Swinney average mutual information diagnostic",
            "automatic_delay_selection": False,
            "bins": int(bins),
            "histogram_edges_fixed_across_lags": True,
            "sampling_contract": "approximately_regular_grid",
            "sampling_step": float(sampling_step),
        },
    )

eyetrajectoriespy.embedding_dimension_diagnostics

embedding_dimension_diagnostics(trajectories: TrajectorySet, *, curve: int | str, dimension: str, delay: float | int, delay_units: str = 'samples', max_dimension: int = 10, theiler_window: float | int = 0, theiler_window_units: str = 'samples', rtol: float = 10.0, atol: float = 2.0) -> EmbeddingDimensionDiagnosticResult

Compute Kennel-style false-nearest-neighbor fractions across dimensions.

The diagnostic curve is returned without silently choosing an embedding dimension.

Source code in src/eyetrajectoriespy/embedding.py
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
def embedding_dimension_diagnostics(
    trajectories: TrajectorySet,
    *,
    curve: int | str,
    dimension: str,
    delay: float | int,
    delay_units: str = "samples",
    max_dimension: int = 10,
    theiler_window: float | int = 0,
    theiler_window_units: str = "samples",
    rtol: float = 10.0,
    atol: float = 2.0,
) -> EmbeddingDimensionDiagnosticResult:
    """Compute Kennel-style false-nearest-neighbor fractions across dimensions.

    The diagnostic curve is returned without silently choosing an embedding
    dimension.
    """

    if dimension not in trajectories.dimension_names:
        raise KeyError(f"Unknown dimension {dimension!r}")
    if not isinstance(max_dimension, (int, np.integer)) or max_dimension < 1:
        raise ValueError("max_dimension must be a positive integer")
    if not np.isfinite(rtol) or rtol <= 0 or not np.isfinite(atol) or atol <= 0:
        raise ValueError("rtol and atol must be positive finite values")

    sampling_step = _require_regular_temporal_grid(
        trajectories.time,
        context="embedding dimension diagnostics",
    )
    curve_index = _curve_index(trajectories, curve)
    signal = trajectories.dimension(dimension)[curve_index].astype(float, copy=False)
    _require_finite(signal, context="embedding dimension diagnostics")
    scale = float(np.std(signal, ddof=0))
    if scale <= 0:
        raise ValueError("false-nearest-neighbor diagnostics require a non-constant signal")
    delay_samples = _resolve_samples(
        trajectories.time,
        delay,
        units=delay_units,
        time_unit=trajectories.time_unit,
        name="delay",
    )
    theiler_samples = _resolve_samples(
        trajectories.time,
        theiler_window,
        units=theiler_window_units,
        time_unit=trajectories.time_unit,
        name="theiler_window",
        allow_zero=True,
    )

    rows = []
    for dimension_value in range(1, int(max_dimension) + 1):
        start = dimension_value * delay_samples
        if start >= signal.size - 1:
            break
        endpoints = np.arange(start, signal.size)
        states = np.column_stack(
            [
                signal[endpoints - lag * delay_samples]
                for lag in range(dimension_value)
            ]
        )
        added = signal[endpoints - dimension_value * delay_samples]
        neighbor_index, distance_m = _nearest_temporally_separated(
            states,
            theiler_window_samples=theiler_samples,
        )
        added_difference = np.abs(added - added[neighbor_index])
        distance_next = np.sqrt(distance_m**2 + added_difference**2)
        false = (added_difference / distance_m > float(rtol)) | (
            distance_next / scale > float(atol)
        )
        rows.append(
            {
                "embedding_dimension": dimension_value,
                "false_neighbor_fraction": float(np.mean(false)),
                "n_reference_states": int(states.shape[0]),
                "rtol": float(rtol),
                "atol": float(atol),
            }
        )
    if not rows:
        raise ValueError("no embedding dimensions are supported by the available series length")

    return EmbeddingDimensionDiagnosticResult(
        table=pd.DataFrame(rows),
        curve_id=trajectories.curve_ids[curve_index],
        dimension=dimension,
        delay_samples=delay_samples,
        theiler_window_samples=theiler_samples,
        rtol=float(rtol),
        atol=float(atol),
        provenance={
            "operation": "embedding_dimension_diagnostics",
            "source_provenance": dict(trajectories.provenance),
            "criterion": "Kennel-Brown-Abarbanel false nearest neighbors",
            "automatic_dimension_selection": False,
            "missing_policy": "error",
            "sampling_contract": "approximately_regular_grid",
            "sampling_step": float(sampling_step),
        },
    )

eyetrajectoriespy.plot_embedding_delay_diagnostics

plot_embedding_delay_diagnostics(result: EmbeddingDelayDiagnosticResult, *, ax=None)

Plot AMI and autocorrelation against lag without selecting a delay.

Source code in src/eyetrajectoriespy/nonlinear_plotting.py
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
def plot_embedding_delay_diagnostics(
    result: EmbeddingDelayDiagnosticResult,
    *,
    ax=None,
):
    """Plot AMI and autocorrelation against lag without selecting a delay."""

    if ax is None:
        _, ax = plt.subplots()
    table = result.table
    ax.plot(table["lag_time"], table["average_mutual_information"], marker="o", label="AMI")
    ax.set_xlabel(f"Lag ({result.time_unit})")
    ax.set_ylabel("Average mutual information")
    ax2 = ax.twinx()
    ax2.plot(table["lag_time"], table["autocorrelation"], linestyle="--", label="ACF")
    ax2.set_ylabel("Autocorrelation")
    marked = table["first_ami_local_minimum"].to_numpy(dtype=bool)
    if np.any(marked):
        ax.scatter(
            table.loc[marked, "lag_time"],
            table.loc[marked, "average_mutual_information"],
            marker="x",
            s=70,
            label="First AMI local minimum",
        )
    ax.set_title(f"Embedding-delay diagnostics: {result.curve_id} / {result.dimension}")
    return ax

eyetrajectoriespy.plot_embedding_dimension_diagnostics

plot_embedding_dimension_diagnostics(result: EmbeddingDimensionDiagnosticResult, *, ax=None)

Plot false-nearest-neighbor fraction against embedding dimension.

Source code in src/eyetrajectoriespy/nonlinear_plotting.py
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
def plot_embedding_dimension_diagnostics(
    result: EmbeddingDimensionDiagnosticResult,
    *,
    ax=None,
):
    """Plot false-nearest-neighbor fraction against embedding dimension."""

    if ax is None:
        _, ax = plt.subplots()
    table = result.table
    ax.plot(
        table["embedding_dimension"],
        table["false_neighbor_fraction"],
        marker="o",
    )
    ax.set_xlabel("Embedding dimension")
    ax.set_ylabel("False-nearest-neighbor fraction")
    ax.set_ylim(bottom=0)
    ax.set_title(f"FNN diagnostics: {result.curve_id} / {result.dimension}")
    return ax

Sparse recurrence and RQA

eyetrajectoriespy.RecurrenceResult dataclass

Sparse recurrence or cross-recurrence matrix plus explicit construction metadata.

Source code in src/eyetrajectoriespy/nonlinear_types.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
@dataclass(frozen=True)
class RecurrenceResult:
    """Sparse recurrence or cross-recurrence matrix plus explicit construction metadata."""

    matrix: csr_matrix
    time_a: np.ndarray
    time_b: np.ndarray
    source_curve_ids: tuple[str, ...]
    radius: float
    target_recurrence_rate: float | None
    achieved_recurrence_rate: float
    metric: str
    theiler_window_samples: int
    kind: str
    state_dimension: int
    provenance: Mapping[str, Any] = field(default_factory=dict)
    time_unit: str | None = None

    @property
    def shape(self) -> tuple[int, int]:
        return self.matrix.shape

eyetrajectoriespy.RecurrenceRadiusProfileResult dataclass

Exact recurrence-rate profile over an analyst-declared radius grid.

Source code in src/eyetrajectoriespy/nonlinear_types.py
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
@dataclass(frozen=True)
class RecurrenceRadiusProfileResult:
    """Exact recurrence-rate profile over an analyst-declared radius grid."""

    table: pd.DataFrame
    curve_id: str
    metric: str
    theiler_window_samples: int
    state_dimension: int
    eligible_pair_count: int
    provenance: Mapping[str, Any] = field(default_factory=dict)

    @property
    def n_radii(self) -> int:
        return len(self.table)

eyetrajectoriespy.RQAResult dataclass

Recurrence-quantification metrics with line-threshold provenance.

Source code in src/eyetrajectoriespy/nonlinear_types.py
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
@dataclass(frozen=True)
class RQAResult:
    """Recurrence-quantification metrics with line-threshold provenance."""

    recurrence_rate: float
    determinism: float
    mean_diagonal_length: float
    max_diagonal_length: int
    diagonal_entropy: float
    laminarity: float
    trapping_time: float
    max_vertical_length: int
    center_of_recurrence_mass: float
    n_recurrence_points: int
    n_diagonal_lines: int
    n_vertical_lines: int
    min_diagonal_length: int
    min_vertical_length: int
    provenance: Mapping[str, Any] = field(default_factory=dict)

eyetrajectoriespy.WindowedRQAResult dataclass

Time-resolved RQA metrics from explicitly sized sliding windows.

Source code in src/eyetrajectoriespy/nonlinear_types.py
181
182
183
184
185
186
187
188
189
190
@dataclass(frozen=True)
class WindowedRQAResult:
    """Time-resolved RQA metrics from explicitly sized sliding windows."""

    table: pd.DataFrame
    window_samples: int
    step_samples: int
    dropped_tail_samples: int
    time_unit: str
    provenance: Mapping[str, Any] = field(default_factory=dict)

eyetrajectoriespy.WindowedRQAFunctionalResult dataclass

Functional trajectory representation of windowed RQA across curves.

Source code in src/eyetrajectoriespy/nonlinear_types.py
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
@dataclass(frozen=True)
class WindowedRQAFunctionalResult:
    """Functional trajectory representation of windowed RQA across curves."""

    trajectories: "TrajectorySet"
    window_results: tuple[WindowedRQAResult, ...]
    metrics: tuple[str, ...]
    window_samples: int
    step_samples: int
    overlap_samples: int
    overlap_fraction: float
    dropped_tail_samples: int
    time_unit: str
    undefined_policy: str
    provenance: Mapping[str, Any] = field(default_factory=dict)

    @property
    def n_curves(self) -> int:
        return self.trajectories.n_curves

    @property
    def n_windows(self) -> int:
        return self.trajectories.n_time

eyetrajectoriespy.WindowedRQASensitivityResult dataclass

Declared window/step sensitivity analyses for functional RQA.

Source code in src/eyetrajectoriespy/nonlinear_types.py
218
219
220
221
222
223
224
225
226
227
228
229
230
231
@dataclass(frozen=True)
class WindowedRQASensitivityResult:
    """Declared window/step sensitivity analyses for functional RQA."""

    analyses: tuple[WindowedRQAFunctionalResult, ...]
    design_table: pd.DataFrame
    summary_table: pd.DataFrame
    pairwise_table: pd.DataFrame
    metrics: tuple[str, ...]
    provenance: Mapping[str, Any] = field(default_factory=dict)

    @property
    def n_specifications(self) -> int:
        return len(self.analyses)

eyetrajectoriespy.WindowedRQAMeanBandResult dataclass

Unit-level simultaneous mean band for functional RQA trajectories.

Source code in src/eyetrajectoriespy/nonlinear_types.py
234
235
236
237
238
239
240
241
242
@dataclass(frozen=True)
class WindowedRQAMeanBandResult:
    """Unit-level simultaneous mean band for functional RQA trajectories."""

    functional_rqa: WindowedRQAFunctionalResult
    band: "FunctionalMeanBandResult"
    unit: str
    participant_column: str | None
    provenance: Mapping[str, Any] = field(default_factory=dict)

eyetrajectoriespy.recurrence_matrix

recurrence_matrix(source: TrajectorySet | DelayEmbeddingResult, *, curve: int | str, radius: float | None = None, target_recurrence_rate: float | None = None, metric: str = 'euclidean', theiler_window: float | int = 0, theiler_window_units: str = 'samples', dimensions: Sequence[str] | None = None) -> RecurrenceResult

Construct a sparse symmetric recurrence matrix for one trajectory.

Exactly one radius policy must be declared. The main diagonal and all pairs within the explicit Theiler window are excluded.

Source code in src/eyetrajectoriespy/recurrence.py
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
def recurrence_matrix(
    source: TrajectorySet | DelayEmbeddingResult,
    *,
    curve: int | str,
    radius: float | None = None,
    target_recurrence_rate: float | None = None,
    metric: str = "euclidean",
    theiler_window: float | int = 0,
    theiler_window_units: str = "samples",
    dimensions: Sequence[str] | None = None,
) -> RecurrenceResult:
    """Construct a sparse symmetric recurrence matrix for one trajectory.

    Exactly one radius policy must be declared.  The main diagonal and all
    pairs within the explicit Theiler window are excluded.
    """

    _validate_radius_policy(radius, target_recurrence_rate)
    if metric not in _METRIC_P:
        raise ValueError(f"metric must be one of {sorted(_METRIC_P)}")
    states, time, curve_id, time_unit, source_info = _state_from_source(
        source, curve=curve, dimensions=dimensions
    )
    theiler = _resolve_samples(
        time,
        theiler_window,
        units=theiler_window_units,
        time_unit=time_unit,
        name="theiler_window",
        allow_zero=True,
    )
    eligible = _eligible_auto_pairs(states.shape[0], theiler)
    if eligible <= 0:
        raise ValueError("Theiler window leaves no eligible recurrence pairs")
    p = _METRIC_P[metric]
    chosen_radius = (
        float(radius)
        if radius is not None
        else _auto_radius_for_target(
            states, float(target_recurrence_rate), p=p, theiler=theiler
        )
    )
    pairs = _filter_auto_pairs(
        cKDTree(states).query_pairs(chosen_radius, p=p, output_type="set"),
        theiler,
    )
    if pairs:
        upper_i = np.asarray([i for i, _ in pairs], dtype=int)
        upper_j = np.asarray([j for _, j in pairs], dtype=int)
        rows = np.concatenate([upper_i, upper_j])
        cols = np.concatenate([upper_j, upper_i])
        data = np.ones(rows.size, dtype=bool)
        matrix = coo_matrix((data, (rows, cols)), shape=(states.shape[0], states.shape[0])).tocsr()
    else:
        matrix = csr_matrix((states.shape[0], states.shape[0]), dtype=bool)
    achieved = len(pairs) / eligible
    return RecurrenceResult(
        matrix=matrix,
        time_a=time,
        time_b=time,
        source_curve_ids=(curve_id,),
        radius=chosen_radius,
        target_recurrence_rate=(
            None if target_recurrence_rate is None else float(target_recurrence_rate)
        ),
        achieved_recurrence_rate=float(achieved),
        metric=metric,
        theiler_window_samples=theiler,
        kind="auto",
        state_dimension=states.shape[1],
        time_unit=time_unit,
        provenance={
            "operation": "recurrence_matrix",
            **source_info,
            "radius_policy": "fixed" if radius is not None else "target_recurrence_rate",
            "target_recurrence_rate": target_recurrence_rate,
            "achieved_recurrence_rate": float(achieved),
            "metric": metric,
            "theiler_window_samples": theiler,
            "sparse": True,
            "threshold_operator": "<=",
            "diagonal_included": False,
            "recurrence_rate_scale": "0_to_1",
            "recurrence_rate_denominator": (
                "eligible_off_diagonal_pairs_outside_theiler_window"
            ),
            "target_rate_tie_policy": (
                None
                if target_recurrence_rate is None
                else (
                    "inclusive-radius bisection; achieved recurrence rate is retained "
                    "because distance ties can prevent an exact target"
                )
            ),
        },
    )

eyetrajectoriespy.recurrence_radius_profile

recurrence_radius_profile(source: TrajectorySet | DelayEmbeddingResult, *, curve: int | str, radii: Sequence[float], metric: str = 'euclidean', theiler_window: float | int = 0, theiler_window_units: str = 'samples', dimensions: Sequence[str] | None = None) -> RecurrenceRadiusProfileResult

Compute exact recurrence density over a declared increasing radius grid.

The table is the empirical cumulative distribution of eligible pairwise state-space distances evaluated at the supplied radii. It is computed without materializing an N x N distance matrix.

Theiler-excluded temporal neighbors are removed from both the pair counts and recurrence-rate denominator using the same contract as :func:recurrence_matrix. No radius is selected automatically.

Source code in src/eyetrajectoriespy/recurrence.py
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
def recurrence_radius_profile(
    source: TrajectorySet | DelayEmbeddingResult,
    *,
    curve: int | str,
    radii: Sequence[float],
    metric: str = "euclidean",
    theiler_window: float | int = 0,
    theiler_window_units: str = "samples",
    dimensions: Sequence[str] | None = None,
) -> RecurrenceRadiusProfileResult:
    """Compute exact recurrence density over a declared increasing radius grid.

    The table is the empirical cumulative distribution of eligible pairwise
    state-space distances evaluated at the supplied radii. It is computed
    without materializing an N x N distance matrix.

    Theiler-excluded temporal neighbors are removed from both the pair counts
    and recurrence-rate denominator using the same contract as
    :func:`recurrence_matrix`. No radius is selected automatically.
    """

    if metric not in _METRIC_P:
        raise ValueError(f"metric must be one of {sorted(_METRIC_P)}")
    if isinstance(radii, (str, bytes)):
        raise TypeError("radii must be a non-string sequence")
    radius_values = tuple(radii)
    if len(radius_values) < 2:
        raise ValueError("radii must contain at least two values")
    for value in radius_values:
        if isinstance(value, (bool, np.bool_)) or not isinstance(
            value,
            (int, float, np.integer, np.floating),
        ):
            raise TypeError("radii values must be numeric and not boolean")
    radius_array = np.asarray(radius_values, dtype=float)
    if not np.all(np.isfinite(radius_array)) or np.any(radius_array <= 0):
        raise ValueError("radii must contain only positive finite values")
    if not np.all(np.diff(radius_array) > 0):
        raise ValueError(
            "radii must be strictly increasing with no duplicates; "
            "the package does not sort or deduplicate the declared profile grid"
        )

    states, time, curve_id, time_unit, source_info = _state_from_source(
        source,
        curve=curve,
        dimensions=dimensions,
    )
    theiler = _resolve_samples(
        time,
        theiler_window,
        units=theiler_window_units,
        time_unit=time_unit,
        name="theiler_window",
        allow_zero=True,
    )
    eligible = _eligible_auto_pairs(states.shape[0], theiler)
    if eligible <= 0:
        raise ValueError("Theiler window leaves no eligible recurrence pairs")

    p = _METRIC_P[metric]
    tree = cKDTree(states)
    ordered_counts = np.asarray(
        tree.count_neighbors(tree, radius_array, p=p, cumulative=True),
        dtype=np.int64,
    )
    unique_off_diagonal = (ordered_counts - states.shape[0]) // 2

    excluded = np.zeros(radius_array.size, dtype=np.int64)
    for lag in range(1, theiler + 1):
        differences = states[lag:] - states[:-lag]
        if p == np.inf:
            distances = np.max(np.abs(differences), axis=1)
        elif p == 1.0:
            distances = np.sum(np.abs(differences), axis=1)
        else:
            distances = np.sqrt(np.sum(differences * differences, axis=1))
        distances.sort()
        excluded += np.searchsorted(
            distances,
            radius_array,
            side="right",
        ).astype(np.int64)

    recurrent_pairs = unique_off_diagonal - excluded
    if np.any(recurrent_pairs < 0):
        raise RuntimeError("Theiler correction produced a negative pair count")
    if np.any(np.diff(recurrent_pairs) < 0):
        raise RuntimeError("cumulative recurrence counts must be non-decreasing")

    recurrence_rate = recurrent_pairs.astype(float) / float(eligible)
    shell_pairs = np.diff(
        np.concatenate([np.array([0], dtype=np.int64), recurrent_pairs])
    )
    shell_fraction = shell_pairs.astype(float) / float(eligible)

    table = pd.DataFrame(
        {
            "radius": radius_array,
            "cumulative_recurrent_pairs": recurrent_pairs,
            "recurrence_rate": recurrence_rate,
            "shell_pair_count": shell_pairs,
            "shell_pair_fraction": shell_fraction,
            "excluded_theiler_pairs_within_radius": excluded,
        }
    )
    table["previous_radius"] = np.concatenate(
        [np.array([np.nan]), radius_array[:-1]]
    )
    table = table[
        [
            "previous_radius",
            "radius",
            "shell_pair_count",
            "shell_pair_fraction",
            "cumulative_recurrent_pairs",
            "recurrence_rate",
            "excluded_theiler_pairs_within_radius",
        ]
    ]

    return RecurrenceRadiusProfileResult(
        table=table,
        curve_id=curve_id,
        metric=metric,
        theiler_window_samples=theiler,
        state_dimension=states.shape[1],
        eligible_pair_count=int(eligible),
        provenance={
            "operation": "recurrence_radius_profile",
            **source_info,
            "metric": metric,
            "theiler_window_samples": theiler,
            "theiler_window_units_requested": theiler_window_units,
            "eligible_pair_count": int(eligible),
            "radius_grid_declared_by_user": True,
            "automatic_radius_selection": False,
            "radius_grid_sorted_or_deduplicated": False,
            "threshold_operator": "<=",
            "recurrence_rate_scale": "0_to_1",
            "recurrence_rate_denominator": (
                "eligible_off_diagonal_pairs_outside_theiler_window"
            ),
            "distance_profile_interpretation": (
                "recurrence_rate is the empirical CDF of eligible pairwise "
                "state-space distances evaluated at each declared radius; "
                "shell_pair_fraction is the empirical mass in "
                "the first shell d <= radius[0], then "
                "(previous_radius, radius] for later rows"
            ),
            "distance_matrix_materialized": False,
            "maximum_radius_coverage_fraction": float(recurrence_rate[-1]),
            "full_distance_distribution_captured": bool(
                np.isclose(recurrence_rate[-1], 1.0)
            ),
        },
    )

eyetrajectoriespy.rqa_metrics

rqa_metrics(recurrence: RecurrenceResult, *, min_diagonal_length: int = 2, min_vertical_length: int = 2) -> RQAResult

Compute standard line-based recurrence-quantification metrics.

Source code in src/eyetrajectoriespy/recurrence.py
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
def rqa_metrics(
    recurrence: RecurrenceResult,
    *,
    min_diagonal_length: int = 2,
    min_vertical_length: int = 2,
) -> RQAResult:
    """Compute standard line-based recurrence-quantification metrics."""

    if min_diagonal_length < 1 or min_vertical_length < 1:
        raise ValueError("minimum line lengths must be positive integers")
    step_a, step_b = _line_rqa_sampling_steps(recurrence)
    matrix = recurrence.matrix.astype(bool).tocsr()
    if recurrence.kind == "auto":
        upper = triu(matrix, k=1).tocsr()
        denominator_points = int(upper.nnz)
        diagonal_lengths_all = _diagonal_lengths(matrix, upper_only=True)
        if denominator_points:
            coo = upper.tocoo()
            weighted_lag = float(np.sum(coo.col - coo.row))
            corm = 100.0 * weighted_lag / (
                max(matrix.shape[0] - 1, 1) * denominator_points
            )
        else:
            corm = float("nan")
    elif recurrence.kind == "cross":
        denominator_points = int(matrix.nnz)
        diagonal_lengths_all = _diagonal_lengths(matrix, upper_only=False)
        corm = float("nan")
    else:
        raise ValueError(f"Unknown recurrence kind {recurrence.kind!r}")

    diagonal_lengths = [x for x in diagonal_lengths_all if x >= min_diagonal_length]
    vertical_lengths_all = _vertical_lengths(matrix)
    vertical_lengths = [x for x in vertical_lengths_all if x >= min_vertical_length]

    diagonal_points = int(sum(diagonal_lengths))
    vertical_points = int(sum(vertical_lengths))
    denominator_vertical = int(matrix.nnz)

    det = (
        diagonal_points / denominator_points
        if denominator_points
        else float("nan")
    )
    lam = (
        vertical_points / denominator_vertical
        if denominator_vertical
        else float("nan")
    )
    return RQAResult(
        recurrence_rate=float(recurrence.achieved_recurrence_rate),
        determinism=float(det),
        mean_diagonal_length=(
            float(np.mean(diagonal_lengths)) if diagonal_lengths else float("nan")
        ),
        max_diagonal_length=max(diagonal_lengths, default=0),
        diagonal_entropy=_line_entropy(diagonal_lengths),
        laminarity=float(lam),
        trapping_time=(
            float(np.mean(vertical_lengths)) if vertical_lengths else float("nan")
        ),
        max_vertical_length=max(vertical_lengths, default=0),
        center_of_recurrence_mass=float(corm),
        n_recurrence_points=denominator_points,
        n_diagonal_lines=len(diagonal_lengths),
        n_vertical_lines=len(vertical_lengths),
        min_diagonal_length=int(min_diagonal_length),
        min_vertical_length=int(min_vertical_length),
        provenance={
            "operation": "rqa_metrics",
            "recurrence_provenance": dict(recurrence.provenance),
            "min_diagonal_length": int(min_diagonal_length),
            "min_vertical_length": int(min_vertical_length),
            "sampling_contract": "approximately_regular_grid",
            "sampling_step_a": float(step_a),
            "sampling_step_b": None if step_b is None else float(step_b),
            "ratio_scale": "0_to_1",
            "recurrence_rate_denominator_policy": recurrence.provenance.get(
                "recurrence_rate_denominator"
            ),
            "line_border_policy": (
                "finite-matrix border lines are counted at their observed length; "
                "no border-effect correction is applied"
            ),
            "line_entropy_probability": (
                "frequency of each qualifying line length divided by the total "
                "number of qualifying lines"
            ),
            "corm_definition": (
                "100 * mean upper-triangle recurrence lag / (n-1)"
                if recurrence.kind == "auto"
                else "not_defined_for_cross_recurrence"
            ),
        },
    )

eyetrajectoriespy.windowed_rqa

windowed_rqa(trajectories: TrajectorySet, *, curve: int | str, window: float | int, step: float | int, window_units: str = 'samples', step_units: str = 'samples', radius: float | None = None, target_recurrence_rate: float | None = None, metric: str = 'euclidean', theiler_window: float | int = 0, theiler_window_units: str = 'samples', dimensions: Sequence[str] | None = None, min_diagonal_length: int = 2, min_vertical_length: int = 2) -> WindowedRQAResult

Compute RQA in full sliding windows while reporting any trailing tail.

Source code in src/eyetrajectoriespy/recurrence.py
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
def windowed_rqa(
    trajectories: TrajectorySet,
    *,
    curve: int | str,
    window: float | int,
    step: float | int,
    window_units: str = "samples",
    step_units: str = "samples",
    radius: float | None = None,
    target_recurrence_rate: float | None = None,
    metric: str = "euclidean",
    theiler_window: float | int = 0,
    theiler_window_units: str = "samples",
    dimensions: Sequence[str] | None = None,
    min_diagonal_length: int = 2,
    min_vertical_length: int = 2,
) -> WindowedRQAResult:
    """Compute RQA in full sliding windows while reporting any trailing tail."""

    window_samples = _resolve_samples(
        trajectories.time,
        window,
        units=window_units,
        time_unit=trajectories.time_unit,
        name="window",
    )
    step_samples = _resolve_samples(
        trajectories.time,
        step,
        units=step_units,
        time_unit=trajectories.time_unit,
        name="step",
    )
    if window_samples < 3:
        raise ValueError("window must contain at least three samples")
    if step_samples > window_samples:
        raise ValueError(
            "step cannot exceed window because that would leave internal samples "
            "unanalyzed; use step <= window"
        )
    index = _curve_index(trajectories, curve)
    starts = np.arange(0, trajectories.n_time - window_samples + 1, step_samples)
    if starts.size == 0:
        raise ValueError("window is longer than the available trajectory")

    rows = []
    for start in starts:
        stop = int(start + window_samples)
        subset = TrajectorySet(
            time=trajectories.time[start:stop],
            values=trajectories.values[index : index + 1, start:stop, :],
            curve_ids=(trajectories.curve_ids[index],),
            dimension_names=trajectories.dimension_names,
            metadata=trajectories.metadata.iloc[[index]].reset_index(drop=True),
            coordinate_system=trajectories.coordinate_system,
            time_unit=trajectories.time_unit,
            provenance={
                **dict(trajectories.provenance),
                "window_start_index": int(start),
                "window_stop_index": stop,
            },
        )
        rec = recurrence_matrix(
            subset,
            curve=0,
            radius=radius,
            target_recurrence_rate=target_recurrence_rate,
            metric=metric,
            theiler_window=theiler_window,
            theiler_window_units=theiler_window_units,
            dimensions=dimensions,
        )
        metrics = rqa_metrics(
            rec,
            min_diagonal_length=min_diagonal_length,
            min_vertical_length=min_vertical_length,
        )
        rows.append(
            {
                "start_index": int(start),
                "stop_index": stop,
                "start_time": float(trajectories.time[start]),
                "end_time": float(trajectories.time[stop - 1]),
                "center_time": float(
                    0.5 * (trajectories.time[start] + trajectories.time[stop - 1])
                ),
                "radius": rec.radius,
                "recurrence_rate": metrics.recurrence_rate,
                "determinism": metrics.determinism,
                "mean_diagonal_length": metrics.mean_diagonal_length,
                "max_diagonal_length": metrics.max_diagonal_length,
                "diagonal_entropy": metrics.diagonal_entropy,
                "laminarity": metrics.laminarity,
                "trapping_time": metrics.trapping_time,
                "max_vertical_length": metrics.max_vertical_length,
                "center_of_recurrence_mass": metrics.center_of_recurrence_mass,
            }
        )
    last_stop = int(starts[-1] + window_samples)
    dropped_tail = trajectories.n_time - last_stop
    return WindowedRQAResult(
        table=pd.DataFrame(rows),
        window_samples=window_samples,
        step_samples=step_samples,
        dropped_tail_samples=int(dropped_tail),
        time_unit=trajectories.time_unit,
        provenance={
            "operation": "windowed_rqa",
            "source_provenance": dict(trajectories.provenance),
            "curve_id": trajectories.curve_ids[index],
            "window_samples": window_samples,
            "step_samples": step_samples,
            "dropped_tail_samples": int(dropped_tail),
            "tail_policy": "full_windows_only_with_explicit_tail_count",
            "radius_policy": "fixed" if radius is not None else "target_recurrence_rate",
        },
    )

eyetrajectoriespy.windowed_rqa_trajectory_set

windowed_rqa_trajectory_set(trajectories: TrajectorySet, *, metrics: Sequence[str], window: float | int, step: float | int, window_units: str = 'samples', step_units: str = 'samples', radius: float | None = None, target_recurrence_rate: float | None = None, metric: str = 'euclidean', theiler_window: float | int = 0, theiler_window_units: str = 'samples', dimensions: Sequence[str] | None = None, min_diagonal_length: int = 2, min_vertical_length: int = 2, undefined_policy: str = 'raise') -> WindowedRQAFunctionalResult

Convert per-curve sliding-window RQA into functional trajectories.

Every input curve is analyzed with the same declared recurrence contract. Window-center times become the common functional grid and selected RQA metrics become functional dimensions. The complete per-curve WindowedRQAResult objects are retained so solved radii and window-level diagnostics are never discarded.

undefined_policy='raise' rejects any undefined selected metric. undefined_policy='keep' retains undefined values as NaN. No imputation is performed.

Overlapping windows deterministically reuse source samples. The result records this overlap and never describes window rows as independent observations. If target_recurrence_rate is used, recurrence_rate cannot be selected as a functional outcome because its density is controlled by construction.

Source code in src/eyetrajectoriespy/recurrence.py
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
def windowed_rqa_trajectory_set(
    trajectories: TrajectorySet,
    *,
    metrics: Sequence[str],
    window: float | int,
    step: float | int,
    window_units: str = "samples",
    step_units: str = "samples",
    radius: float | None = None,
    target_recurrence_rate: float | None = None,
    metric: str = "euclidean",
    theiler_window: float | int = 0,
    theiler_window_units: str = "samples",
    dimensions: Sequence[str] | None = None,
    min_diagonal_length: int = 2,
    min_vertical_length: int = 2,
    undefined_policy: str = "raise",
) -> WindowedRQAFunctionalResult:
    """Convert per-curve sliding-window RQA into functional trajectories.

    Every input curve is analyzed with the same declared recurrence contract.
    Window-center times become the common functional grid and selected RQA
    metrics become functional dimensions. The complete per-curve
    WindowedRQAResult objects are retained so solved radii and window-level
    diagnostics are never discarded.

    undefined_policy='raise' rejects any undefined selected metric.
    undefined_policy='keep' retains undefined values as NaN. No imputation is
    performed.

    Overlapping windows deterministically reuse source samples. The result
    records this overlap and never describes window rows as independent
    observations. If target_recurrence_rate is used, recurrence_rate cannot be
    selected as a functional outcome because its density is controlled by
    construction.
    """

    if trajectories.n_curves < 1:
        raise ValueError("functional windowed RQA requires at least one source curve")
    metric_names = tuple(str(name) for name in metrics)
    if not metric_names:
        raise ValueError("metrics must contain at least one RQA metric")
    if len(set(metric_names)) != len(metric_names):
        raise ValueError("metrics must be unique")
    unknown = [
        name
        for name in metric_names
        if name not in _WINDOWED_RQA_FUNCTIONAL_METRIC_UNITS
    ]
    if unknown:
        raise KeyError(f"Unknown functional RQA metrics: {unknown}")
    if undefined_policy not in {"raise", "keep"}:
        raise ValueError("undefined_policy must be 'raise' or 'keep'")
    if target_recurrence_rate is not None and "recurrence_rate" in metric_names:
        raise ValueError(
            "recurrence_rate cannot be a functional outcome when "
            "recurrence density is controlled by design through "
            "target_recurrence_rate; "
            "use a fixed radius or omit recurrence_rate"
        )

    per_curve = tuple(
        windowed_rqa(
            trajectories,
            curve=curve_index,
            window=window,
            step=step,
            window_units=window_units,
            step_units=step_units,
            radius=radius,
            target_recurrence_rate=target_recurrence_rate,
            metric=metric,
            theiler_window=theiler_window,
            theiler_window_units=theiler_window_units,
            dimensions=dimensions,
            min_diagonal_length=min_diagonal_length,
            min_vertical_length=min_vertical_length,
        )
        for curve_index in range(trajectories.n_curves)
    )

    first = per_curve[0]
    center_time = first.table["center_time"].to_numpy(dtype=float)
    if center_time.size < 2:
        raise ValueError(
            "functional windowed RQA requires at least two complete windows; "
            "reduce window, reduce step, or provide a longer trajectory"
        )
    for result in per_curve[1:]:
        candidate_time = result.table["center_time"].to_numpy(dtype=float)
        if candidate_time.shape != center_time.shape or not np.array_equal(
            candidate_time,
            center_time,
        ):
            raise RuntimeError(
                "common-grid input produced inconsistent window-center grids"
            )
        if result.window_samples != first.window_samples:
            raise RuntimeError("window sample counts differ across curves")
        if result.step_samples != first.step_samples:
            raise RuntimeError("step sample counts differ across curves")
        if result.dropped_tail_samples != first.dropped_tail_samples:
            raise RuntimeError("tail accounting differs across curves")

    values = np.stack(
        [
            result.table.loc[:, list(metric_names)].to_numpy(dtype=float)
            for result in per_curve
        ],
        axis=0,
    )
    nonfinite = ~np.isfinite(values)
    if np.any(nonfinite) and undefined_policy == "raise":
        curve_index, window_index, metric_index = np.argwhere(nonfinite)[0]
        raise ValueError(
            "selected windowed RQA metric is undefined: "
            f"curve={trajectories.curve_ids[int(curve_index)]!r}, "
            f"window_index={int(window_index)}, "
            f"metric={metric_names[int(metric_index)]!r}; "
            "change the recurrence/line-threshold contract or set "
            "undefined_policy='keep' to retain NaN explicitly"
        )

    overlap_samples = max(first.window_samples - first.step_samples, 0)
    overlap_fraction = overlap_samples / first.window_samples
    functional = TrajectorySet(
        time=center_time,
        values=values,
        curve_ids=trajectories.curve_ids,
        dimension_names=metric_names,
        metadata=trajectories.metadata.reset_index(drop=True),
        coordinate_system="rqa_metrics",
        time_unit=trajectories.time_unit,
        provenance={
            "operation": "windowed_rqa_trajectory_set",
            "source_provenance": dict(trajectories.provenance),
            "source_coordinate_system": trajectories.coordinate_system,
            "source_dimension_names": (
                tuple(dimensions) if dimensions is not None else None
            ),
            "metrics": metric_names,
            "metric_units": {
                name: _WINDOWED_RQA_FUNCTIONAL_METRIC_UNITS[name]
                for name in metric_names
            },
            "window_samples": first.window_samples,
            "step_samples": first.step_samples,
            "overlap_samples": overlap_samples,
            "overlap_fraction": float(overlap_fraction),
            "overlapping_windows": bool(overlap_samples > 0),
            "window_rows_are_independent": False,
            "window_center_definition": (
                "midpoint_of_first_and_last_observed_sample"
            ),
            "source_time_support": (
                float(trajectories.time[0]),
                float(trajectories.time[-1]),
            ),
            "functional_time_support": (
                float(center_time[0]),
                float(center_time[-1]),
            ),
            "leading_edge_span": float(center_time[0] - trajectories.time[0]),
            "trailing_edge_span": float(trajectories.time[-1] - center_time[-1]),
            "edge_policy": "full_window_centers_only",
            "dropped_tail_samples": first.dropped_tail_samples,
            "tail_policy": "full_windows_only_with_explicit_tail_count",
            "radius_policy": (
                "fixed" if radius is not None else "target_recurrence_rate"
            ),
            "fixed_radius": None if radius is None else float(radius),
            "target_recurrence_rate": (
                None
                if target_recurrence_rate is None
                else float(target_recurrence_rate)
            ),
            "recurrence_rate_controlled_by_design": bool(
                target_recurrence_rate is not None
            ),
            "distance_metric": metric,
            "theiler_window": theiler_window,
            "theiler_window_units": theiler_window_units,
            "min_diagonal_length": int(min_diagonal_length),
            "min_vertical_length": int(min_vertical_length),
            "undefined_policy": undefined_policy,
            "undefined_value_count": int(np.sum(nonfinite)),
            "downstream_note": (
                "Windowed metrics are functional summaries, not independent "
                "window-level observations; preserve the curve/participant "
                "sampling unit in downstream inference."
            ),
        },
    )
    return WindowedRQAFunctionalResult(
        trajectories=functional,
        window_results=per_curve,
        metrics=metric_names,
        window_samples=first.window_samples,
        step_samples=first.step_samples,
        overlap_samples=overlap_samples,
        overlap_fraction=float(overlap_fraction),
        dropped_tail_samples=first.dropped_tail_samples,
        time_unit=trajectories.time_unit,
        undefined_policy=undefined_policy,
        provenance={
            "operation": "windowed_rqa_trajectory_set",
            "functional_trajectory_provenance": dict(functional.provenance),
        },
    )

eyetrajectoriespy.windowed_rqa_sensitivity

windowed_rqa_sensitivity(trajectories: TrajectorySet, *, metrics: Sequence[str], window_step_pairs: Sequence[tuple[float | int, float | int]], window_units: str = 'samples', step_units: str = 'samples', radius: float | None = None, target_recurrence_rate: float | None = None, metric: str = 'euclidean', theiler_window: float | int = 0, theiler_window_units: str = 'samples', dimensions: Sequence[str] | None = None, min_diagonal_length: int = 2, min_vertical_length: int = 2, undefined_policy: str = 'raise') -> WindowedRQASensitivityResult

Evaluate declared window/step specifications without selecting one.

Every specification is run through windowed_rqa_trajectory_set under the same recurrence contract. No interpolation is used to compare profiles from different specifications. Pairwise shape diagnostics use exact shared window-center times only.

The design table quantifies deterministic sample reuse caused by overlap. Its profile-grid spacing is the temporal spacing of the derived RQA function, not an estimate of independent-information resolution.

Source code in src/eyetrajectoriespy/functional_rqa.py
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
def windowed_rqa_sensitivity(
    trajectories: TrajectorySet,
    *,
    metrics: Sequence[str],
    window_step_pairs: Sequence[tuple[float | int, float | int]],
    window_units: str = "samples",
    step_units: str = "samples",
    radius: float | None = None,
    target_recurrence_rate: float | None = None,
    metric: str = "euclidean",
    theiler_window: float | int = 0,
    theiler_window_units: str = "samples",
    dimensions: Sequence[str] | None = None,
    min_diagonal_length: int = 2,
    min_vertical_length: int = 2,
    undefined_policy: str = "raise",
) -> WindowedRQASensitivityResult:
    """Evaluate declared window/step specifications without selecting one.

    Every specification is run through windowed_rqa_trajectory_set under the
    same recurrence contract. No interpolation is used to compare profiles
    from different specifications. Pairwise shape diagnostics use exact shared
    window-center times only.

    The design table quantifies deterministic sample reuse caused by overlap.
    Its profile-grid spacing is the temporal spacing of the derived RQA
    function, not an estimate of independent-information resolution.
    """

    pairs = _validated_window_step_pairs(window_step_pairs)
    metric_names = tuple(str(name) for name in metrics)
    analyses = tuple(
        windowed_rqa_trajectory_set(
            trajectories,
            metrics=metric_names,
            window=window,
            step=step,
            window_units=window_units,
            step_units=step_units,
            radius=radius,
            target_recurrence_rate=target_recurrence_rate,
            metric=metric,
            theiler_window=theiler_window,
            theiler_window_units=theiler_window_units,
            dimensions=dimensions,
            min_diagonal_length=min_diagonal_length,
            min_vertical_length=min_vertical_length,
            undefined_policy=undefined_policy,
        )
        for window, step in pairs
    )

    resolved_pairs = tuple(
        (analysis.window_samples, analysis.step_samples)
        for analysis in analyses
    )
    if len(set(resolved_pairs)) != len(resolved_pairs):
        raise ValueError(
            "two or more requested window/step specifications resolve to the "
            "same sample counts; remove duplicate resolved specifications"
        )

    specification_ids = tuple(
        f"spec_{index + 1}" for index in range(len(analyses))
    )
    design_rows: list[dict[str, float | int | str]] = []
    for specification_id, requested, analysis in zip(
        specification_ids, pairs, analyses, strict=True
    ):
        first_window = analysis.window_results[0]
        table = first_window.table
        starts = table["start_index"].to_numpy(dtype=int)
        centers = analysis.trajectories.time
        profile_grid_spacing = (
            float(np.median(np.diff(centers)))
            if centers.size > 1
            else float("nan")
        )
        window_span = float(
            table["end_time"].iloc[0] - table["start_time"].iloc[0]
        )
        reuse = _window_reuse_diagnostics(
            trajectories.n_time,
            starts=starts,
            window_samples=analysis.window_samples,
        )
        design_rows.append(
            {
                "specification_id": specification_id,
                "requested_window": float(requested[0]),
                "requested_step": float(requested[1]),
                "window_units": window_units,
                "step_units": step_units,
                "window_samples": int(analysis.window_samples),
                "step_samples": int(analysis.step_samples),
                "window_span_time": window_span,
                "profile_grid_spacing_time": profile_grid_spacing,
                "profile_time_unit": trajectories.time_unit,
                "n_windows": int(analysis.n_windows),
                "overlap_samples": int(analysis.overlap_samples),
                "overlap_fraction": float(analysis.overlap_fraction),
                "functional_support_start": float(centers[0]),
                "functional_support_end": float(centers[-1]),
                "dropped_tail_samples": int(
                    analysis.dropped_tail_samples
                ),
                **reuse,
            }
        )

    design_table = pd.DataFrame(design_rows)
    summary_table = _descriptive_summary(
        analyses,
        specification_ids,
        metric_names,
    )
    pairwise_table = _pairwise_exact_center_comparison(
        analyses,
        specification_ids,
        metric_names,
    )

    return WindowedRQASensitivityResult(
        analyses=analyses,
        design_table=design_table,
        summary_table=summary_table,
        pairwise_table=pairwise_table,
        metrics=metric_names,
        provenance={
            "operation": "windowed_rqa_sensitivity",
            "source_provenance": dict(trajectories.provenance),
            "specification_ids": specification_ids,
            "requested_window_step_pairs": tuple(
                (float(window), float(step)) for window, step in pairs
            ),
            "resolved_window_step_samples": resolved_pairs,
            "no_automatic_specification_selection": True,
            "pairwise_time_alignment": "exact_shared_window_centers_only",
            "interpolation_used_for_sensitivity_comparison": False,
            "summary_statistics_are_descriptive_not_inferential": True,
            "profile_grid_spacing_interpretation": (
                "derived-function temporal grid spacing; not an estimate of "
                "independent-information resolution"
            ),
            "window_dependence_interpretation": (
                "sample-reuse diagnostics quantify deterministic overlap only; "
                "serial dependence can remain even when overlap_fraction is zero"
            ),
        },
    )

eyetrajectoriespy.windowed_rqa_functional_mean_band

windowed_rqa_functional_mean_band(trajectories: TrajectorySet, *, metrics: Sequence[str], window: float | int, step: float | int, unit: str, participant_column: str | None = None, window_units: str = 'samples', step_units: str = 'samples', radius: float | None = None, target_recurrence_rate: float | None = None, metric: str = 'euclidean', theiler_window: float | int = 0, theiler_window_units: str = 'samples', dimensions: Sequence[str] | None = None, min_diagonal_length: int = 2, min_vertical_length: int = 2, confidence_level: float = 0.95, n_multiplier: int = 2000, random_state: int | None = 0) -> WindowedRQAMeanBandResult

Estimate a unit-level simultaneous mean band for functional RQA.

Windowed RQA is first computed once per source curve with undefined selected metrics rejected. The existing functional-mean multiplier band is then applied to complete derived functions, never to window rows.

unit="participant" averages repeated trial curves within participant before inference and requires participant_column. unit="curve" treats each source curve as an independent unit and therefore should only be used when that independence is scientifically justified.

Source code in src/eyetrajectoriespy/functional_rqa.py
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
def windowed_rqa_functional_mean_band(
    trajectories: TrajectorySet,
    *,
    metrics: Sequence[str],
    window: float | int,
    step: float | int,
    unit: str,
    participant_column: str | None = None,
    window_units: str = "samples",
    step_units: str = "samples",
    radius: float | None = None,
    target_recurrence_rate: float | None = None,
    metric: str = "euclidean",
    theiler_window: float | int = 0,
    theiler_window_units: str = "samples",
    dimensions: Sequence[str] | None = None,
    min_diagonal_length: int = 2,
    min_vertical_length: int = 2,
    confidence_level: float = 0.95,
    n_multiplier: int = 2000,
    random_state: int | None = 0,
) -> WindowedRQAMeanBandResult:
    """Estimate a unit-level simultaneous mean band for functional RQA.

    Windowed RQA is first computed once per source curve with undefined
    selected metrics rejected. The existing functional-mean multiplier band is
    then applied to complete derived functions, never to window rows.

    unit="participant" averages repeated trial curves within participant before
    inference and requires participant_column. unit="curve" treats each source
    curve as an independent unit and therefore should only be used when that
    independence is scientifically justified.
    """

    functional_rqa = windowed_rqa_trajectory_set(
        trajectories,
        metrics=metrics,
        window=window,
        step=step,
        window_units=window_units,
        step_units=step_units,
        radius=radius,
        target_recurrence_rate=target_recurrence_rate,
        metric=metric,
        theiler_window=theiler_window,
        theiler_window_units=theiler_window_units,
        dimensions=dimensions,
        min_diagonal_length=min_diagonal_length,
        min_vertical_length=min_vertical_length,
        undefined_policy="raise",
    )
    band = multiplier_functional_mean_band(
        functional_rqa.trajectories,
        confidence_level=confidence_level,
        n_multiplier=n_multiplier,
        unit=unit,
        participant_column=participant_column,
        random_state=random_state,
    )
    return WindowedRQAMeanBandResult(
        functional_rqa=functional_rqa,
        band=band,
        unit=unit,
        participant_column=participant_column,
        provenance={
            "operation": "windowed_rqa_functional_mean_band",
            "functional_rqa_provenance": dict(functional_rqa.provenance),
            "functional_mean_band_provenance": dict(band.provenance),
            "resampling_unit": unit,
            "participant_column": participant_column,
            "window_rows_resampled_as_independent": False,
            "whole_function_resampling_contract": True,
            "within_function_temporal_dependence_preserved": True,
            "within_source_curve_block_bootstrap": False,
            "inference_scope": (
                "simultaneous observed-grid mean band for independent "
                "curve/participant functional units"
            ),
        },
    )

eyetrajectoriespy.cross_recurrence_matrix

cross_recurrence_matrix(source_a: TrajectorySet | DelayEmbeddingResult, source_b: TrajectorySet | DelayEmbeddingResult, *, curve_a: int | str, curve_b: int | str, radius: float | None = None, target_recurrence_rate: float | None = None, metric: str = 'euclidean', dimensions_a: Sequence[str] | None = None, dimensions_b: Sequence[str] | None = None) -> RecurrenceResult

Construct a sparse cross-recurrence matrix between two trajectories.

Source code in src/eyetrajectoriespy/recurrence.py
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
def cross_recurrence_matrix(
    source_a: TrajectorySet | DelayEmbeddingResult,
    source_b: TrajectorySet | DelayEmbeddingResult,
    *,
    curve_a: int | str,
    curve_b: int | str,
    radius: float | None = None,
    target_recurrence_rate: float | None = None,
    metric: str = "euclidean",
    dimensions_a: Sequence[str] | None = None,
    dimensions_b: Sequence[str] | None = None,
) -> RecurrenceResult:
    """Construct a sparse cross-recurrence matrix between two trajectories."""

    _validate_radius_policy(radius, target_recurrence_rate)
    if metric not in _METRIC_P:
        raise ValueError(f"metric must be one of {sorted(_METRIC_P)}")
    a, time_a, id_a, unit_a, info_a = _state_from_source(
        source_a, curve=curve_a, dimensions=dimensions_a
    )
    b, time_b, id_b, unit_b, info_b = _state_from_source(
        source_b, curve=curve_b, dimensions=dimensions_b
    )
    if a.shape[1] != b.shape[1]:
        raise ValueError("cross-recurrence state spaces must have the same dimension")
    if info_a.get("state_names") != info_b.get("state_names"):
        raise ValueError(
            "cross-recurrence state spaces must use the same named state variables "
            "in the same order"
        )
    if info_a.get("coordinate_system") != info_b.get("coordinate_system"):
        raise ValueError(
            "cross-recurrence state spaces must use the same coordinate_system"
        )
    if unit_a != unit_b:
        raise ValueError("cross-recurrence trajectories must use the same time_unit")
    if info_a.get("source_kind") == info_b.get("source_kind") == "DelayEmbeddingResult":
        if info_a.get("embedding_dimension") != info_b.get("embedding_dimension"):
            raise ValueError("cross-recurrence embeddings must use the same embedding_dimension")
        if info_a.get("delay_samples") != info_b.get("delay_samples"):
            raise ValueError("cross-recurrence embeddings must use the same delay_samples")
        delay_a = info_a.get("delay_time")
        delay_b = info_b.get("delay_time")
        if np.isfinite(delay_a) != np.isfinite(delay_b):
            raise ValueError("cross-recurrence embeddings have incompatible delay-time semantics")
        if np.isfinite(delay_a) and not np.isclose(delay_a, delay_b):
            raise ValueError("cross-recurrence embeddings must use the same physical delay")
    p = _METRIC_P[metric]
    chosen_radius = (
        float(radius)
        if radius is not None
        else _cross_radius_for_target(a, b, float(target_recurrence_rate), p=p)
    )
    neighbors = cKDTree(a).query_ball_tree(cKDTree(b), chosen_radius, p=p)
    rows = []
    cols = []
    for i, js in enumerate(neighbors):
        rows.extend([i] * len(js))
        cols.extend(js)
    matrix = coo_matrix(
        (np.ones(len(rows), dtype=bool), (rows, cols)),
        shape=(a.shape[0], b.shape[0]),
    ).tocsr()
    achieved = matrix.nnz / (a.shape[0] * b.shape[0])
    return RecurrenceResult(
        matrix=matrix,
        time_a=time_a,
        time_b=time_b,
        source_curve_ids=(id_a, id_b),
        radius=chosen_radius,
        target_recurrence_rate=(
            None if target_recurrence_rate is None else float(target_recurrence_rate)
        ),
        achieved_recurrence_rate=float(achieved),
        metric=metric,
        theiler_window_samples=0,
        kind="cross",
        state_dimension=a.shape[1],
        time_unit=unit_a,
        provenance={
            "operation": "cross_recurrence_matrix",
            "source_a": info_a,
            "source_b": info_b,
            "radius_policy": "fixed" if radius is not None else "target_recurrence_rate",
            "target_recurrence_rate": target_recurrence_rate,
            "achieved_recurrence_rate": float(achieved),
            "metric": metric,
            "sparse": True,
            "threshold_operator": "<=",
            "recurrence_rate_scale": "0_to_1",
            "recurrence_rate_denominator": "all_cross_state_pairs",
            "time_alignment": "none",
            "target_rate_tie_policy": (
                None
                if target_recurrence_rate is None
                else (
                    "inclusive-radius bisection; achieved recurrence rate is retained "
                    "because distance ties can prevent an exact target"
                )
            ),
        },
    )

eyetrajectoriespy.cross_rqa_metrics

cross_rqa_metrics(recurrence: RecurrenceResult, *, min_diagonal_length: int = 2, min_vertical_length: int = 2) -> RQAResult

Compute line-based metrics for a cross-recurrence result.

Source code in src/eyetrajectoriespy/recurrence.py
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
def cross_rqa_metrics(
    recurrence: RecurrenceResult,
    *,
    min_diagonal_length: int = 2,
    min_vertical_length: int = 2,
) -> RQAResult:
    """Compute line-based metrics for a cross-recurrence result."""

    if recurrence.kind != "cross":
        raise ValueError("cross_rqa_metrics requires a cross-recurrence result")
    return rqa_metrics(
        recurrence,
        min_diagonal_length=min_diagonal_length,
        min_vertical_length=min_vertical_length,
    )

eyetrajectoriespy.plot_recurrence

plot_recurrence(result: RecurrenceResult, *, max_points: int | None = 200000, ax=None)

Plot the sparse recurrence matrix without densifying it.

Source code in src/eyetrajectoriespy/nonlinear_plotting.py
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
def plot_recurrence(
    result: RecurrenceResult,
    *,
    max_points: int | None = 200_000,
    ax=None,
):
    """Plot the sparse recurrence matrix without densifying it."""

    if max_points is not None and result.matrix.nnz > max_points:
        raise ValueError(
            "recurrence matrix exceeds max_points; increase max_points explicitly "
            "rather than silently subsampling recurrence points"
        )
    if ax is None:
        _, ax = plt.subplots()
    coo = result.matrix.tocoo()
    ax.scatter(coo.col, coo.row, s=4, marker="s")
    ax.set_xlabel("State index B" if result.kind == "cross" else "State index")
    ax.set_ylabel("State index A" if result.kind == "cross" else "State index")
    ax.invert_yaxis()
    ax.set_aspect("equal", adjustable="box")
    ax.set_title(
        f"{'Cross-' if result.kind == 'cross' else ''}recurrence "
        f"(RR={result.achieved_recurrence_rate:.3f})"
    )
    return ax

eyetrajectoriespy.plot_recurrence_rate_curve

plot_recurrence_rate_curve(result: RecurrenceRadiusProfileResult, *, ax=None)

Plot exact recurrence rate against the declared radius grid.

Source code in src/eyetrajectoriespy/nonlinear_plotting.py
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
def plot_recurrence_rate_curve(
    result: RecurrenceRadiusProfileResult,
    *,
    ax=None,
):
    """Plot exact recurrence rate against the declared radius grid."""

    if ax is None:
        _, ax = plt.subplots()
    table = result.table
    ax.plot(
        table["radius"],
        table["recurrence_rate"],
        marker="o",
    )
    ax.set_xlabel(f"Radius ({result.metric} state-space distance)")
    ax.set_ylabel("Recurrence rate")
    ax.set_ylim(bottom=0.0, top=1.0)
    ax.set_title(f"Recurrence radius profile: {result.curve_id}")
    return ax

eyetrajectoriespy.plot_windowed_rqa

plot_windowed_rqa(result: WindowedRQAResult, *, metrics: Sequence[str] = ('recurrence_rate', 'determinism', 'laminarity'), ax=None)

Plot selected time-varying RQA metrics.

Source code in src/eyetrajectoriespy/nonlinear_plotting.py
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
def plot_windowed_rqa(
    result: WindowedRQAResult,
    *,
    metrics: Sequence[str] = ("recurrence_rate", "determinism", "laminarity"),
    ax=None,
):
    """Plot selected time-varying RQA metrics."""

    if not metrics:
        raise ValueError("metrics must contain at least one column")
    missing = [metric for metric in metrics if metric not in result.table.columns]
    if missing:
        raise KeyError(f"Unknown windowed RQA metric columns: {missing}")
    if ax is None:
        _, ax = plt.subplots()
    for metric in metrics:
        ax.plot(result.table["center_time"], result.table[metric], marker="o", label=metric)
    ax.set_xlabel(f"Window center ({result.time_unit})")
    ax.set_ylabel("RQA metric")
    ax.legend()
    ax.set_title("Windowed recurrence dynamics")
    return ax

eyetrajectoriespy.plot_windowed_rqa_trajectories

plot_windowed_rqa_trajectories(result: WindowedRQAFunctionalResult, *, metric: str, show_mean: bool = False, max_curves: int | None = None, ax=None)

Plot one functional windowed-RQA metric across source curves.

Source code in src/eyetrajectoriespy/nonlinear_plotting.py
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
def plot_windowed_rqa_trajectories(
    result: WindowedRQAFunctionalResult,
    *,
    metric: str,
    show_mean: bool = False,
    max_curves: int | None = None,
    ax=None,
):
    """Plot one functional windowed-RQA metric across source curves."""

    if metric not in result.metrics:
        raise KeyError(f"Unknown functional RQA metric {metric!r}")
    if max_curves is not None:
        if not isinstance(max_curves, int) or max_curves < 1:
            raise ValueError("max_curves must be a positive integer or None")
        n_plot = min(result.n_curves, max_curves)
    else:
        n_plot = result.n_curves
    if ax is None:
        _, ax = plt.subplots()
    values = result.trajectories.dimension(metric)
    time = result.trajectories.time
    for curve_index in range(n_plot):
        ax.plot(time, values[curve_index], alpha=0.45)
    if show_mean:
        ax.plot(
            time,
            np.nanmean(values, axis=0),
            linewidth=2.2,
            label="Across-curve mean",
        )
        ax.legend()
    unit = result.trajectories.provenance.get("metric_units", {}).get(
        metric,
        "metric units",
    )
    ax.set_xlabel(f"Window center ({result.time_unit})")
    ax.set_ylabel(f"{metric} ({unit})")
    ax.set_title(f"Functional windowed RQA: {metric}")
    return ax

eyetrajectoriespy.plot_windowed_rqa_sensitivity

plot_windowed_rqa_sensitivity(result: WindowedRQASensitivityResult, *, curve: int | str, metric: str, ax=None)

Overlay one curve/metric across declared window/step specifications.

Source code in src/eyetrajectoriespy/nonlinear_plotting.py
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
def plot_windowed_rqa_sensitivity(
    result: WindowedRQASensitivityResult,
    *,
    curve: int | str,
    metric: str,
    ax=None,
):
    """Overlay one curve/metric across declared window/step specifications."""

    if metric not in result.metrics:
        raise KeyError(f"Unknown functional RQA metric {metric!r}")
    curve_ids = result.analyses[0].trajectories.curve_ids
    if isinstance(curve, str):
        try:
            curve_index = curve_ids.index(curve)
        except ValueError as exc:
            raise KeyError(f"Unknown curve_id {curve!r}") from exc
    elif isinstance(curve, (int, np.integer)):
        curve_index = int(curve)
        if curve_index < 0 or curve_index >= len(curve_ids):
            raise IndexError("curve index is out of range")
    else:
        raise TypeError("curve must be an integer index or curve_id string")

    if ax is None:
        _, ax = plt.subplots()

    specification_ids = tuple(result.provenance["specification_ids"])
    for specification_id, analysis in zip(
        specification_ids, result.analyses, strict=True
    ):
        values = analysis.trajectories.dimension(metric)[curve_index]
        label = (
            f"{specification_id}: W={analysis.window_samples}, "
            f"S={analysis.step_samples}"
        )
        ax.plot(analysis.trajectories.time, values, marker="o", label=label)

    ax.set_xlabel(f"Window center ({result.analyses[0].time_unit})")
    ax.set_ylabel(metric)
    ax.set_title(f"Window/step sensitivity: {curve_ids[curve_index]} / {metric}")
    ax.legend()
    return ax

Population uncertainty for RQA summaries

eyetrajectoriespy.RQAMeanBootstrapResult dataclass

Bootstrap uncertainty for population-average per-curve RQA metrics.

Source code in src/eyetrajectoriespy/nonlinear_types.py
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
@dataclass(frozen=True)
class RQAMeanBootstrapResult:
    """Bootstrap uncertainty for population-average per-curve RQA metrics."""

    observed_table: pd.DataFrame
    unit_table: pd.DataFrame
    bootstrap_table: pd.DataFrame
    summary_table: pd.DataFrame
    metrics: tuple[str, ...]
    unit: str
    unit_ids: tuple[str, ...]
    participant_column: str | None
    confidence_level: float
    n_bootstrap: int
    provenance: Mapping[str, Any] = field(default_factory=dict)

    @property
    def n_units(self) -> int:
        return len(self.unit_ids)

eyetrajectoriespy.bootstrap_rqa_metric_means

bootstrap_rqa_metric_means(trajectories: TrajectorySet, *, dimensions: Sequence[str], metrics: Sequence[str], radius: float | None = None, target_recurrence_rate: float | None = None, distance_metric: str = 'euclidean', theiler_window: float | int = 0, theiler_window_units: str = 'samples', min_diagonal_length: int = 2, min_vertical_length: int = 2, embedding_dimension: int | None = None, delay: float | int | None = None, delay_units: str = 'samples', unit: str = 'curve', participant_column: str | None = None, confidence_level: float = 0.95, n_bootstrap: int = 2000, random_state: int | None = 0) -> RQAMeanBootstrapResult

Bootstrap population-average per-curve RQA metrics.

RQA metrics are first computed once for every observed source curve under one fixed, fully declared recurrence contract. Bootstrap resampling then operates on the independent analysis units: curves, or equal-weight participant averages when repeated trials are present.

This is a population-sampling bootstrap for the mean of curve-level RQA summaries. It does not estimate within-single-trajectory recurrence uncertainty, does not resample recurrence lines, and does not implement a moving/block bootstrap for one time series.

Source code in src/eyetrajectoriespy/rqa_inference.py
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
def bootstrap_rqa_metric_means(
    trajectories: TrajectorySet,
    *,
    dimensions: Sequence[str],
    metrics: Sequence[str],
    radius: float | None = None,
    target_recurrence_rate: float | None = None,
    distance_metric: str = "euclidean",
    theiler_window: float | int = 0,
    theiler_window_units: str = "samples",
    min_diagonal_length: int = 2,
    min_vertical_length: int = 2,
    embedding_dimension: int | None = None,
    delay: float | int | None = None,
    delay_units: str = "samples",
    unit: str = "curve",
    participant_column: str | None = None,
    confidence_level: float = 0.95,
    n_bootstrap: int = 2000,
    random_state: int | None = 0,
) -> RQAMeanBootstrapResult:
    """Bootstrap population-average per-curve RQA metrics.

    RQA metrics are first computed once for every observed source curve under
    one fixed, fully declared recurrence contract. Bootstrap resampling then
    operates on the independent analysis units: curves, or equal-weight
    participant averages when repeated trials are present.

    This is a population-sampling bootstrap for the mean of curve-level RQA
    summaries. It does not estimate within-single-trajectory recurrence
    uncertainty, does not resample recurrence lines, and does not implement a
    moving/block bootstrap for one time series.
    """

    validate_trajectory_set(trajectories, require_complete=True)
    if not np.all(np.isfinite(trajectories.values)):
        raise ValueError(
            "RQA bootstrap requires complete finite trajectory values"
        )
    if isinstance(dimensions, (str, bytes)):
        raise TypeError("dimensions must be a non-string sequence")
    dimension_names = tuple(str(name) for name in dimensions)
    if not dimension_names:
        raise ValueError("dimensions must contain at least one name")
    if len(set(dimension_names)) != len(dimension_names):
        raise ValueError("dimensions must be unique")
    missing = [
        name for name in dimension_names if name not in trajectories.dimension_names
    ]
    if missing:
        raise KeyError(f"Unknown trajectory dimensions: {missing}")

    metric_names = _validate_metrics(
        metrics,
        target_recurrence_rate=target_recurrence_rate,
    )
    _validate_bootstrap_controls(
        confidence_level=confidence_level,
        n_bootstrap=n_bootstrap,
    )

    observed_table, state_provenance = _compute_curve_rqa_table(
        trajectories,
        dimensions=dimension_names,
        metrics=metric_names,
        radius=radius,
        target_recurrence_rate=target_recurrence_rate,
        distance_metric=distance_metric,
        theiler_window=theiler_window,
        theiler_window_units=theiler_window_units,
        min_diagonal_length=min_diagonal_length,
        min_vertical_length=min_vertical_length,
        embedding_dimension=embedding_dimension,
        delay=delay,
        delay_units=delay_units,
    )
    unit_table, unit_ids, unit_provenance = _unit_table(
        trajectories,
        observed_table,
        metrics=metric_names,
        unit=unit,
        participant_column=participant_column,
    )
    n_units = len(unit_ids)
    if n_units < 2:
        raise ValueError("At least two independent bootstrap units are required")

    unit_values = unit_table.loc[:, list(metric_names)].to_numpy(dtype=float)
    observed_mean = np.mean(unit_values, axis=0)

    rng = np.random.default_rng(random_state)
    bootstrap_values = np.empty((int(n_bootstrap), len(metric_names)), dtype=float)
    batch_size = min(512, int(n_bootstrap))
    for start in range(0, int(n_bootstrap), batch_size):
        stop = min(start + batch_size, int(n_bootstrap))
        indices = rng.integers(
            0,
            n_units,
            size=(stop - start, n_units),
        )
        bootstrap_values[start:stop] = np.mean(
            unit_values[indices],
            axis=1,
        )

    alpha = 1.0 - float(confidence_level)
    lower_q = alpha / 2.0
    upper_q = 1.0 - alpha / 2.0
    lower = np.quantile(
        bootstrap_values,
        lower_q,
        axis=0,
        method="linear",
    )
    upper = np.quantile(
        bootstrap_values,
        upper_q,
        axis=0,
        method="linear",
    )
    bootstrap_mean = np.mean(bootstrap_values, axis=0)
    bootstrap_se = np.std(bootstrap_values, axis=0, ddof=1)

    bootstrap_table = pd.DataFrame(
        bootstrap_values,
        columns=metric_names,
    )
    bootstrap_table.insert(
        0,
        "bootstrap_id",
        np.arange(1, int(n_bootstrap) + 1, dtype=int),
    )

    summary_table = pd.DataFrame(
        {
            "metric": metric_names,
            "mean": observed_mean,
            "bootstrap_mean": bootstrap_mean,
            "bootstrap_bias": bootstrap_mean - observed_mean,
            "bootstrap_standard_error": bootstrap_se,
            "lower": lower,
            "upper": upper,
        }
    )

    threshold_policy = (
        "fixed_radius" if radius is not None else "target_recurrence_rate"
    )
    return RQAMeanBootstrapResult(
        observed_table=observed_table,
        unit_table=unit_table,
        bootstrap_table=bootstrap_table,
        summary_table=summary_table,
        metrics=metric_names,
        unit=unit,
        unit_ids=unit_ids,
        participant_column=(
            participant_column if unit == "participant" else None
        ),
        confidence_level=float(confidence_level),
        n_bootstrap=int(n_bootstrap),
        provenance={
            "operation": "bootstrap_rqa_metric_means",
            "source_provenance": dict(trajectories.provenance),
            "dimensions": dimension_names,
            **state_provenance,
            "threshold_policy": threshold_policy,
            "requested_radius": radius,
            "requested_target_recurrence_rate": target_recurrence_rate,
            "distance_metric": distance_metric,
            "theiler_window_requested": theiler_window,
            "theiler_window_units": theiler_window_units,
            "min_diagonal_length": min_diagonal_length,
            "min_vertical_length": min_vertical_length,
            "metrics": metric_names,
            "unit": unit,
            "participant_column": (
                participant_column if unit == "participant" else None
            ),
            "n_units": n_units,
            "n_bootstrap": int(n_bootstrap),
            "confidence_level": float(confidence_level),
            "interval_method": "percentile_bootstrap",
            "random_state": random_state,
            "curve_level_rqa_recomputed_per_bootstrap_draw": False,
            "curve_level_rqa_reason": (
                "each observed curve has one deterministic RQA summary under "
                "the fixed declared recurrence contract; the bootstrap targets "
                "between-unit population sampling uncertainty"
            ),
            "within_single_trajectory_uncertainty": False,
            "recurrence_line_resampling": False,
            "moving_or_block_bootstrap": False,
            "automatic_parameter_selection": False,
            "parameter_selection_uncertainty_included": False,
            "undefined_metric_policy": "raise_entire_analysis",
            **unit_provenance,
        },
    )

eyetrajectoriespy.plot_rqa_metric_mean_bootstrap

plot_rqa_metric_mean_bootstrap(result: RQAMeanBootstrapResult, *, metrics: Sequence[str] | None = None, ax=None)

Plot population-average RQA metrics with percentile-bootstrap intervals.

Source code in src/eyetrajectoriespy/nonlinear_plotting.py
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
def plot_rqa_metric_mean_bootstrap(
    result: RQAMeanBootstrapResult,
    *,
    metrics: Sequence[str] | None = None,
    ax=None,
):
    """Plot population-average RQA metrics with percentile-bootstrap intervals."""

    selected_metrics = result.metrics if metrics is None else tuple(metrics)
    if not selected_metrics:
        raise ValueError("metrics must contain at least one RQA metric")
    unknown = [metric for metric in selected_metrics if metric not in result.metrics]
    if unknown:
        raise KeyError(f"Unknown bootstrap RQA metrics: {unknown}")

    table = result.summary_table.set_index("metric").loc[list(selected_metrics)]
    x = np.arange(len(selected_metrics), dtype=float)
    mean = table["mean"].to_numpy(dtype=float)
    lower = table["lower"].to_numpy(dtype=float)
    upper = table["upper"].to_numpy(dtype=float)
    yerr = np.vstack([mean - lower, upper - mean])

    if ax is None:
        _, ax = plt.subplots()
    ax.errorbar(
        x,
        mean,
        yerr=yerr,
        fmt="o",
        capsize=4,
    )
    ax.set_xticks(x, selected_metrics, rotation=30, ha="right")
    ax.set_ylabel("RQA metric")
    ax.set_title(f"RQA population mean ({result.unit}-level bootstrap)")
    return ax

Declared nonlinear parameter sensitivity

eyetrajectoriespy.RQAParameterSensitivityResult dataclass

Declared multiverse of reconstructed-state RQA specifications.

Source code in src/eyetrajectoriespy/nonlinear_types.py
245
246
247
248
249
250
251
252
253
254
255
256
257
258
@dataclass(frozen=True)
class RQAParameterSensitivityResult:
    """Declared multiverse of reconstructed-state RQA specifications."""

    table: pd.DataFrame
    summary_table: pd.DataFrame
    parameter_columns: tuple[str, ...]
    metric_columns: tuple[str, ...]
    curve_id: str
    provenance: Mapping[str, Any] = field(default_factory=dict)

    @property
    def n_specifications(self) -> int:
        return len(self.table)

eyetrajectoriespy.LyapunovParameterSensitivityResult dataclass

Declared multiverse of Rosenstein LLE specifications.

Source code in src/eyetrajectoriespy/nonlinear_types.py
261
262
263
264
265
266
267
268
269
270
271
272
273
274
@dataclass(frozen=True)
class LyapunovParameterSensitivityResult:
    """Declared multiverse of Rosenstein LLE specifications."""

    table: pd.DataFrame
    summary_table: pd.DataFrame
    parameter_columns: tuple[str, ...]
    curve_id: str
    exponent_unit: str
    provenance: Mapping[str, Any] = field(default_factory=dict)

    @property
    def n_specifications(self) -> int:
        return len(self.table)

eyetrajectoriespy.KantzParameterSensitivityResult dataclass

Declared multiverse of Kantz LLE neighborhood specifications.

Source code in src/eyetrajectoriespy/nonlinear_types.py
277
278
279
280
281
282
283
284
285
286
287
288
289
290
@dataclass(frozen=True)
class KantzParameterSensitivityResult:
    """Declared multiverse of Kantz LLE neighborhood specifications."""

    table: pd.DataFrame
    summary_table: pd.DataFrame
    parameter_columns: tuple[str, ...]
    curve_id: str
    exponent_unit: str
    provenance: Mapping[str, Any] = field(default_factory=dict)

    @property
    def n_specifications(self) -> int:
        return len(self.table)

eyetrajectoriespy.rqa_parameter_sensitivity

rqa_parameter_sensitivity(trajectories: TrajectorySet, *, curve: int | str, dimensions: Sequence[str], embedding_dimensions: Sequence[int], delays: Sequence[float | int], theiler_windows: Sequence[float | int], min_diagonal_lengths: Sequence[int], min_vertical_lengths: Sequence[int], radii: Sequence[float] | None = None, target_recurrence_rates: Sequence[float] | None = None, delay_units: str = 'samples', theiler_window_units: str = 'samples', distance_metric: str = 'euclidean') -> RQAParameterSensitivityResult

Evaluate a predeclared RQA parameter multiverse without selecting a winner.

Exactly one recurrence-threshold grid must be supplied: radii or target_recurrence_rates. Every Cartesian-product specification is evaluated. Invalid specifications fail the whole analysis with the offending combination identified; no failed row is silently discarded.

The returned summaries are descriptive variation across the declared analysis choices. They are not sampling distributions, posterior probabilities, or automatic tuning criteria.

Source code in src/eyetrajectoriespy/nonlinear_sensitivity.py
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
def rqa_parameter_sensitivity(
    trajectories: TrajectorySet,
    *,
    curve: int | str,
    dimensions: Sequence[str],
    embedding_dimensions: Sequence[int],
    delays: Sequence[float | int],
    theiler_windows: Sequence[float | int],
    min_diagonal_lengths: Sequence[int],
    min_vertical_lengths: Sequence[int],
    radii: Sequence[float] | None = None,
    target_recurrence_rates: Sequence[float] | None = None,
    delay_units: str = "samples",
    theiler_window_units: str = "samples",
    distance_metric: str = "euclidean",
) -> RQAParameterSensitivityResult:
    """Evaluate a predeclared RQA parameter multiverse without selecting a winner.

    Exactly one recurrence-threshold grid must be supplied: radii or
    target_recurrence_rates. Every Cartesian-product specification is
    evaluated. Invalid specifications fail the whole analysis with the
    offending combination identified; no failed row is silently discarded.

    The returned summaries are descriptive variation across the declared
    analysis choices. They are not sampling distributions, posterior
    probabilities, or automatic tuning criteria.
    """

    dimension_names = _validate_dimensions(trajectories, dimensions)
    curve_name = _curve_id(trajectories, curve)
    embedding_grid = _positive_integer_grid(
        embedding_dimensions,
        name="embedding_dimensions",
        minimum=2,
    )
    delay_grid = _finite_numeric_grid(
        delays,
        name="delays",
        positive=True,
    )
    theiler_grid = _finite_numeric_grid(
        theiler_windows,
        name="theiler_windows",
        positive=False,
        allow_zero=True,
    )
    diagonal_grid = _positive_integer_grid(
        min_diagonal_lengths,
        name="min_diagonal_lengths",
    )
    vertical_grid = _positive_integer_grid(
        min_vertical_lengths,
        name="min_vertical_lengths",
    )

    if (radii is None) == (target_recurrence_rates is None):
        raise ValueError(
            "supply exactly one sensitivity threshold grid: radii or "
            "target_recurrence_rates"
        )
    if radii is not None:
        threshold_grid = _finite_numeric_grid(
            radii,
            name="radii",
            positive=True,
        )
        threshold_policy = "fixed_radius"
    else:
        target_grid = _finite_numeric_grid(
            (
                target_recurrence_rates
                if target_recurrence_rates is not None
                else ()
            ),
            name="target_recurrence_rates",
            positive=True,
        )
        if any(float(value) >= 1 for value in target_grid):
            raise ValueError(
                "target_recurrence_rates must be strictly between 0 and 1"
            )
        threshold_grid = target_grid
        threshold_policy = "target_recurrence_rate"

    rows: list[dict[str, Any]] = []
    resolved_embedding_keys: set[tuple[int, int]] = set()
    resolved_specification_keys: set[tuple[Any, ...]] = set()

    for embedding_dimension, delay in product(embedding_grid, delay_grid):
        try:
            embedding = delay_embed_trajectory(
                trajectories,
                embedding_dimension=embedding_dimension,
                delay=delay,
                delay_units=delay_units,
                dimensions=dimension_names,
            )
        except (TypeError, ValueError, KeyError, IndexError) as exc:
            raise ValueError(
                "RQA sensitivity embedding failed for "
                f"embedding_dimension={embedding_dimension}, delay={delay!r} "
                f"{delay_units}: {exc}"
            ) from exc

        embedding_key = (embedding_dimension, embedding.delay_samples)
        if embedding_key in resolved_embedding_keys:
            raise ValueError(
                "two declared embedding specifications resolve to the same "
                f"(embedding_dimension, delay_samples)={embedding_key}; "
                "remove duplicate resolved specifications"
            )
        resolved_embedding_keys.add(embedding_key)

        for threshold, theiler_window in product(
            threshold_grid,
            theiler_grid,
        ):
            recurrence_kwargs: dict[str, Any] = {
                "curve": curve,
                "metric": distance_metric,
                "theiler_window": theiler_window,
                "theiler_window_units": theiler_window_units,
            }
            if threshold_policy == "fixed_radius":
                recurrence_kwargs["radius"] = float(threshold)
            else:
                recurrence_kwargs["target_recurrence_rate"] = float(threshold)

            try:
                recurrence = recurrence_matrix(
                    embedding,
                    **recurrence_kwargs,
                )
            except (TypeError, ValueError, KeyError, IndexError) as exc:
                raise ValueError(
                    "RQA sensitivity recurrence failed for "
                    f"embedding_dimension={embedding_dimension}, "
                    f"delay={delay!r} {delay_units}, "
                    f"{threshold_policy}={float(threshold):.12g}, "
                    f"theiler_window={theiler_window!r} "
                    f"{theiler_window_units}: {exc}"
                ) from exc

            for min_diagonal_length, min_vertical_length in product(
                diagonal_grid,
                vertical_grid,
            ):
                specification_key = (
                    embedding_dimension,
                    embedding.delay_samples,
                    threshold_policy,
                    float(threshold),
                    recurrence.theiler_window_samples,
                    min_diagonal_length,
                    min_vertical_length,
                )
                if specification_key in resolved_specification_keys:
                    raise ValueError(
                        "two declared RQA specifications resolve to the same "
                        "sample-level analysis contract; remove duplicate "
                        "resolved specifications"
                    )
                resolved_specification_keys.add(specification_key)
                try:
                    metrics = rqa_metrics(
                        recurrence,
                        min_diagonal_length=min_diagonal_length,
                        min_vertical_length=min_vertical_length,
                    )
                except (TypeError, ValueError, KeyError, IndexError) as exc:
                    raise ValueError(
                        "RQA sensitivity metric calculation failed for "
                        f"embedding_dimension={embedding_dimension}, "
                        f"delay_samples={embedding.delay_samples}, "
                        f"{threshold_policy}={float(threshold):.12g}, "
                        f"theiler_window_samples="
                        f"{recurrence.theiler_window_samples}, "
                        f"min_diagonal_length={min_diagonal_length}, "
                        f"min_vertical_length={min_vertical_length}: {exc}"
                    ) from exc

                rows.append(
                    {
                        "embedding_dimension": embedding_dimension,
                        "requested_delay": float(delay),
                        "delay_units": delay_units,
                        "delay_samples": embedding.delay_samples,
                        "delay_time": embedding.delay_time,
                        "threshold_policy": threshold_policy,
                        "requested_radius": (
                            float(threshold)
                            if threshold_policy == "fixed_radius"
                            else float("nan")
                        ),
                        "requested_target_recurrence_rate": (
                            float(threshold)
                            if threshold_policy == "target_recurrence_rate"
                            else float("nan")
                        ),
                        "resolved_radius": recurrence.radius,
                        "achieved_recurrence_rate": recurrence.achieved_recurrence_rate,
                        "requested_theiler_window": float(theiler_window),
                        "theiler_window_units": theiler_window_units,
                        "theiler_window_samples": recurrence.theiler_window_samples,
                        "min_diagonal_length": min_diagonal_length,
                        "min_vertical_length": min_vertical_length,
                        "distance_metric": distance_metric,
                        "recurrence_rate": metrics.recurrence_rate,
                        "determinism": metrics.determinism,
                        "mean_diagonal_length": metrics.mean_diagonal_length,
                        "max_diagonal_length": metrics.max_diagonal_length,
                        "diagonal_entropy": metrics.diagonal_entropy,
                        "laminarity": metrics.laminarity,
                        "trapping_time": metrics.trapping_time,
                        "max_vertical_length": metrics.max_vertical_length,
                        "center_of_recurrence_mass": metrics.center_of_recurrence_mass,
                        "n_recurrence_points": metrics.n_recurrence_points,
                        "n_diagonal_lines": metrics.n_diagonal_lines,
                        "n_vertical_lines": metrics.n_vertical_lines,
                    }
                )

    table = pd.DataFrame(rows)
    table.insert(
        0,
        "specification_id",
        [f"spec_{index + 1}" for index in range(len(table))],
    )
    parameter_columns = (
        "embedding_dimension",
        "requested_delay",
        "delay_samples",
        "threshold_policy",
        "requested_radius",
        "requested_target_recurrence_rate",
        "resolved_radius",
        "requested_theiler_window",
        "theiler_window_samples",
        "min_diagonal_length",
        "min_vertical_length",
    )
    summary = _variation_summary(table, _RQA_METRICS)

    return RQAParameterSensitivityResult(
        table=table,
        summary_table=summary,
        parameter_columns=parameter_columns,
        metric_columns=_RQA_METRICS,
        curve_id=curve_name,
        provenance={
            "operation": "rqa_parameter_sensitivity",
            "source_provenance": dict(trajectories.provenance),
            "curve_id": curve_name,
            "dimensions": dimension_names,
            "threshold_policy": threshold_policy,
            "delay_units": delay_units,
            "theiler_window_units": theiler_window_units,
            "distance_metric": distance_metric,
            "cartesian_product_evaluated": True,
            "n_specifications": len(table),
            "automatic_parameter_selection": False,
            "failed_specification_policy": "raise_entire_analysis",
            "summary_interpretation": (
                "descriptive variation over the analyst-declared parameter "
                "multiverse; not a sampling distribution"
            ),
            "controlled_metric_caution": (
                "under target-recurrence-rate sensitivity, recurrence_rate is "
                "controlled by design and should not be interpreted as an "
                "independent robustness outcome"
            ),
            "matrices_retained": False,
            "memory_contract": (
                "sensitivity retains tidy metrics/provenance rather than one "
                "sparse recurrence matrix per specification"
            ),
        },
    )

eyetrajectoriespy.lyapunov_parameter_sensitivity

lyapunov_parameter_sensitivity(trajectories: TrajectorySet, *, curve: int | str, dimensions: Sequence[str], embedding_dimensions: Sequence[int], delays: Sequence[float | int], theiler_windows: Sequence[float | int], fit_intervals: Sequence[tuple[float | int, float | int]], max_horizon: float | int, delay_units: str = 'samples', theiler_window_units: str = 'samples', fit_units: str = 'samples', max_horizon_units: str = 'samples') -> LyapunovParameterSensitivityResult

Evaluate declared Rosenstein-LLE reconstruction and fit choices.

Divergence curves are reused across fit intervals for the same embedding and Theiler specification. No fit interval, embedding dimension, delay, or Theiler window is selected automatically.

The fraction of declared specifications with positive exponents is a descriptive property of the analyst-specified grid. It is not a probability that the system is chaotic.

Source code in src/eyetrajectoriespy/nonlinear_sensitivity.py
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
def lyapunov_parameter_sensitivity(
    trajectories: TrajectorySet,
    *,
    curve: int | str,
    dimensions: Sequence[str],
    embedding_dimensions: Sequence[int],
    delays: Sequence[float | int],
    theiler_windows: Sequence[float | int],
    fit_intervals: Sequence[tuple[float | int, float | int]],
    max_horizon: float | int,
    delay_units: str = "samples",
    theiler_window_units: str = "samples",
    fit_units: str = "samples",
    max_horizon_units: str = "samples",
) -> LyapunovParameterSensitivityResult:
    """Evaluate declared Rosenstein-LLE reconstruction and fit choices.

    Divergence curves are reused across fit intervals for the same embedding
    and Theiler specification. No fit interval, embedding dimension, delay, or
    Theiler window is selected automatically.

    The fraction of declared specifications with positive exponents is a
    descriptive property of the analyst-specified grid. It is not a
    probability that the system is chaotic.
    """

    dimension_names = _validate_dimensions(trajectories, dimensions)
    curve_name = _curve_id(trajectories, curve)
    embedding_grid = _positive_integer_grid(
        embedding_dimensions,
        name="embedding_dimensions",
        minimum=2,
    )
    delay_grid = _finite_numeric_grid(
        delays,
        name="delays",
        positive=True,
    )
    theiler_grid = _finite_numeric_grid(
        theiler_windows,
        name="theiler_windows",
        positive=False,
        allow_zero=True,
    )
    intervals = _validate_fit_intervals(fit_intervals)
    if isinstance(max_horizon, (bool, np.bool_)) or not isinstance(
        max_horizon,
        (int, float, np.integer, np.floating),
    ):
        raise TypeError("max_horizon must be numeric")
    if not np.isfinite(float(max_horizon)) or float(max_horizon) <= 0:
        raise ValueError("max_horizon must be positive and finite")

    rows: list[dict[str, Any]] = []
    resolved_embedding_keys: set[tuple[int, int]] = set()
    resolved_specification_keys: set[tuple[Any, ...]] = set()
    exponent_unit: str | None = None

    for embedding_dimension, delay in product(embedding_grid, delay_grid):
        try:
            embedding = delay_embed_trajectory(
                trajectories,
                embedding_dimension=embedding_dimension,
                delay=delay,
                delay_units=delay_units,
                dimensions=dimension_names,
            )
        except (TypeError, ValueError, KeyError, IndexError) as exc:
            raise ValueError(
                "LLE sensitivity embedding failed for "
                f"embedding_dimension={embedding_dimension}, delay={delay!r} "
                f"{delay_units}: {exc}"
            ) from exc

        embedding_key = (embedding_dimension, embedding.delay_samples)
        if embedding_key in resolved_embedding_keys:
            raise ValueError(
                "two declared embedding specifications resolve to the same "
                f"(embedding_dimension, delay_samples)={embedding_key}; "
                "remove duplicate resolved specifications"
            )
        resolved_embedding_keys.add(embedding_key)

        for theiler_window in theiler_grid:
            try:
                divergence = local_divergence_curve(
                    embedding,
                    curve=curve,
                    theiler_window=theiler_window,
                    theiler_window_units=theiler_window_units,
                    max_horizon=max_horizon,
                    max_horizon_units=max_horizon_units,
                )
            except (TypeError, ValueError, KeyError, IndexError) as exc:
                raise ValueError(
                    "LLE sensitivity divergence failed for "
                    f"embedding_dimension={embedding_dimension}, "
                    f"delay_samples={embedding.delay_samples}, "
                    f"theiler_window={theiler_window!r} "
                    f"{theiler_window_units}: {exc}"
                ) from exc

            for fit_start, fit_end in intervals:
                try:
                    estimate = estimate_largest_lyapunov_rosenstein(
                        divergence,
                        fit_start=fit_start,
                        fit_end=fit_end,
                        fit_units=fit_units,
                    )
                except (TypeError, ValueError, KeyError, IndexError) as exc:
                    raise ValueError(
                        "LLE sensitivity fit failed for "
                        f"embedding_dimension={embedding_dimension}, "
                        f"delay_samples={embedding.delay_samples}, "
                        f"theiler_window_samples={divergence.theiler_window_samples}, "
                        f"fit_interval=({fit_start!r}, {fit_end!r}) "
                        f"{fit_units}: {exc}"
                    ) from exc

                fit_start_samples = int(estimate.provenance["fit_start_samples"])
                fit_end_samples = int(estimate.provenance["fit_end_samples"])
                specification_key = (
                    embedding_dimension,
                    embedding.delay_samples,
                    divergence.theiler_window_samples,
                    fit_start_samples,
                    fit_end_samples,
                )
                if specification_key in resolved_specification_keys:
                    raise ValueError(
                        "two declared LLE specifications resolve to the same "
                        "sample-level reconstruction/fit contract; remove "
                        "duplicate resolved specifications"
                    )
                resolved_specification_keys.add(specification_key)

                if exponent_unit is None:
                    exponent_unit = estimate.exponent_unit
                elif exponent_unit != estimate.exponent_unit:
                    raise RuntimeError(
                        "LLE sensitivity produced inconsistent exponent units"
                    )

                fit_mask = (
                    (divergence.horizons >= fit_start_samples)
                    & (divergence.horizons <= fit_end_samples)
                )
                fit_pair_counts = divergence.pair_counts[fit_mask]
                fit_zero_counts = divergence.zero_distance_counts[fit_mask]
                rows.append(
                    {
                        "embedding_dimension": embedding_dimension,
                        "requested_delay": float(delay),
                        "delay_units": delay_units,
                        "delay_samples": embedding.delay_samples,
                        "delay_time": embedding.delay_time,
                        "requested_theiler_window": float(theiler_window),
                        "theiler_window_units": theiler_window_units,
                        "theiler_window_samples": divergence.theiler_window_samples,
                        "requested_fit_start": float(fit_start),
                        "requested_fit_end": float(fit_end),
                        "fit_units": fit_units,
                        "fit_start_samples": fit_start_samples,
                        "fit_end_samples": fit_end_samples,
                        "resolved_fit_start_time": estimate.fit_start,
                        "resolved_fit_end_time": estimate.fit_end,
                        "requested_max_horizon": float(max_horizon),
                        "max_horizon_units": max_horizon_units,
                        "max_horizon_samples": divergence.max_horizon_samples,
                        "exponent": estimate.exponent,
                        "exponent_unit": estimate.exponent_unit,
                        "r_squared": estimate.r_squared,
                        "standard_error": estimate.standard_error,
                        "intercept": estimate.intercept,
                        "n_fit_points": estimate.n_fit_points,
                        "minimum_pair_count_in_fit": (
                            int(np.min(fit_pair_counts))
                            if fit_pair_counts.size
                            else 0
                        ),
                        "total_zero_distance_count_in_fit": int(
                            np.sum(fit_zero_counts)
                        ),
                    }
                )

    table = pd.DataFrame(rows)
    table.insert(
        0,
        "specification_id",
        [f"spec_{index + 1}" for index in range(len(table))],
    )
    parameter_columns = (
        "embedding_dimension",
        "requested_delay",
        "delay_samples",
        "requested_theiler_window",
        "theiler_window_samples",
        "requested_fit_start",
        "requested_fit_end",
        "fit_start_samples",
        "fit_end_samples",
    )
    summary = _variation_summary(
        table,
        _LLE_SUMMARY_METRICS,
        exponent_sign=True,
    )
    if exponent_unit is None:
        raise RuntimeError("LLE sensitivity unexpectedly produced no specifications")

    return LyapunovParameterSensitivityResult(
        table=table,
        summary_table=summary,
        parameter_columns=parameter_columns,
        curve_id=curve_name,
        exponent_unit=exponent_unit,
        provenance={
            "operation": "lyapunov_parameter_sensitivity",
            "source_provenance": dict(trajectories.provenance),
            "curve_id": curve_name,
            "dimensions": dimension_names,
            "delay_units": delay_units,
            "theiler_window_units": theiler_window_units,
            "fit_units": fit_units,
            "max_horizon_units": max_horizon_units,
            "cartesian_product_evaluated": True,
            "n_specifications": len(table),
            "automatic_parameter_selection": False,
            "automatic_fit_interval_selection": False,
            "failed_specification_policy": "raise_entire_analysis",
            "divergence_reuse": (
                "one divergence curve per resolved embedding/Theiler "
                "specification, reused across declared fit intervals"
            ),
            "positive_fraction_interpretation": (
                "descriptive fraction of the analyst-declared sensitivity grid "
                "with exponent > 0; not a probability of deterministic chaos"
            ),
            "summary_interpretation": (
                "descriptive variation over the analyst-declared parameter "
                "multiverse; not a sampling distribution"
            ),
        },
    )

eyetrajectoriespy.kantz_parameter_sensitivity

kantz_parameter_sensitivity(trajectories: TrajectorySet, *, curve: int | str, dimensions: Sequence[str], embedding_dimensions: Sequence[int], delays: Sequence[float | int], radii: Sequence[float], min_neighbors: Sequence[int], theiler_windows: Sequence[float | int], fit_intervals: Sequence[tuple[float | int, float | int]], max_horizon: float | int, delay_units: str = 'samples', theiler_window_units: str = 'samples', fit_units: str = 'samples', max_horizon_units: str = 'samples') -> KantzParameterSensitivityResult

Evaluate a declared Kantz-LLE parameter multiverse without tuning.

Every Cartesian-product specification is evaluated. Divergence curves are reused across fit intervals for the same resolved embedding, radius, minimum-neighbor, and Theiler contract. Invalid specifications fail the complete analysis rather than being silently removed.

The resulting ranges and sign fractions describe sensitivity across the analyst-declared grid. They are not confidence intervals, posterior probabilities, or probabilities that the source dynamics are chaotic.

Source code in src/eyetrajectoriespy/nonlinear_sensitivity.py
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
def kantz_parameter_sensitivity(
    trajectories: TrajectorySet,
    *,
    curve: int | str,
    dimensions: Sequence[str],
    embedding_dimensions: Sequence[int],
    delays: Sequence[float | int],
    radii: Sequence[float],
    min_neighbors: Sequence[int],
    theiler_windows: Sequence[float | int],
    fit_intervals: Sequence[tuple[float | int, float | int]],
    max_horizon: float | int,
    delay_units: str = "samples",
    theiler_window_units: str = "samples",
    fit_units: str = "samples",
    max_horizon_units: str = "samples",
) -> KantzParameterSensitivityResult:
    """Evaluate a declared Kantz-LLE parameter multiverse without tuning.

    Every Cartesian-product specification is evaluated. Divergence curves are
    reused across fit intervals for the same resolved embedding, radius,
    minimum-neighbor, and Theiler contract. Invalid specifications fail the
    complete analysis rather than being silently removed.

    The resulting ranges and sign fractions describe sensitivity across the
    analyst-declared grid. They are not confidence intervals, posterior
    probabilities, or probabilities that the source dynamics are chaotic.
    """

    dimension_names = _validate_dimensions(trajectories, dimensions)
    curve_name = _curve_id(trajectories, curve)
    embedding_grid = _positive_integer_grid(
        embedding_dimensions,
        name="embedding_dimensions",
        minimum=2,
    )
    delay_grid = _finite_numeric_grid(
        delays,
        name="delays",
        positive=True,
    )
    radius_grid = _finite_numeric_grid(
        radii,
        name="radii",
        positive=True,
    )
    neighbor_grid = _positive_integer_grid(
        min_neighbors,
        name="min_neighbors",
        minimum=1,
    )
    theiler_grid = _finite_numeric_grid(
        theiler_windows,
        name="theiler_windows",
        positive=False,
        allow_zero=True,
    )
    intervals = _validate_fit_intervals(fit_intervals)

    if isinstance(max_horizon, (bool, np.bool_)) or not isinstance(
        max_horizon,
        (int, float, np.integer, np.floating),
    ):
        raise TypeError("max_horizon must be numeric")
    if not np.isfinite(float(max_horizon)) or float(max_horizon) <= 0:
        raise ValueError("max_horizon must be positive and finite")

    rows: list[dict[str, Any]] = []
    resolved_embedding_keys: set[tuple[int, int]] = set()
    resolved_divergence_keys: set[tuple[Any, ...]] = set()
    resolved_specification_keys: set[tuple[Any, ...]] = set()
    exponent_unit: str | None = None

    for embedding_dimension, delay in product(embedding_grid, delay_grid):
        try:
            embedding = delay_embed_trajectory(
                trajectories,
                embedding_dimension=embedding_dimension,
                delay=delay,
                delay_units=delay_units,
                dimensions=dimension_names,
            )
        except (TypeError, ValueError, KeyError, IndexError) as exc:
            raise ValueError(
                "Kantz sensitivity embedding failed for "
                f"embedding_dimension={embedding_dimension}, delay={delay!r} "
                f"{delay_units}: {exc}"
            ) from exc

        embedding_key = (embedding_dimension, embedding.delay_samples)
        if embedding_key in resolved_embedding_keys:
            raise ValueError(
                "two declared embedding specifications resolve to the same "
                f"(embedding_dimension, delay_samples)={embedding_key}; "
                "remove duplicate resolved specifications"
            )
        resolved_embedding_keys.add(embedding_key)

        for radius, minimum_neighbors, theiler_window in product(
            radius_grid,
            neighbor_grid,
            theiler_grid,
        ):
            try:
                divergence = kantz_divergence_curve(
                    embedding,
                    curve=curve,
                    radius=float(radius),
                    min_neighbors=minimum_neighbors,
                    theiler_window=theiler_window,
                    theiler_window_units=theiler_window_units,
                    max_horizon=max_horizon,
                    max_horizon_units=max_horizon_units,
                )
            except (TypeError, ValueError, KeyError, IndexError) as exc:
                raise ValueError(
                    "Kantz sensitivity divergence failed for "
                    f"embedding_dimension={embedding_dimension}, "
                    f"delay_samples={embedding.delay_samples}, "
                    f"radius={float(radius):.12g}, "
                    f"min_neighbors={minimum_neighbors}, "
                    f"theiler_window={theiler_window!r} "
                    f"{theiler_window_units}: {exc}"
                ) from exc

            divergence_key = (
                embedding_dimension,
                embedding.delay_samples,
                float(radius),
                minimum_neighbors,
                divergence.theiler_window_samples,
            )
            if divergence_key in resolved_divergence_keys:
                raise ValueError(
                    "two declared Kantz divergence specifications resolve to "
                    "the same sample-level neighborhood contract; remove "
                    "duplicate resolved specifications"
                )
            resolved_divergence_keys.add(divergence_key)

            initial_counts = divergence.initial_neighbor_counts.astype(float)
            supported_initial = divergence.initial_neighbor_counts >= minimum_neighbors
            initial_supported_fraction = float(np.mean(supported_initial))

            for fit_start, fit_end in intervals:
                try:
                    estimate = estimate_largest_lyapunov_kantz(
                        divergence,
                        fit_start=fit_start,
                        fit_end=fit_end,
                        fit_units=fit_units,
                    )
                except (TypeError, ValueError, KeyError, IndexError) as exc:
                    raise ValueError(
                        "Kantz sensitivity fit failed for "
                        f"embedding_dimension={embedding_dimension}, "
                        f"delay_samples={embedding.delay_samples}, "
                        f"radius={float(radius):.12g}, "
                        f"min_neighbors={minimum_neighbors}, "
                        f"theiler_window_samples="
                        f"{divergence.theiler_window_samples}, "
                        f"fit_interval=({fit_start!r}, {fit_end!r}) "
                        f"{fit_units}: {exc}"
                    ) from exc

                fit_start_samples = int(estimate.provenance["fit_start_samples"])
                fit_end_samples = int(estimate.provenance["fit_end_samples"])
                specification_key = (
                    *divergence_key,
                    fit_start_samples,
                    fit_end_samples,
                )
                if specification_key in resolved_specification_keys:
                    raise ValueError(
                        "two declared Kantz LLE specifications resolve to the "
                        "same sample-level neighborhood/fit contract; remove "
                        "duplicate resolved specifications"
                    )
                resolved_specification_keys.add(specification_key)

                if exponent_unit is None:
                    exponent_unit = estimate.exponent_unit
                elif exponent_unit != estimate.exponent_unit:
                    raise RuntimeError(
                        "Kantz sensitivity produced inconsistent exponent units"
                    )

                fit_mask = (
                    (divergence.horizons >= fit_start_samples)
                    & (divergence.horizons <= fit_end_samples)
                )
                fit_reference_counts = divergence.reference_counts[fit_mask]
                fit_pair_counts = divergence.pair_counts[fit_mask]
                fit_zero_counts = divergence.zero_mean_neighborhood_counts[fit_mask]

                rows.append(
                    {
                        "embedding_dimension": embedding_dimension,
                        "requested_delay": float(delay),
                        "delay_units": delay_units,
                        "delay_samples": embedding.delay_samples,
                        "delay_time": embedding.delay_time,
                        "requested_radius": float(radius),
                        "resolved_radius": divergence.radius,
                        "min_neighbors": minimum_neighbors,
                        "requested_theiler_window": float(theiler_window),
                        "theiler_window_units": theiler_window_units,
                        "theiler_window_samples": divergence.theiler_window_samples,
                        "requested_fit_start": float(fit_start),
                        "requested_fit_end": float(fit_end),
                        "fit_units": fit_units,
                        "fit_start_samples": fit_start_samples,
                        "fit_end_samples": fit_end_samples,
                        "resolved_fit_start_time": estimate.fit_start,
                        "resolved_fit_end_time": estimate.fit_end,
                        "requested_max_horizon": float(max_horizon),
                        "max_horizon_units": max_horizon_units,
                        "max_horizon_samples": divergence.max_horizon_samples,
                        "exponent": estimate.exponent,
                        "exponent_unit": estimate.exponent_unit,
                        "r_squared": estimate.r_squared,
                        "standard_error": estimate.standard_error,
                        "intercept": estimate.intercept,
                        "n_fit_points": estimate.n_fit_points,
                        "minimum_reference_count_in_fit": (
                            int(np.min(fit_reference_counts))
                            if fit_reference_counts.size
                            else 0
                        ),
                        "minimum_pair_count_in_fit": (
                            int(np.min(fit_pair_counts))
                            if fit_pair_counts.size
                            else 0
                        ),
                        "total_zero_mean_neighborhood_count_in_fit": int(
                            np.sum(fit_zero_counts)
                        ),
                        "initial_neighbor_count_minimum": float(
                            np.min(initial_counts)
                        ),
                        "initial_neighbor_count_median": float(
                            np.median(initial_counts)
                        ),
                        "initial_neighbor_count_maximum": float(
                            np.max(initial_counts)
                        ),
                        "initial_supported_reference_fraction": (
                            initial_supported_fraction
                        ),
                    }
                )

    table = pd.DataFrame(rows)
    table.insert(
        0,
        "specification_id",
        [f"spec_{index + 1}" for index in range(len(table))],
    )
    parameter_columns = (
        "embedding_dimension",
        "requested_delay",
        "delay_samples",
        "requested_radius",
        "resolved_radius",
        "min_neighbors",
        "requested_theiler_window",
        "theiler_window_samples",
        "requested_fit_start",
        "requested_fit_end",
        "fit_start_samples",
        "fit_end_samples",
    )
    summary = _variation_summary(
        table,
        _KANTZ_LLE_SUMMARY_METRICS,
        exponent_sign=True,
    )
    if exponent_unit is None:
        raise RuntimeError(
            "Kantz sensitivity unexpectedly produced no specifications"
        )

    return KantzParameterSensitivityResult(
        table=table,
        summary_table=summary,
        parameter_columns=parameter_columns,
        curve_id=curve_name,
        exponent_unit=exponent_unit,
        provenance={
            "operation": "kantz_parameter_sensitivity",
            "estimator_family": "Kantz fixed-radius neighborhood divergence",
            "source_provenance": dict(trajectories.provenance),
            "curve_id": curve_name,
            "dimensions": dimension_names,
            "delay_units": delay_units,
            "theiler_window_units": theiler_window_units,
            "fit_units": fit_units,
            "max_horizon_units": max_horizon_units,
            "cartesian_product_evaluated": True,
            "n_specifications": len(table),
            "automatic_parameter_selection": False,
            "automatic_radius_selection": False,
            "automatic_fit_interval_selection": False,
            "adaptive_radius_expansion": False,
            "failed_specification_policy": "raise_entire_analysis",
            "divergence_reuse": (
                "one Kantz divergence curve per resolved embedding/radius/"
                "minimum-neighbor/Theiler specification, reused across "
                "declared fit intervals"
            ),
            "positive_fraction_interpretation": (
                "descriptive fraction of the analyst-declared sensitivity grid "
                "with exponent > 0; not a probability of deterministic chaos"
            ),
            "summary_interpretation": (
                "descriptive variation over the analyst-declared parameter "
                "multiverse; not a sampling distribution"
            ),
            "support_diagnostics": (
                "reference/pair support and zero-mean-neighborhood counts are "
                "retained for each specification rather than converted into "
                "an automatic quality threshold"
            ),
        },
    )

eyetrajectoriespy.plot_rqa_sensitivity

plot_rqa_sensitivity(result: RQAParameterSensitivityResult, *, parameter: str, metric: str, filters: Mapping[str, object] | None = None, ax=None)

Plot one explicit one-parameter slice of an RQA sensitivity grid.

Source code in src/eyetrajectoriespy/nonlinear_plotting.py
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
def plot_rqa_sensitivity(
    result: RQAParameterSensitivityResult,
    *,
    parameter: str,
    metric: str,
    filters: Mapping[str, object] | None = None,
    ax=None,
):
    """Plot one explicit one-parameter slice of an RQA sensitivity grid."""

    if metric not in result.metric_columns:
        raise KeyError(f"Unknown RQA sensitivity metric {metric!r}")
    selected = _filtered_sensitivity_slice(
        result.table,
        parameter=parameter,
        response=metric,
        filters=filters,
    )
    if ax is None:
        _, ax = plt.subplots()
    ax.plot(selected[parameter], selected[metric], marker="o")
    ax.set_xlabel(parameter)
    ax.set_ylabel(metric)
    ax.set_title(f"RQA parameter sensitivity: {result.curve_id}")
    return ax

eyetrajectoriespy.plot_lyapunov_sensitivity

plot_lyapunov_sensitivity(result: LyapunovParameterSensitivityResult, *, parameter: str, response: str = 'exponent', filters: Mapping[str, object] | None = None, ax=None)

Plot one explicit one-parameter slice of an LLE sensitivity grid.

Source code in src/eyetrajectoriespy/nonlinear_plotting.py
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
def plot_lyapunov_sensitivity(
    result: LyapunovParameterSensitivityResult,
    *,
    parameter: str,
    response: str = "exponent",
    filters: Mapping[str, object] | None = None,
    ax=None,
):
    """Plot one explicit one-parameter slice of an LLE sensitivity grid."""

    allowed = {
        "exponent",
        "r_squared",
        "standard_error",
        "n_fit_points",
        "minimum_pair_count_in_fit",
        "total_zero_distance_count_in_fit",
    }
    if response not in allowed:
        raise KeyError(f"Unknown LLE sensitivity response {response!r}")
    selected = _filtered_sensitivity_slice(
        result.table,
        parameter=parameter,
        response=response,
        filters=filters,
    )
    if ax is None:
        _, ax = plt.subplots()
    ax.plot(selected[parameter], selected[response], marker="o")
    if response == "exponent":
        ax.axhline(0.0, linestyle=":")
    ax.set_xlabel(parameter)
    ax.set_ylabel(
        f"{response} ({result.exponent_unit})"
        if response == "exponent"
        else response
    )
    ax.set_title(f"Rosenstein sensitivity: {result.curve_id}")
    return ax

eyetrajectoriespy.plot_kantz_sensitivity

plot_kantz_sensitivity(result: KantzParameterSensitivityResult, *, parameter: str, response: str = 'exponent', filters: Mapping[str, object] | None = None, ax=None)

Plot one explicit one-parameter slice of a Kantz sensitivity grid.

Source code in src/eyetrajectoriespy/nonlinear_plotting.py
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
def plot_kantz_sensitivity(
    result: KantzParameterSensitivityResult,
    *,
    parameter: str,
    response: str = "exponent",
    filters: Mapping[str, object] | None = None,
    ax=None,
):
    """Plot one explicit one-parameter slice of a Kantz sensitivity grid."""

    allowed = {
        "exponent",
        "r_squared",
        "standard_error",
        "n_fit_points",
        "minimum_reference_count_in_fit",
        "minimum_pair_count_in_fit",
        "initial_supported_reference_fraction",
        "total_zero_mean_neighborhood_count_in_fit",
    }
    if response not in allowed:
        raise KeyError(f"Unknown Kantz sensitivity response {response!r}")
    selected = _filtered_sensitivity_slice(
        result.table,
        parameter=parameter,
        response=response,
        filters=filters,
    )
    if ax is None:
        _, ax = plt.subplots()
    ax.plot(selected[parameter], selected[response], marker="o")
    if response == "exponent":
        ax.axhline(0.0, linestyle=":")
    ax.set_xlabel(parameter)
    ax.set_ylabel(
        f"{response} ({result.exponent_unit})"
        if response == "exponent"
        else response
    )
    ax.set_title(f"Kantz sensitivity: {result.curve_id}")
    return ax

Local divergence and surrogate testing

eyetrajectoriespy.LocalDivergenceResult dataclass

Rosenstein-style mean log-divergence curve before linear fitting.

Source code in src/eyetrajectoriespy/nonlinear_types.py
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
@dataclass(frozen=True)
class LocalDivergenceResult:
    """Rosenstein-style mean log-divergence curve before linear fitting."""

    horizons: np.ndarray
    time_lags: np.ndarray
    mean_log_divergence: np.ndarray
    pair_counts: np.ndarray
    zero_distance_counts: np.ndarray
    nearest_neighbor_indices: np.ndarray
    theiler_window_samples: int
    max_horizon_samples: int
    curve_id: str
    time_unit: str
    provenance: Mapping[str, Any] = field(default_factory=dict)

eyetrajectoriespy.KantzDivergenceResult dataclass

Kantz neighborhood-averaged mean log-divergence curve.

Source code in src/eyetrajectoriespy/nonlinear_types.py
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
@dataclass(frozen=True)
class KantzDivergenceResult:
    """Kantz neighborhood-averaged mean log-divergence curve."""

    horizons: np.ndarray
    time_lags: np.ndarray
    mean_log_divergence: np.ndarray
    reference_counts: np.ndarray
    pair_counts: np.ndarray
    zero_mean_neighborhood_counts: np.ndarray
    initial_neighbor_counts: np.ndarray
    radius: float
    min_neighbors: int
    theiler_window_samples: int
    max_horizon_samples: int
    curve_id: str
    time_unit: str
    provenance: Mapping[str, Any] = field(default_factory=dict)

eyetrajectoriespy.LargestLyapunovResult dataclass

Largest-Lyapunov estimate from an explicitly selected divergence interval.

Source code in src/eyetrajectoriespy/nonlinear_types.py
351
352
353
354
355
356
357
358
359
360
361
362
363
364
@dataclass(frozen=True)
class LargestLyapunovResult:
    """Largest-Lyapunov estimate from an explicitly selected divergence interval."""

    exponent: float
    exponent_unit: str
    intercept: float
    r_squared: float
    standard_error: float
    fit_start: float
    fit_end: float
    n_fit_points: int
    divergence: LocalDivergenceResult | KantzDivergenceResult
    provenance: Mapping[str, Any] = field(default_factory=dict)

eyetrajectoriespy.SurrogateNonlinearityResult dataclass

Monte Carlo surrogate-data test for a declared nonlinear statistic.

Source code in src/eyetrajectoriespy/nonlinear_types.py
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
@dataclass(frozen=True)
class SurrogateNonlinearityResult:
    """Monte Carlo surrogate-data test for a declared nonlinear statistic."""

    observed_statistic: float
    surrogate_statistics: np.ndarray
    p_value: float
    alternative: str
    statistic: str
    method: str
    n_surrogates: int
    random_state: int | None
    convergence_iterations: np.ndarray
    spectral_errors: np.ndarray
    provenance: Mapping[str, Any] = field(default_factory=dict)

eyetrajectoriespy.local_divergence_curve

local_divergence_curve(embedding: DelayEmbeddingResult, *, curve: int | str, theiler_window: float | int, max_horizon: float | int, theiler_window_units: str = 'samples', max_horizon_units: str = 'samples') -> LocalDivergenceResult

Compute a Rosenstein-style mean log-divergence curve.

Zero distances encountered after forward evolution are excluded from the logarithm but counted explicitly in zero_distance_counts.

Source code in src/eyetrajectoriespy/nonlinear_dynamics.py
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
def local_divergence_curve(
    embedding: DelayEmbeddingResult,
    *,
    curve: int | str,
    theiler_window: float | int,
    max_horizon: float | int,
    theiler_window_units: str = "samples",
    max_horizon_units: str = "samples",
) -> LocalDivergenceResult:
    """Compute a Rosenstein-style mean log-divergence curve.

    Zero distances encountered after forward evolution are excluded from the
    logarithm but counted explicitly in zero_distance_counts.
    """

    index = _embedded_curve_index(embedding, curve)
    states = np.asarray(embedding.values[index], dtype=float)
    _require_finite(states, context="local divergence")
    theiler = _resolve_samples(
        embedding.time,
        theiler_window,
        units=theiler_window_units,
        time_unit=embedding.time_unit,
        name="theiler_window",
        allow_zero=True,
    )
    max_horizon_samples = _resolve_samples(
        embedding.time,
        max_horizon,
        units=max_horizon_units,
        time_unit=embedding.time_unit,
        name="max_horizon",
        allow_zero=True,
    )
    if max_horizon_samples < 1:
        raise ValueError("max_horizon must be at least one sample")
    if max_horizon_samples >= embedding.n_states:
        raise ValueError("max_horizon must be smaller than the number of reconstructed states")

    neighbor_indices, _ = _nearest_temporally_separated(
        states,
        theiler_window_samples=theiler,
    )
    horizons = np.arange(max_horizon_samples + 1, dtype=int)
    means = np.full(horizons.size, np.nan, dtype=float)
    pair_counts = np.zeros(horizons.size, dtype=int)
    zero_counts = np.zeros(horizons.size, dtype=int)

    for k in horizons:
        valid = (
            (np.arange(states.shape[0]) + k < states.shape[0])
            & (neighbor_indices + k < states.shape[0])
        )
        refs = np.where(valid)[0]
        if refs.size == 0:
            continue
        distances = np.linalg.norm(
            states[refs + k] - states[neighbor_indices[refs] + k],
            axis=1,
        )
        zero = distances <= 0
        zero_counts[k] = int(np.sum(zero))
        positive = distances[~zero]
        pair_counts[k] = int(positive.size)
        if positive.size:
            means[k] = float(np.mean(np.log(positive)))

    step = _regular_step(embedding.time)
    return LocalDivergenceResult(
        horizons=horizons,
        time_lags=horizons.astype(float) * step,
        mean_log_divergence=means,
        pair_counts=pair_counts,
        zero_distance_counts=zero_counts,
        nearest_neighbor_indices=neighbor_indices,
        theiler_window_samples=theiler,
        max_horizon_samples=max_horizon_samples,
        curve_id=embedding.curve_ids[index],
        time_unit=embedding.time_unit,
        provenance={
            "operation": "local_divergence_curve",
            "embedding_provenance": dict(embedding.provenance),
            "estimator_family": "Rosenstein nearest-neighbor divergence",
            "theiler_window_samples": theiler,
            "max_horizon_samples": max_horizon_samples,
            "zero_distance_policy": "excluded_from_log_and_counted_explicitly",
            "automatic_fit_interval_selection": False,
        },
    )

eyetrajectoriespy.kantz_divergence_curve

kantz_divergence_curve(embedding: DelayEmbeddingResult, *, curve: int | str, radius: float, theiler_window: float | int, max_horizon: float | int, min_neighbors: int = 2, theiler_window_units: str = 'samples', max_horizon_units: str = 'samples') -> KantzDivergenceResult

Compute a Kantz-style neighborhood-averaged log-divergence curve.

The neighborhood radius is fixed and analyst-declared. Reference states with fewer than the declared minimum neighbors are not enlarged or repaired automatically.

Source code in src/eyetrajectoriespy/nonlinear_dynamics.py
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
def kantz_divergence_curve(
    embedding: DelayEmbeddingResult,
    *,
    curve: int | str,
    radius: float,
    theiler_window: float | int,
    max_horizon: float | int,
    min_neighbors: int = 2,
    theiler_window_units: str = "samples",
    max_horizon_units: str = "samples",
) -> KantzDivergenceResult:
    """Compute a Kantz-style neighborhood-averaged log-divergence curve.

    The neighborhood radius is fixed and analyst-declared. Reference states
    with fewer than the declared minimum neighbors are not enlarged or
    repaired automatically.
    """

    if isinstance(radius, (bool, np.bool_)) or not isinstance(
        radius, (int, float, np.integer, np.floating)
    ):
        raise TypeError("radius must be numeric and not boolean")
    if not np.isfinite(radius) or float(radius) <= 0:
        raise ValueError("radius must be a positive finite value")
    if isinstance(min_neighbors, (bool, np.bool_)) or not isinstance(
        min_neighbors, (int, np.integer)
    ):
        raise TypeError("min_neighbors must be an integer")
    if int(min_neighbors) < 1:
        raise ValueError("min_neighbors must be at least 1")

    index = _embedded_curve_index(embedding, curve)
    states = np.asarray(embedding.values[index], dtype=float)
    _require_finite(states, context="Kantz local divergence")
    theiler = _resolve_samples(
        embedding.time,
        theiler_window,
        units=theiler_window_units,
        time_unit=embedding.time_unit,
        name="theiler_window",
        allow_zero=True,
    )
    max_horizon_samples = _resolve_samples(
        embedding.time,
        max_horizon,
        units=max_horizon_units,
        time_unit=embedding.time_unit,
        name="max_horizon",
        allow_zero=True,
    )
    if max_horizon_samples < 1:
        raise ValueError("max_horizon must be at least one sample")
    if max_horizon_samples >= embedding.n_states:
        raise ValueError(
            "max_horizon must be smaller than the number of reconstructed states"
        )

    tree = cKDTree(states)
    neighborhoods: list[np.ndarray] = []
    initial_neighbor_counts = np.zeros(states.shape[0], dtype=int)
    for reference in range(states.shape[0]):
        candidates = np.asarray(
            tree.query_ball_point(states[reference], r=float(radius)),
            dtype=int,
        )
        keep = (candidates != reference) & (
            np.abs(candidates - reference) > theiler
        )
        neighbors = np.sort(candidates[keep])
        neighborhoods.append(neighbors)
        initial_neighbor_counts[reference] = int(neighbors.size)

    horizons = np.arange(max_horizon_samples + 1, dtype=int)
    means = np.full(horizons.size, np.nan, dtype=float)
    reference_counts = np.zeros(horizons.size, dtype=int)
    pair_counts = np.zeros(horizons.size, dtype=int)
    zero_mean_counts = np.zeros(horizons.size, dtype=int)

    for k in horizons:
        log_reference_means: list[float] = []
        for reference, neighbors in enumerate(neighborhoods):
            if reference + k >= states.shape[0]:
                continue
            valid_neighbors = neighbors[neighbors + k < states.shape[0]]
            if valid_neighbors.size < int(min_neighbors):
                continue
            distances = np.linalg.norm(
                states[valid_neighbors + k] - states[reference + k],
                axis=1,
            )
            pair_counts[k] += int(distances.size)
            mean_distance = float(np.mean(distances))
            if mean_distance <= 0:
                zero_mean_counts[k] += 1
                continue
            log_reference_means.append(float(np.log(mean_distance)))
        reference_counts[k] = len(log_reference_means)
        if log_reference_means:
            means[k] = float(np.mean(log_reference_means))

    if reference_counts[0] == 0:
        raise ValueError(
            "no reference state has the declared minimum number of eligible "
            "neighbors inside radius; increase radius or lower min_neighbors "
            "explicitly"
        )

    step = _regular_step(embedding.time)
    return KantzDivergenceResult(
        horizons=horizons,
        time_lags=horizons.astype(float) * step,
        mean_log_divergence=means,
        reference_counts=reference_counts,
        pair_counts=pair_counts,
        zero_mean_neighborhood_counts=zero_mean_counts,
        initial_neighbor_counts=initial_neighbor_counts,
        radius=float(radius),
        min_neighbors=int(min_neighbors),
        theiler_window_samples=theiler,
        max_horizon_samples=max_horizon_samples,
        curve_id=embedding.curve_ids[index],
        time_unit=embedding.time_unit,
        provenance={
            "operation": "kantz_divergence_curve",
            "embedding_provenance": dict(embedding.provenance),
            "estimator_family": "Kantz fixed-radius neighborhood divergence",
            "radius": float(radius),
            "min_neighbors": int(min_neighbors),
            "theiler_window_samples": theiler,
            "max_horizon_samples": max_horizon_samples,
            "distance_metric": "euclidean",
            "radius_selected_automatically": False,
            "neighborhood_expansion_policy": "none_fail_or_skip_reference",
            "zero_mean_neighborhood_policy": (
                "excluded_from_log_and_counted_explicitly"
            ),
            "automatic_fit_interval_selection": False,
        },
    )

eyetrajectoriespy.estimate_largest_lyapunov_rosenstein

estimate_largest_lyapunov_rosenstein(divergence: LocalDivergenceResult, *, fit_start: float | int, fit_end: float | int, fit_units: str = 'samples') -> LargestLyapunovResult

Fit the declared linear segment of a Rosenstein divergence curve.

A positive result is a local-divergence estimate and is not, by itself, evidence that behavioral gaze is a deterministic chaotic system.

Source code in src/eyetrajectoriespy/nonlinear_dynamics.py
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
def estimate_largest_lyapunov_rosenstein(
    divergence: LocalDivergenceResult,
    *,
    fit_start: float | int,
    fit_end: float | int,
    fit_units: str = "samples",
) -> LargestLyapunovResult:
    """Fit the declared linear segment of a Rosenstein divergence curve.

    A positive result is a local-divergence estimate and is not, by itself,
    evidence that behavioral gaze is a deterministic chaotic system.
    """

    if not isinstance(divergence, LocalDivergenceResult):
        raise TypeError(
            "estimate_largest_lyapunov_rosenstein requires LocalDivergenceResult"
        )

    start, end = _fit_interval_samples(divergence, fit_start, fit_end, fit_units)
    mask = (
        (divergence.horizons >= start)
        & (divergence.horizons <= end)
        & np.isfinite(divergence.mean_log_divergence)
        & (divergence.pair_counts > 0)
    )
    if int(np.sum(mask)) < 3:
        raise ValueError("the declared fit interval contains fewer than three finite divergence points")

    raw_time = divergence.time_lags[mask]
    normalized_unit = divergence.time_unit.lower()
    if normalized_unit in _TIME_TO_SECONDS:
        x = raw_time * _TIME_TO_SECONDS[normalized_unit]
        exponent_unit = "1/s"
    else:
        x = raw_time
        exponent_unit = f"1/{divergence.time_unit}"
    y = divergence.mean_log_divergence[mask]
    fit = stats.linregress(x, y)
    return LargestLyapunovResult(
        exponent=float(fit.slope),
        exponent_unit=exponent_unit,
        intercept=float(fit.intercept),
        r_squared=float(fit.rvalue**2),
        standard_error=float(fit.stderr),
        fit_start=float(divergence.time_lags[start]),
        fit_end=float(divergence.time_lags[end]),
        n_fit_points=int(np.sum(mask)),
        divergence=divergence,
        provenance={
            "operation": "estimate_largest_lyapunov_rosenstein",
            "divergence_provenance": dict(divergence.provenance),
            "fit_start_samples": start,
            "fit_end_samples": end,
            "fit_interval_selected_automatically": False,
            "interpretation_boundary": (
                "positive exponent estimates local exponential divergence; "
                "it is not standalone evidence of deterministic chaos"
            ),
        },
    )

eyetrajectoriespy.estimate_largest_lyapunov_kantz

estimate_largest_lyapunov_kantz(divergence: KantzDivergenceResult, *, fit_start: float | int, fit_end: float | int, fit_units: str = 'samples') -> LargestLyapunovResult

Fit the declared linear segment of a Kantz divergence curve.

Source code in src/eyetrajectoriespy/nonlinear_dynamics.py
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
def estimate_largest_lyapunov_kantz(
    divergence: KantzDivergenceResult,
    *,
    fit_start: float | int,
    fit_end: float | int,
    fit_units: str = "samples",
) -> LargestLyapunovResult:
    """Fit the declared linear segment of a Kantz divergence curve."""

    if not isinstance(divergence, KantzDivergenceResult):
        raise TypeError(
            "estimate_largest_lyapunov_kantz requires KantzDivergenceResult"
        )
    start, end = _fit_interval_samples(
        divergence,
        fit_start,
        fit_end,
        fit_units,
    )
    mask = (
        (divergence.horizons >= start)
        & (divergence.horizons <= end)
        & np.isfinite(divergence.mean_log_divergence)
        & (divergence.reference_counts > 0)
    )
    if int(np.sum(mask)) < 3:
        raise ValueError(
            "the declared fit interval contains fewer than three finite "
            "Kantz divergence points"
        )

    raw_time = divergence.time_lags[mask]
    normalized_unit = divergence.time_unit.lower()
    if normalized_unit in _TIME_TO_SECONDS:
        x = raw_time * _TIME_TO_SECONDS[normalized_unit]
        exponent_unit = "1/s"
    else:
        x = raw_time
        exponent_unit = f"1/{divergence.time_unit}"
    y = divergence.mean_log_divergence[mask]
    fit = stats.linregress(x, y)
    return LargestLyapunovResult(
        exponent=float(fit.slope),
        exponent_unit=exponent_unit,
        intercept=float(fit.intercept),
        r_squared=float(fit.rvalue**2),
        standard_error=float(fit.stderr),
        fit_start=float(divergence.time_lags[start]),
        fit_end=float(divergence.time_lags[end]),
        n_fit_points=int(np.sum(mask)),
        divergence=divergence,
        provenance={
            "operation": "estimate_largest_lyapunov_kantz",
            "divergence_provenance": dict(divergence.provenance),
            "fit_start_samples": start,
            "fit_end_samples": end,
            "fit_interval_selected_automatically": False,
            "interpretation_boundary": (
                "positive exponent estimates local exponential divergence "
                "under the declared Kantz neighborhood contract; it is not "
                "standalone evidence of deterministic chaos"
            ),
        },
    )

eyetrajectoriespy.surrogate_nonlinearity_test

surrogate_nonlinearity_test(trajectories: TrajectorySet, *, curve: int | str, dimension: str, statistic: str, embedding_dimension: int, delay: float | int, theiler_window: float | int, max_horizon: float | int, fit_start: float | int, fit_end: float | int, delay_units: str = 'samples', theiler_window_units: str = 'samples', max_horizon_units: str = 'samples', fit_units: str = 'samples', method: str = 'iaaft', n_surrogates: int = 199, alternative: str = 'greater', max_iterations: int = 1000, tolerance: float = 1e-08, random_state: int | None = None) -> SurrogateNonlinearityResult

Test a declared nonlinear statistic against IAAFT surrogate series.

Version 0.23 supports statistic='largest_lyapunov' only. Every surrogate is evaluated under the identical embedding and fit contract.

Source code in src/eyetrajectoriespy/nonlinear_dynamics.py
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
def surrogate_nonlinearity_test(
    trajectories: TrajectorySet,
    *,
    curve: int | str,
    dimension: str,
    statistic: str,
    embedding_dimension: int,
    delay: float | int,
    theiler_window: float | int,
    max_horizon: float | int,
    fit_start: float | int,
    fit_end: float | int,
    delay_units: str = "samples",
    theiler_window_units: str = "samples",
    max_horizon_units: str = "samples",
    fit_units: str = "samples",
    method: str = "iaaft",
    n_surrogates: int = 199,
    alternative: str = "greater",
    max_iterations: int = 1000,
    tolerance: float = 1e-8,
    random_state: int | None = None,
) -> SurrogateNonlinearityResult:
    """Test a declared nonlinear statistic against IAAFT surrogate series.

    Version 0.23 supports statistic='largest_lyapunov' only. Every surrogate is
    evaluated under the identical embedding and fit contract.
    """

    if statistic != "largest_lyapunov":
        raise ValueError("0.23 supports statistic='largest_lyapunov' only")
    if method != "iaaft":
        raise ValueError("0.23 supports method='iaaft' only")
    if alternative not in {"greater", "less", "two-sided"}:
        raise ValueError("alternative must be 'greater', 'less', or 'two-sided'")
    if not isinstance(n_surrogates, (int, np.integer)) or n_surrogates < 1:
        raise ValueError("n_surrogates must be a positive integer")
    if not isinstance(max_iterations, (int, np.integer)) or max_iterations < 1:
        raise ValueError("max_iterations must be a positive integer")
    if not np.isfinite(tolerance) or tolerance <= 0:
        raise ValueError("tolerance must be positive and finite")
    if dimension not in trajectories.dimension_names:
        raise KeyError(f"Unknown dimension {dimension!r}")

    curve_index = _curve_index(trajectories, curve)
    signal = trajectories.dimension(dimension)[curve_index].astype(float, copy=False)
    _require_finite(signal, context="surrogate nonlinearity testing")
    if np.std(signal, ddof=0) <= 0:
        raise ValueError("surrogate testing requires a non-constant signal")

    observed_trajectory = _scalar_trajectory(
        trajectories,
        curve_index=curve_index,
        dimension=dimension,
        values=signal,
    )
    common = {
        "embedding_dimension": embedding_dimension,
        "delay": delay,
        "delay_units": delay_units,
        "theiler_window": theiler_window,
        "theiler_window_units": theiler_window_units,
        "max_horizon": max_horizon,
        "max_horizon_units": max_horizon_units,
        "fit_start": fit_start,
        "fit_end": fit_end,
        "fit_units": fit_units,
    }
    observed = _lle_statistic(observed_trajectory, **common)

    rng = np.random.default_rng(random_state)
    surrogate_statistics = np.empty(int(n_surrogates), dtype=float)
    iterations = np.empty(int(n_surrogates), dtype=int)
    spectral_errors = np.empty(int(n_surrogates), dtype=float)
    for b in range(int(n_surrogates)):
        try:
            surrogate, n_iter, spectral_error = _iaaft_one(
                signal,
                rng=rng,
                max_iterations=int(max_iterations),
                tolerance=float(tolerance),
            )
            surrogate_trajectory = _scalar_trajectory(
                trajectories,
                curve_index=curve_index,
                dimension=dimension,
                values=surrogate,
            )
            surrogate_statistics[b] = _lle_statistic(surrogate_trajectory, **common)
        except Exception as exc:
            raise RuntimeError(
                f"surrogate {b} failed under the declared analysis contract"
            ) from exc
        iterations[b] = n_iter
        spectral_errors[b] = spectral_error

    upper = (int(np.sum(surrogate_statistics >= observed)) + 1.0) / (
        int(n_surrogates) + 1.0
    )
    lower = (int(np.sum(surrogate_statistics <= observed)) + 1.0) / (
        int(n_surrogates) + 1.0
    )
    if alternative == "greater":
        p_value = upper
    elif alternative == "less":
        p_value = lower
    else:
        p_value = min(1.0, 2.0 * min(upper, lower))

    return SurrogateNonlinearityResult(
        observed_statistic=float(observed),
        surrogate_statistics=surrogate_statistics,
        p_value=float(p_value),
        alternative=alternative,
        statistic=statistic,
        method=method,
        n_surrogates=int(n_surrogates),
        random_state=random_state,
        convergence_iterations=iterations,
        spectral_errors=spectral_errors,
        provenance={
            "operation": "surrogate_nonlinearity_test",
            "source_provenance": dict(trajectories.provenance),
            "curve_id": trajectories.curve_ids[curve_index],
            "dimension": dimension,
            "surrogate_method": "IAAFT",
            "p_value_correction": (
                "plus_one" if alternative in {"greater", "less"} else "two_sided_double_min_plus_one_tails"
            ),
            "maximum_final_spectral_error": float(np.max(spectral_errors)),
            "common_statistic_settings": common,
            "failed_surrogate_policy": "raise",
            "interpretation_boundary": (
                "rejection is evidence against the declared linear-stochastic surrogate null; "
                "it does not establish a unique deterministic chaotic mechanism"
            ),
        },
    )

eyetrajectoriespy.plot_local_divergence

plot_local_divergence(result: LocalDivergenceResult | KantzDivergenceResult | LargestLyapunovResult, *, ax=None)

Plot the mean log-divergence curve and an explicit LLE fit when present.

Source code in src/eyetrajectoriespy/nonlinear_plotting.py
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
def plot_local_divergence(
    result: LocalDivergenceResult | KantzDivergenceResult | LargestLyapunovResult,
    *,
    ax=None,
):
    """Plot the mean log-divergence curve and an explicit LLE fit when present."""

    fit = result if isinstance(result, LargestLyapunovResult) else None
    divergence = fit.divergence if fit is not None else result
    if ax is None:
        _, ax = plt.subplots()
    ax.plot(
        divergence.time_lags,
        divergence.mean_log_divergence,
        marker="o",
        label="Mean log divergence",
    )
    if fit is not None:
        mask = (
            (divergence.time_lags >= fit.fit_start)
            & (divergence.time_lags <= fit.fit_end)
            & np.isfinite(divergence.mean_log_divergence)
        )
        normalized = divergence.time_unit.lower()
        if normalized in {"s", "sec", "second", "seconds"}:
            x_fit = divergence.time_lags[mask]
        elif normalized in {"ms", "millisecond", "milliseconds"}:
            x_fit = divergence.time_lags[mask] * 1e-3
        else:
            x_fit = divergence.time_lags[mask]
        y_fit = fit.intercept + fit.exponent * x_fit
        ax.plot(
            divergence.time_lags[mask],
            y_fit,
            linestyle="--",
            label=f"LLE fit: {fit.exponent:.3g} {fit.exponent_unit}",
        )
    ax.set_xlabel(f"Divergence lag ({divergence.time_unit})")
    ax.set_ylabel("Mean log distance")
    ax.legend()
    family = divergence.provenance.get("estimator_family", "local divergence")
    ax.set_title(f"{family}: {divergence.curve_id}")
    return ax

eyetrajectoriespy.plot_surrogate_nonlinearity

plot_surrogate_nonlinearity(result: SurrogateNonlinearityResult, *, bins: int = 20, ax=None)

Plot the surrogate statistic distribution and observed statistic.

Source code in src/eyetrajectoriespy/nonlinear_plotting.py
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
def plot_surrogate_nonlinearity(
    result: SurrogateNonlinearityResult,
    *,
    bins: int = 20,
    ax=None,
):
    """Plot the surrogate statistic distribution and observed statistic."""

    if not isinstance(bins, int) or bins < 2:
        raise ValueError("bins must be an integer >= 2")
    if ax is None:
        _, ax = plt.subplots()
    ax.hist(result.surrogate_statistics, bins=bins, alpha=0.7)
    ax.axvline(result.observed_statistic, linestyle="--", label="Observed")
    ax.set_xlabel(result.statistic)
    ax.set_ylabel("Surrogate count")
    ax.set_title(f"{result.method.upper()} surrogate test (p={result.p_value:.3g})")
    ax.legend()
    return ax

Nonlinear reporting helpers

eyetrajectoriespy.recurrence_radius_profile_reporting_text

recurrence_radius_profile_reporting_text(result: RecurrenceRadiusProfileResult) -> str

Return manuscript-ready wording for recurrence-threshold diagnostics.

Source code in src/eyetrajectoriespy/nonlinear_reporting.py
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
def recurrence_radius_profile_reporting_text(
    result: RecurrenceRadiusProfileResult,
) -> str:
    """Return manuscript-ready wording for recurrence-threshold diagnostics."""

    table = result.table
    coverage = float(
        result.provenance.get(
            "maximum_radius_coverage_fraction",
            table["recurrence_rate"].iloc[-1],
        )
    )
    complete = bool(
        result.provenance.get("full_distance_distribution_captured", False)
    )
    coverage_text = (
        "the supplied maximum radius covered the complete eligible "
        "pair-distance distribution"
        if complete
        else (
            f"the supplied maximum radius covered {100.0 * coverage:.1f}% "
            "of eligible pair distances"
        )
    )
    return (
        f"Recurrence-threshold diagnostics evaluated {result.n_radii} "
        f"predeclared radii for curve {result.curve_id!r} using the "
        f"{result.metric} state-space distance and a Theiler window of "
        f"{result.theiler_window_samples} samples. RR increased from "
        f"{table['recurrence_rate'].iloc[0]:.4g} to "
        f"{table['recurrence_rate'].iloc[-1]:.4g}; {coverage_text}. "
        "Cumulative and shell pair counts were computed with the same "
        "eligible-pair denominator and inclusive radius rule as the base "
        "recurrence estimator. No radius was selected automatically."
    )

eyetrajectoriespy.rqa_metric_mean_bootstrap_reporting_text

rqa_metric_mean_bootstrap_reporting_text(result: RQAMeanBootstrapResult) -> str

Return manuscript-ready wording for population-average RQA uncertainty.

Source code in src/eyetrajectoriespy/nonlinear_reporting.py
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
def rqa_metric_mean_bootstrap_reporting_text(
    result: RQAMeanBootstrapResult,
) -> str:
    """Return manuscript-ready wording for population-average RQA uncertainty."""

    metric_text = ", ".join(result.metrics)
    if result.unit == "participant":
        unit_text = (
            f"{result.n_units} equal-weight participant units defined by "
            f"{result.participant_column!r}; repeated curve-level RQA metrics "
            "were averaged within participant before resampling"
        )
    else:
        unit_text = f"{result.n_units} curve-level independent units"

    return (
        f"Population-average RQA uncertainty for {metric_text} used "
        f"{result.n_bootstrap} percentile bootstrap replicates at the "
        f"{100.0 * result.confidence_level:.1f}% level and {unit_text}. "
        "Each source curve was summarized under one fixed declared RQA "
        "specification before unit-level resampling. Undefined selected "
        "curve-level metrics caused analysis failure rather than deletion or "
        "imputation. These intervals describe between-unit population sampling "
        "uncertainty conditional on the declared RQA specification; they do "
        "not estimate within-single-trajectory recurrence uncertainty, "
        "recurrence-line resampling uncertainty, or parameter-selection "
        "uncertainty."
    )

eyetrajectoriespy.rqa_reporting_text

rqa_reporting_text(recurrence: RecurrenceResult, metrics: RQAResult) -> str

Return concise manuscript-ready recurrence-analysis wording.

Source code in src/eyetrajectoriespy/nonlinear_reporting.py
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
def rqa_reporting_text(
    recurrence: RecurrenceResult,
    metrics: RQAResult,
) -> str:
    """Return concise manuscript-ready recurrence-analysis wording."""

    radius_policy = recurrence.provenance.get("radius_policy", "unknown")
    if radius_policy == "target_recurrence_rate":
        policy = (
            f"target recurrence rate {recurrence.target_recurrence_rate:.4g}, "
            f"yielding radius {recurrence.radius:.4g} and achieved RR "
            f"{recurrence.achieved_recurrence_rate:.4g}"
        )
    else:
        policy = f"fixed radius {recurrence.radius:.4g}"
    return (
        f"Recurrence analysis used {recurrence.kind} recurrence with the "
        f"{recurrence.metric} metric, {policy}, and a Theiler window of "
        f"{recurrence.theiler_window_samples} samples. RQA used minimum "
        f"diagonal and vertical line lengths of {metrics.min_diagonal_length} "
        f"and {metrics.min_vertical_length}, respectively. The resulting "
        f"RR was {metrics.recurrence_rate:.4g}, DET {metrics.determinism:.4g}, "
        f"LAM {metrics.laminarity:.4g}, and trapping time "
        f"{metrics.trapping_time:.4g}."
    )

eyetrajectoriespy.rqa_parameter_sensitivity_reporting_text

rqa_parameter_sensitivity_reporting_text(result: RQAParameterSensitivityResult) -> str

Return manuscript-ready wording for an RQA parameter multiverse.

Source code in src/eyetrajectoriespy/nonlinear_reporting.py
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
def rqa_parameter_sensitivity_reporting_text(
    result: RQAParameterSensitivityResult,
) -> str:
    """Return manuscript-ready wording for an RQA parameter multiverse."""

    summary = result.summary_table.set_index("metric")
    det = summary.loc["determinism"]
    lam = summary.loc["laminarity"]
    rr = summary.loc["recurrence_rate"]
    threshold_policy = result.provenance.get("threshold_policy", "unknown")
    controlled = (
        " Recurrence rate was controlled by the target-rate design and was "
        "not treated as an independent robustness outcome."
        if threshold_policy == "target_recurrence_rate"
        else ""
    )
    return (
        f"RQA robustness was evaluated across {result.n_specifications} "
        f"predeclared parameter specifications for curve {result.curve_id!r}. "
        f"DET ranged from {det['minimum']:.4g} to {det['maximum']:.4g}, "
        f"LAM from {lam['minimum']:.4g} to {lam['maximum']:.4g}, and RR from "
        f"{rr['minimum']:.4g} to {rr['maximum']:.4g}. Every Cartesian-product "
        "specification was retained in the sensitivity table; no parameter "
        "combination was selected automatically and invalid specifications "
        "were configured to fail the analysis rather than disappear silently."
        + controlled
    )

eyetrajectoriespy.windowed_rqa_reporting_text

windowed_rqa_reporting_text(result: WindowedRQAResult) -> str

Return concise manuscript-ready wording for sliding-window RQA.

Source code in src/eyetrajectoriespy/nonlinear_reporting.py
221
222
223
224
225
226
227
228
229
230
def windowed_rqa_reporting_text(result: WindowedRQAResult) -> str:
    """Return concise manuscript-ready wording for sliding-window RQA."""

    return (
        f"Windowed RQA used full windows of {result.window_samples} samples "
        f"advanced by {result.step_samples} samples, producing "
        f"{len(result.table)} windows. The final {result.dropped_tail_samples} "
        "samples outside a complete window were retained in the audit record "
        "but not analyzed as a partial window."
    )

eyetrajectoriespy.windowed_rqa_functional_reporting_text

windowed_rqa_functional_reporting_text(result: WindowedRQAFunctionalResult) -> str

Return manuscript-ready wording for RQA-derived functional trajectories.

Source code in src/eyetrajectoriespy/nonlinear_reporting.py
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
def windowed_rqa_functional_reporting_text(
    result: WindowedRQAFunctionalResult,
) -> str:
    """Return manuscript-ready wording for RQA-derived functional trajectories."""

    metric_text = ", ".join(result.metrics)
    overlap = 100.0 * result.overlap_fraction
    radius_policy = result.trajectories.provenance.get("radius_policy", "unknown")
    return (
        f"Windowed RQA was converted to functional trajectories for "
        f"{result.n_curves} source curve(s) using {result.n_windows} complete "
        f"windows of {result.window_samples} samples, advanced by "
        f"{result.step_samples} samples ({overlap:.1f}% sample overlap). "
        f"Functional dimensions were {metric_text}; the recurrence-radius "
        f"policy was {radius_policy}. Window centers formed the functional "
        f"time grid and {result.dropped_tail_samples} trailing samples outside "
        "a complete window were excluded explicitly. Window rows were not "
        "treated as independent observations; downstream inference retained "
        "the source curve/participant as the sampling unit."
    )

eyetrajectoriespy.windowed_rqa_sensitivity_reporting_text

windowed_rqa_sensitivity_reporting_text(result: WindowedRQASensitivityResult) -> str

Return manuscript-ready wording for declared window/step sensitivity.

Source code in src/eyetrajectoriespy/nonlinear_reporting.py
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
def windowed_rqa_sensitivity_reporting_text(
    result: WindowedRQASensitivityResult,
) -> str:
    """Return manuscript-ready wording for declared window/step sensitivity."""

    design = result.design_table
    overlap_min = 100.0 * float(design["overlap_fraction"].min())
    overlap_max = 100.0 * float(design["overlap_fraction"].max())
    spacing_min = float(design["profile_grid_spacing_time"].min())
    spacing_max = float(design["profile_grid_spacing_time"].max())
    return (
        f"Functional RQA sensitivity was evaluated across "
        f"{result.n_specifications} predeclared window/step specifications. "
        f"Sample overlap ranged from {overlap_min:.1f}% to {overlap_max:.1f}% "
        f"and derived-profile grid spacing ranged from {spacing_min:.4g} to "
        f"{spacing_max:.4g} {design['profile_time_unit'].iloc[0]}. "
        "Overlap diagnostics quantified deterministic source-sample reuse; "
        "they were not converted into an effective independent sample size. "
        "Pairwise profile comparisons used exact shared window centers only, "
        "with no interpolation and no automatic selection of a preferred "
        "window/step specification."
    )

eyetrajectoriespy.windowed_rqa_mean_band_reporting_text

windowed_rqa_mean_band_reporting_text(result: WindowedRQAMeanBandResult) -> str

Return wording for unit-level simultaneous functional-RQA inference.

Source code in src/eyetrajectoriespy/nonlinear_reporting.py
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
def windowed_rqa_mean_band_reporting_text(
    result: WindowedRQAMeanBandResult,
) -> str:
    """Return wording for unit-level simultaneous functional-RQA inference."""

    band = result.band
    overlap = 100.0 * result.functional_rqa.overlap_fraction
    if result.unit == "participant":
        unit_text = (
            f"participant-level units defined by "
            f"{result.participant_column!r}"
        )
    else:
        unit_text = "curve-level units"
    return (
        f"Windowed RQA functions used {result.functional_rqa.window_samples}-sample "
        f"windows advanced by {result.functional_rqa.step_samples} samples "
        f"({overlap:.1f}% overlap). Simultaneous observed-grid mean inference "
        f"used {len(band.unit_ids)} independent {unit_text} and a "
        f"{100.0 * band.confidence_level:.1f}% Gaussian multiplier band. "
        "Complete derived functions, not window rows, were the resampling "
        "objects, preserving within-function temporal dependence. The procedure "
        "does not provide a within-trajectory block-bootstrap guarantee or "
        "treat overlapping windows as independent observations."
    )

eyetrajectoriespy.kantz_parameter_sensitivity_reporting_text

kantz_parameter_sensitivity_reporting_text(result: KantzParameterSensitivityResult) -> str

Return manuscript-ready wording for declared Kantz-LLE sensitivity.

Source code in src/eyetrajectoriespy/nonlinear_reporting.py
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
def kantz_parameter_sensitivity_reporting_text(
    result: KantzParameterSensitivityResult,
) -> str:
    """Return manuscript-ready wording for declared Kantz-LLE sensitivity."""

    summary = result.summary_table.set_index("metric")
    exponent = summary.loc["exponent"]
    support = summary.loc["initial_supported_reference_fraction"]
    return (
        f"Kantz local-divergence sensitivity was evaluated across "
        f"{result.n_specifications} predeclared reconstruction, radius, "
        f"minimum-neighbor, Theiler-window, and fit-interval specifications "
        f"for curve {result.curve_id!r}. Estimated exponents ranged from "
        f"{exponent['minimum']:.4g} to {exponent['maximum']:.4g} "
        f"{result.exponent_unit}; "
        f"{100.0 * exponent['positive_specification_fraction']:.1f}% of "
        "finite declared specifications had positive slopes. Initial "
        f"supported-reference fractions ranged from {support['minimum']:.3f} "
        f"to {support['maximum']:.3f}. These quantities describe sensitivity "
        "across the declared analysis grid, not sampling uncertainty or a "
        "probability of deterministic chaos. No radius, reconstruction, "
        "minimum-neighbor rule, or fit interval was selected automatically."
    )

eyetrajectoriespy.largest_lyapunov_reporting_text

largest_lyapunov_reporting_text(result: LargestLyapunovResult) -> str

Return named-estimator LLE wording without turning slope into a chaos claim.

Source code in src/eyetrajectoriespy/nonlinear_reporting.py
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
def largest_lyapunov_reporting_text(result: LargestLyapunovResult) -> str:
    """Return named-estimator LLE wording without turning slope into a chaos claim."""

    if isinstance(result.divergence, KantzDivergenceResult):
        method = (
            f"Kantz fixed-radius neighborhood divergence "
            f"(radius={result.divergence.radius:.4g}, "
            f"min_neighbors={result.divergence.min_neighbors})"
        )
    else:
        method = "Rosenstein nearest-neighbor divergence"

    return (
        f"A {method} estimate was fitted over "
        f"{result.fit_start:.4g} to {result.fit_end:.4g} "
        f"{result.divergence.time_unit} using {result.n_fit_points} divergence "
        f"points. The estimated largest Lyapunov exponent was "
        f"{result.exponent:.4g} {result.exponent_unit} "
        f"(R^2={result.r_squared:.3f}, slope SE={result.standard_error:.4g}). "
        "This quantity was interpreted as a conditional local-divergence "
        "estimate and not as standalone evidence of deterministic chaos."
    )

eyetrajectoriespy.lyapunov_parameter_sensitivity_reporting_text

lyapunov_parameter_sensitivity_reporting_text(result: LyapunovParameterSensitivityResult) -> str

Return manuscript-ready wording for Rosenstein-LLE sensitivity.

Source code in src/eyetrajectoriespy/nonlinear_reporting.py
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
def lyapunov_parameter_sensitivity_reporting_text(
    result: LyapunovParameterSensitivityResult,
) -> str:
    """Return manuscript-ready wording for Rosenstein-LLE sensitivity."""

    summary = result.summary_table.set_index("metric")
    exponent = summary.loc["exponent"]
    return (
        f"Rosenstein local-divergence sensitivity was evaluated across "
        f"{result.n_specifications} predeclared reconstruction, Theiler-window, "
        f"and fit-interval specifications for curve {result.curve_id!r}. "
        f"Estimated exponents ranged from {exponent['minimum']:.4g} to "
        f"{exponent['maximum']:.4g} {result.exponent_unit}; "
        f"{100.0 * exponent['positive_specification_fraction']:.1f}% of finite "
        "declared specifications had positive slopes. This percentage was "
        "reported only as descriptive sensitivity across the declared analysis "
        "grid, not as a probability of deterministic chaos. No reconstruction "
        "or fit interval was selected automatically."
    )

eyetrajectoriespy.surrogate_nonlinearity_reporting_text

surrogate_nonlinearity_reporting_text(result: SurrogateNonlinearityResult) -> str

Return manuscript-ready IAAFT surrogate-test wording.

Source code in src/eyetrajectoriespy/nonlinear_reporting.py
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
def surrogate_nonlinearity_reporting_text(
    result: SurrogateNonlinearityResult,
) -> str:
    """Return manuscript-ready IAAFT surrogate-test wording."""

    finite = np.isfinite(result.surrogate_statistics)
    if not np.all(finite):
        raise ValueError("surrogate statistics must all be finite for reporting")
    return (
        f"Nonlinearity was assessed with {result.n_surrogates} "
        f"{result.method.upper()} surrogates using the "
        f"{result.statistic.replace('_', ' ')} statistic and a "
        f"{result.alternative} alternative. A plus-one Monte Carlo p-value "
        f"was used (p={result.p_value:.4g}; random_state={result.random_state}); "
        f"the maximum retained final relative spectrum mismatch was "
        f"{np.max(result.spectral_errors):.4g}. "
        "The test was interpreted relative to the declared surrogate null, "
        "not as proof of a unique nonlinear or chaotic mechanism."
    )

eyetrajectoriespy.return_map_stability_reporting_text

return_map_stability_reporting_text(fit: LocalReturnMapResult, result: ReturnMapStabilityResult) -> str

Return manuscript-ready wording for experimental empirical return-map stability.

Source code in src/eyetrajectoriespy/nonlinear_reporting.py
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
def return_map_stability_reporting_text(
    fit: LocalReturnMapResult,
    result: ReturnMapStabilityResult,
) -> str:
    """Return manuscript-ready wording for experimental empirical return-map stability."""

    eigenvalue_text = ", ".join(
        f"{value.real:.4g}{value.imag:+.4g}i" if abs(value.imag) > 1e-12 else f"{value.real:.4g}"
        for value in result.eigenvalues
    )
    r2_text = ", ".join(
        "nan" if not np.isfinite(value) else f"{value:.3f}"
        for value in fit.r_squared
    )
    crossing = fit.provenance.get("crossing_provenance", {})
    section = crossing.get("section_dimension", "unknown")
    section_value = crossing.get("section_value", "unknown")
    direction = crossing.get("direction", "unknown")
    return (
        "Experimental empirical return-map stability used section "
        f"{section}={section_value} with {direction} crossings and a "
        f"{fit.neighborhood_policy} neighborhood ({fit.neighborhood_value}); "
        f"{fit.n_transitions} transitions were fitted. The local design "
        f"condition number was {fit.design_condition_number:.4g} and per-state "
        f"R^2 values were [{r2_text}]. Jacobian eigenvalues were "
        f"({eigenvalue_text}), with spectral radius {result.spectral_radius:.4g} "
        f"and tolerance {result.tolerance:.4g}; the map was classified as "
        f"{result.classification}. These eigenvalues were not interpreted as "
        "classical Floquet multipliers and the fitted Jacobian was not described "
        "as a monodromy matrix."
    )

Experimental empirical return maps

eyetrajectoriespy.PoincareCrossingResult dataclass

Interpolated crossings of an explicitly declared Poincare section.

Source code in src/eyetrajectoriespy/nonlinear_types.py
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
@dataclass(frozen=True)
class PoincareCrossingResult:
    """Interpolated crossings of an explicitly declared Poincare section."""

    states: np.ndarray
    times: np.ndarray
    left_indices: np.ndarray
    fractions: np.ndarray
    curve_id: str
    section_dimension: str
    section_value: float
    direction: str
    state_dimensions: tuple[str, ...]
    provenance: Mapping[str, Any] = field(default_factory=dict)

    @property
    def n_crossings(self) -> int:
        return self.states.shape[0]

eyetrajectoriespy.LocalReturnMapResult dataclass

Local affine return-map fit around an explicitly declared reference state.

Source code in src/eyetrajectoriespy/nonlinear_types.py
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
@dataclass(frozen=True)
class LocalReturnMapResult:
    """Local affine return-map fit around an explicitly declared reference state."""

    reference_state: np.ndarray
    selected_transition_indices: np.ndarray
    jacobian: np.ndarray
    intercept: np.ndarray
    residuals: np.ndarray
    r_squared: np.ndarray
    design_condition_number: float
    neighborhood_policy: str
    neighborhood_value: float | int
    provenance: Mapping[str, Any] = field(default_factory=dict)

    @property
    def n_transitions(self) -> int:
        return self.selected_transition_indices.size

eyetrajectoriespy.ReturnMapStabilityResult dataclass

Eigenvalue-based local return-map contraction/expansion diagnostic.

Source code in src/eyetrajectoriespy/nonlinear_types.py
469
470
471
472
473
474
475
476
477
@dataclass(frozen=True)
class ReturnMapStabilityResult:
    """Eigenvalue-based local return-map contraction/expansion diagnostic."""

    eigenvalues: np.ndarray
    spectral_radius: float
    classification: str
    tolerance: float
    provenance: Mapping[str, Any] = field(default_factory=dict)

eyetrajectoriespy.poincare_crossings

poincare_crossings(trajectories: TrajectorySet, *, curve: int | str, section_dimension: str, section_value: float, direction: str, state_dimensions: Sequence[str] | None = None) -> PoincareCrossingResult

Interpolate crossings of an explicitly declared scalar section.

By default, the returned crossing state contains every trajectory dimension except the section dimension, avoiding an automatically constant coordinate in downstream return-map regression.

Source code in src/eyetrajectoriespy/return_maps.py
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
def poincare_crossings(
    trajectories: TrajectorySet,
    *,
    curve: int | str,
    section_dimension: str,
    section_value: float,
    direction: str,
    state_dimensions: Sequence[str] | None = None,
) -> PoincareCrossingResult:
    """Interpolate crossings of an explicitly declared scalar section.

    By default, the returned crossing state contains every trajectory dimension
    except the section dimension, avoiding an automatically constant coordinate
    in downstream return-map regression.
    """

    if section_dimension not in trajectories.dimension_names:
        raise KeyError(f"Unknown section_dimension {section_dimension!r}")
    if not np.isfinite(section_value):
        raise ValueError("section_value must be finite")
    if direction not in {"positive", "negative", "both"}:
        raise ValueError("direction must be 'positive', 'negative', or 'both'")

    curve_index = _curve_index(trajectories, curve)
    section_index = trajectories.dimension_names.index(section_dimension)
    if state_dimensions is None:
        raise ValueError("state_dimensions must be supplied explicitly")
    state_names = tuple(state_dimensions)
    if not state_names:
        raise ValueError(
            "state_dimensions must contain at least one non-section state variable"
        )
    if len(set(state_names)) != len(state_names):
        raise ValueError("state_dimensions must be unique")
    if section_dimension in state_names:
        raise ValueError(
            "state_dimensions cannot include section_dimension because that "
            "coordinate is constant on the declared Poincare section"
        )
    state_indices = []
    for name in state_names:
        if name not in trajectories.dimension_names:
            raise KeyError(f"Unknown state dimension {name!r}")
        state_indices.append(trajectories.dimension_names.index(name))

    values = trajectories.values[curve_index]
    _require_finite(values, context="Poincare crossing detection")
    section = values[:, section_index] - float(section_value)

    crossing_left = []
    fractions = []
    for i in range(trajectories.n_time - 1):
        a = float(section[i])
        b = float(section[i + 1])
        positive = a < 0 <= b
        negative = a > 0 >= b
        if not (
            (direction == "positive" and positive)
            or (direction == "negative" and negative)
            or (direction == "both" and (positive or negative))
        ):
            continue
        denominator = b - a
        if denominator == 0:
            continue
        fraction = -a / denominator
        if 0 <= fraction <= 1:
            crossing_left.append(i)
            fractions.append(fraction)

    if not crossing_left:
        raise ValueError("no crossings satisfy the declared section and direction")
    left = np.asarray(crossing_left, dtype=int)
    frac = np.asarray(fractions, dtype=float)
    states = np.empty((left.size, len(state_indices)), dtype=float)
    times = np.empty(left.size, dtype=float)
    for row, (i, f) in enumerate(zip(left, frac, strict=True)):
        states[row] = values[i, state_indices] + f * (
            values[i + 1, state_indices] - values[i, state_indices]
        )
        times[row] = trajectories.time[i] + f * (
            trajectories.time[i + 1] - trajectories.time[i]
        )

    return PoincareCrossingResult(
        states=states,
        times=times,
        left_indices=left,
        fractions=frac,
        curve_id=trajectories.curve_ids[curve_index],
        section_dimension=section_dimension,
        section_value=float(section_value),
        direction=direction,
        state_dimensions=state_names,
        provenance={
            "operation": "poincare_crossings",
            "source_provenance": dict(trajectories.provenance),
            "interpolation": "linear_between_observed_samples",
            "section_dimension": section_dimension,
            "section_value": float(section_value),
            "direction": direction,
            "state_dimensions": state_names,
            "experimental": True,
        },
    )

eyetrajectoriespy.fit_local_return_map

fit_local_return_map(crossings: PoincareCrossingResult, *, reference: str | ndarray, neighborhood_radius: float | None = None, n_neighbors: int | None = None) -> LocalReturnMapResult

Fit a local affine map from one section crossing to the next.

Exactly one neighborhood policy must be specified. The fitted Jacobian is empirical and must not be described as a classical monodromy matrix.

Source code in src/eyetrajectoriespy/return_maps.py
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
def fit_local_return_map(
    crossings: PoincareCrossingResult,
    *,
    reference: str | np.ndarray,
    neighborhood_radius: float | None = None,
    n_neighbors: int | None = None,
) -> LocalReturnMapResult:
    """Fit a local affine map from one section crossing to the next.

    Exactly one neighborhood policy must be specified.  The fitted Jacobian is
    empirical and must not be described as a classical monodromy matrix.
    """

    if (neighborhood_radius is None) == (n_neighbors is None):
        raise ValueError("supply exactly one of neighborhood_radius or n_neighbors")
    if crossings.n_crossings < 3:
        raise ValueError("at least three crossings are required to fit a return map")

    x = np.asarray(crossings.states[:-1], dtype=float)
    y = np.asarray(crossings.states[1:], dtype=float)
    reference_state, reference_policy = _reference_state(x, reference)
    distances = np.linalg.norm(x - reference_state, axis=1)

    if neighborhood_radius is not None:
        if not np.isfinite(neighborhood_radius) or neighborhood_radius <= 0:
            raise ValueError("neighborhood_radius must be positive and finite")
        selected = np.where(distances <= float(neighborhood_radius))[0]
        policy = "radius"
        value: float | int = float(neighborhood_radius)
    else:
        if not isinstance(n_neighbors, (int, np.integer)) or n_neighbors < 1:
            raise ValueError("n_neighbors must be a positive integer")
        if int(n_neighbors) > x.shape[0]:
            raise ValueError(
                "n_neighbors exceeds the number of available return-map transitions"
            )
        count = int(n_neighbors)
        selected = np.argsort(distances, kind="mergesort")[:count]
        policy = "n_neighbors"
        value = int(n_neighbors)

    state_dimension = x.shape[1]
    minimum = state_dimension + 1
    if selected.size < minimum:
        raise ValueError(
            f"local affine return map requires at least {minimum} selected transitions; "
            f"got {selected.size}"
        )

    x_centered = x[selected] - reference_state
    y_centered = y[selected] - reference_state
    design = np.column_stack([np.ones(selected.size), x_centered])
    if np.linalg.matrix_rank(design) < design.shape[1]:
        raise ValueError(
            "selected return-map neighborhood is rank deficient; "
            "change the declared section, state variables, reference, or neighborhood"
        )
    condition_number = float(np.linalg.cond(design))
    if not np.isfinite(condition_number):
        raise ValueError("selected return-map design has a non-finite condition number")
    coefficient, _, _, _ = np.linalg.lstsq(design, y_centered, rcond=None)
    fitted = design @ coefficient
    residuals = y_centered - fitted
    intercept = coefficient[0]
    jacobian = coefficient[1:].T

    r_squared = np.full(state_dimension, np.nan, dtype=float)
    for d in range(state_dimension):
        target = y_centered[:, d]
        ss_total = float(np.sum((target - target.mean()) ** 2))
        if ss_total > 0:
            ss_residual = float(np.sum(residuals[:, d] ** 2))
            r_squared[d] = 1.0 - ss_residual / ss_total

    return LocalReturnMapResult(
        reference_state=reference_state,
        selected_transition_indices=np.asarray(selected, dtype=int),
        jacobian=jacobian,
        intercept=np.asarray(intercept, dtype=float),
        residuals=residuals,
        r_squared=r_squared,
        design_condition_number=condition_number,
        neighborhood_policy=policy,
        neighborhood_value=value,
        provenance={
            "operation": "fit_local_return_map",
            "crossing_provenance": dict(crossings.provenance),
            "reference_policy": reference_policy,
            "neighborhood_policy": policy,
            "neighborhood_value": value,
            "n_selected_transitions": int(selected.size),
            "design_condition_number": condition_number,
            "fit": "local_affine_least_squares",
            "experimental": True,
            "interpretation_boundary": (
                "empirical local return-map Jacobian; not a variational-equation "
                "monodromy matrix and not a classical Floquet multiplier calculation"
            ),
        },
    )

eyetrajectoriespy.return_map_stability

return_map_stability(fit: LocalReturnMapResult, *, tolerance: float = 1e-06) -> ReturnMapStabilityResult

Summarize empirical return-map contraction or expansion from eigenvalues.

Source code in src/eyetrajectoriespy/return_maps.py
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
def return_map_stability(
    fit: LocalReturnMapResult,
    *,
    tolerance: float = 1e-6,
) -> ReturnMapStabilityResult:
    """Summarize empirical return-map contraction or expansion from eigenvalues."""

    if not np.isfinite(tolerance) or tolerance < 0:
        raise ValueError("tolerance must be non-negative and finite")
    if fit.jacobian.shape[0] != fit.jacobian.shape[1]:
        raise ValueError("return-map Jacobian must be square")
    eigenvalues = np.linalg.eigvals(fit.jacobian)
    spectral_radius = float(np.max(np.abs(eigenvalues)))
    if spectral_radius < 1.0 - tolerance:
        classification = "contracting"
    elif spectral_radius > 1.0 + tolerance:
        classification = "expanding"
    else:
        classification = "near-neutral"

    return ReturnMapStabilityResult(
        eigenvalues=eigenvalues,
        spectral_radius=spectral_radius,
        classification=classification,
        tolerance=float(tolerance),
        provenance={
            "operation": "return_map_stability",
            "fit_provenance": dict(fit.provenance),
            "criterion": "spectral radius of empirical local return-map Jacobian",
            "experimental": True,
            "interpretation_boundary": (
                "describes local empirical cycle-to-cycle contraction/expansion; "
                "does not establish deterministic orbital stability"
            ),
        },
    )

eyetrajectoriespy.plot_poincare_return_map

plot_poincare_return_map(crossings: PoincareCrossingResult, *, fit: LocalReturnMapResult | None = None, ax=None)

Plot a one-dimensional empirical return map x_n -> x_(n+1).

Source code in src/eyetrajectoriespy/nonlinear_plotting.py
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
def plot_poincare_return_map(
    crossings: PoincareCrossingResult,
    *,
    fit: LocalReturnMapResult | None = None,
    ax=None,
):
    """Plot a one-dimensional empirical return map x_n -> x_(n+1)."""

    if crossings.states.shape[1] != 1:
        raise ValueError(
            "plot_poincare_return_map currently requires one returned state dimension; "
            "select one state dimension explicitly rather than projecting silently"
        )
    if crossings.n_crossings < 2:
        raise ValueError("at least two crossings are required for a return-map plot")
    x = crossings.states[:-1, 0]
    y = crossings.states[1:, 0]
    if ax is None:
        _, ax = plt.subplots()
    ax.scatter(x, y, label="Successive crossings")
    low = float(min(np.min(x), np.min(y)))
    high = float(max(np.max(x), np.max(y)))
    ax.plot([low, high], [low, high], linestyle=":", label="Identity")
    if fit is not None:
        grid = np.linspace(low, high, 100)
        centered = grid - fit.reference_state[0]
        predicted = (
            fit.reference_state[0]
            + fit.intercept[0]
            + fit.jacobian[0, 0] * centered
        )
        ax.plot(grid, predicted, linestyle="--", label="Local affine fit")
    ax.set_xlabel(f"{crossings.state_dimensions[0]} at crossing n")
    ax.set_ylabel(f"{crossings.state_dimensions[0]} at crossing n+1")
    ax.legend()
    ax.set_title("Empirical Poincare return map")
    return ax

Mathematical contracts

eyetrajectoriespy.MathematicalContract dataclass

Mathematical specification attached to one or more public APIs.

Source code in src/eyetrajectoriespy/mathematical_contracts.py
15
16
17
18
19
20
21
22
23
24
@dataclass(frozen=True, slots=True)
class MathematicalContract:
    """Mathematical specification attached to one or more public APIs."""

    key: str
    title: str
    public_api: tuple[str, ...]
    equations: tuple[str, ...]
    site_anchor: str
    scope: str

eyetrajectoriespy.list_mathematical_contracts

list_mathematical_contracts() -> tuple[MathematicalContract, ...]

Return all mathematical contracts in stable documentation order.

Source code in src/eyetrajectoriespy/mathematical_contracts.py
874
875
876
877
def list_mathematical_contracts() -> tuple[MathematicalContract, ...]:
    """Return all mathematical contracts in stable documentation order."""

    return _CONTRACTS

eyetrajectoriespy.get_mathematical_contract

get_mathematical_contract(name: str) -> MathematicalContract

Return a mathematical contract by key or registered public function.

Source code in src/eyetrajectoriespy/mathematical_contracts.py
880
881
882
883
884
885
886
887
888
def get_mathematical_contract(name: str) -> MathematicalContract:
    """Return a mathematical contract by key or registered public function."""

    if not isinstance(name, str):
        raise TypeError("name must be a string")
    for contract in _CONTRACTS:
        if name == contract.key or name in contract.public_api:
            return contract
    raise KeyError(f"No mathematical contract is registered for {name!r}")

eyetrajectoriespy.mathematical_contract_frame

mathematical_contract_frame() -> pd.DataFrame

Return one tidy row per registered public function and its LaTeX contract.

Source code in src/eyetrajectoriespy/mathematical_contracts.py
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
def mathematical_contract_frame() -> pd.DataFrame:
    """Return one tidy row per registered public function and its LaTeX contract."""

    rows = []
    for contract in _CONTRACTS:
        latex = "\n\n".join(contract.equations)
        for function in contract.public_api:
            rows.append(
                {
                    "contract_key": contract.key,
                    "title": contract.title,
                    "function": function,
                    "latex": latex,
                    "site_anchor": contract.site_anchor,
                    "scope": contract.scope,
                }
            )
    return pd.DataFrame(rows)

Reproducibility and portable results

eyetrajectoriespy.PortableScientificResultSnapshot dataclass

Loaded portable scientific snapshot.

This is intentionally not a reconstruction of the original fitted backend object. The payload contains portable scientific state; nonportable_fields records excluded opaque/backend-native fields explicitly.

Source code in src/eyetrajectoriespy/portable_results.py
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
@dataclass(frozen=True)
class PortableScientificResultSnapshot:
    """Loaded portable scientific snapshot.

    This is intentionally not a reconstruction of the original fitted backend
    object. The payload contains portable scientific state; nonportable_fields
    records excluded opaque/backend-native fields explicitly.
    """

    result_type: str
    source_package_version: str
    loaded_package_version: str
    payload: Any
    environment: Mapping[str, Any] | None
    units: Mapping[str, Any]
    nonportable_fields: tuple[str, ...]
    manifest: Mapping[str, Any]

    @property
    def package_version_match(self) -> bool:
        return self.source_package_version == self.loaded_package_version

eyetrajectoriespy.capture_environment

capture_environment() -> dict[str, Any]

Capture the software/platform environment relevant to reproducibility.

Source code in src/eyetrajectoriespy/portable_results.py
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
def capture_environment() -> dict[str, Any]:
    """Capture the software/platform environment relevant to reproducibility."""

    return {
        "schema_version": ENVIRONMENT_SCHEMA_VERSION,
        "captured_at_utc": datetime.now(timezone.utc).isoformat(),
        "package": {
            "name": "eyetrajectoriespy",
            "version": _package_version(),
            "git_commit": _git_commit(),
        },
        "python": {
            "version": platform.python_version(),
            "implementation": platform.python_implementation(),
            "executable": sys.executable,
        },
        "platform": {
            "system": platform.system(),
            "release": platform.release(),
            "machine": platform.machine(),
            "platform": platform.platform(),
            "processor": platform.processor() or None,
            "logical_cpu_count": os.cpu_count(),
        },
        "dependencies": {
            name: _distribution_version(name)
            for name in _CORE_DEPENDENCIES
        },
        "optional_backends": {
            name: _distribution_version(name)
            for name in _OPTIONAL_BACKENDS
        },
        "numerical_thread_environment": {
            name: os.environ.get(name)
            for name in (
                "OMP_NUM_THREADS",
                "OPENBLAS_NUM_THREADS",
                "MKL_NUM_THREADS",
                "NUMEXPR_NUM_THREADS",
            )
        },
    }

eyetrajectoriespy.export_portable_result

export_portable_result(result: Any, directory: str | Path, *, include_environment: bool = True, overwrite: bool = False) -> Path

Export scientific result state as explicit JSON + NPZ.

The bundle is a portable snapshot, not a pickle. Unsupported backend objects are represented by an explicit nonportable marker and listed in the manifest instead of being silently dropped.

Source code in src/eyetrajectoriespy/portable_results.py
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
def export_portable_result(
    result: Any,
    directory: str | Path,
    *,
    include_environment: bool = True,
    overwrite: bool = False,
) -> Path:
    """Export scientific result state as explicit JSON + NPZ.

    The bundle is a portable snapshot, not a pickle. Unsupported backend
    objects are represented by an explicit nonportable marker and listed in the
    manifest instead of being silently dropped.
    """

    if not is_dataclass(result) or isinstance(result, type):
        raise TypeError("result must be a dataclass-based scientific result object")

    destination = Path(directory)
    if destination.exists() and any(destination.iterdir()):
        if not overwrite:
            raise FileExistsError(
                f"portable result directory is not empty: {destination}"
            )
        for child in destination.iterdir():
            if child.is_dir():
                raise ValueError(
                    "overwrite does not recursively remove existing directories"
                )
            child.unlink()
    destination.mkdir(parents=True, exist_ok=True)

    encoder = _PortableEncoder()
    payload = encoder.encode(result, "result")

    arrays_path = destination / "arrays.npz"
    np.savez_compressed(arrays_path, **encoder.arrays)

    package_version = _package_version()
    manifest = {
        "schema_version": PORTABLE_RESULT_SCHEMA_VERSION,
        "format": PORTABLE_RESULT_FORMAT,
        "source_package_version": package_version,
        "result_type": (
            f"{result.__class__.__module__}.{result.__class__.__qualname__}"
        ),
        "portable_scope": (
            "Scientific arrays, identifiers, specifications, units, diagnostics "
            "and provenance. Backend-native/opaque objects are not promised "
            "portable or reconstructible."
        ),
        "arrays_file": arrays_path.name,
        "arrays_sha256": _sha256(arrays_path),
        "units": _extract_units(result),
        "nonportable_fields": sorted(set(encoder.nonportable_fields)),
        "environment": capture_environment() if include_environment else None,
        "payload": payload,
    }
    manifest_path = destination / "manifest.json"
    manifest_path.write_text(
        json.dumps(manifest, indent=2, sort_keys=True) + "\n",
        encoding="utf-8",
    )
    return destination

eyetrajectoriespy.load_portable_result

load_portable_result(directory: str | Path) -> PortableScientificResultSnapshot

Load and integrity-check a portable scientific snapshot.

Source code in src/eyetrajectoriespy/portable_results.py
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
def load_portable_result(
    directory: str | Path,
) -> PortableScientificResultSnapshot:
    """Load and integrity-check a portable scientific snapshot."""

    source = Path(directory)
    manifest_path = source / "manifest.json"
    if not manifest_path.is_file():
        raise FileNotFoundError(f"portable result manifest not found: {manifest_path}")

    manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
    if manifest.get("format") != PORTABLE_RESULT_FORMAT:
        raise ValueError("not an eyetrajectoriespy portable scientific result")
    schema_version = manifest.get("schema_version")
    if schema_version != PORTABLE_RESULT_SCHEMA_VERSION:
        raise ValueError(
            "unsupported portable result schema version "
            f"{schema_version!r}; supported={PORTABLE_RESULT_SCHEMA_VERSION}"
        )

    arrays_path = source / manifest["arrays_file"]
    if not arrays_path.is_file():
        raise FileNotFoundError(f"portable result array payload not found: {arrays_path}")
    actual_hash = _sha256(arrays_path)
    if actual_hash != manifest["arrays_sha256"]:
        raise ValueError("portable result array checksum mismatch")

    with np.load(arrays_path, allow_pickle=False) as archive:
        arrays = {
            key: np.asarray(archive[key])
            for key in archive.files
        }
    payload = _decode(manifest["payload"], arrays)

    return PortableScientificResultSnapshot(
        result_type=str(manifest["result_type"]),
        source_package_version=str(manifest["source_package_version"]),
        loaded_package_version=_package_version(),
        payload=payload,
        environment=manifest.get("environment"),
        units=dict(manifest.get("units", {})),
        nonportable_fields=tuple(manifest.get("nonportable_fields", ())),
        manifest=manifest,
    )

Core objects

eyetrajectoriespy.TrajectorySet dataclass

Collection of functional trajectories observed on a common grid.

Parameters:

Name Type Description Default
time ndarray

One-dimensional, strictly increasing common time grid with shape (n_time,).

required
values ndarray

Numeric array with shape (n_curves, n_time, n_dimensions). Missing observations are represented with np.nan and are never interpreted as zeros.

required
curve_ids tuple[str, ...]

Unique labels, one per trajectory.

required
dimension_names tuple[str, ...]

Names for the functional dimensions, e.g. ("x", "y").

required
metadata DataFrame

One row per curve. It may contain participant, trial, condition, stimulus, or other design variables.

DataFrame()
coordinate_system str

Explicit coordinate semantics such as "pixels", "normalized", "degrees", or "landmark_relative".

'unknown'
time_unit str

Explicit time unit such as "ms", "s", or "normalized".

'unknown'
provenance Mapping[str, Any]

JSON-like dictionary describing source/preprocessing decisions.

dict()
Source code in src/eyetrajectoriespy/types.py
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
@dataclass(frozen=True)
class TrajectorySet:
    """Collection of functional trajectories observed on a common grid.

    Parameters
    ----------
    time:
        One-dimensional, strictly increasing common time grid with shape
        ``(n_time,)``.
    values:
        Numeric array with shape ``(n_curves, n_time, n_dimensions)``.
        Missing observations are represented with ``np.nan`` and are never
        interpreted as zeros.
    curve_ids:
        Unique labels, one per trajectory.
    dimension_names:
        Names for the functional dimensions, e.g. ``("x", "y")``.
    metadata:
        One row per curve. It may contain participant, trial, condition,
        stimulus, or other design variables.
    coordinate_system:
        Explicit coordinate semantics such as ``"pixels"``, ``"normalized"``,
        ``"degrees"``, or ``"landmark_relative"``.
    time_unit:
        Explicit time unit such as ``"ms"``, ``"s"``, or ``"normalized"``.
    provenance:
        JSON-like dictionary describing source/preprocessing decisions.
    """

    time: np.ndarray
    values: np.ndarray
    curve_ids: tuple[str, ...]
    dimension_names: tuple[str, ...]
    metadata: pd.DataFrame = field(default_factory=pd.DataFrame)
    coordinate_system: str = "unknown"
    time_unit: str = "unknown"
    provenance: Mapping[str, Any] = field(default_factory=dict)

    def __post_init__(self) -> None:
        time = np.asarray(self.time, dtype=float)
        values = np.asarray(self.values, dtype=float)
        if time.ndim != 1:
            raise ValueError("time must be one-dimensional")
        if time.size < 2:
            raise ValueError("time must contain at least two samples")
        if not np.all(np.isfinite(time)):
            raise ValueError("time must contain only finite values")
        if not np.all(np.diff(time) > 0):
            raise ValueError("time must be strictly increasing")
        if values.ndim != 3:
            raise ValueError("values must have shape (n_curves, n_time, n_dimensions)")
        if values.shape[1] != time.size:
            raise ValueError("values.shape[1] must equal len(time)")
        if values.shape[0] != len(self.curve_ids):
            raise ValueError("curve_ids length must equal number of curves")
        if values.shape[2] != len(self.dimension_names):
            raise ValueError("dimension_names length must equal number of dimensions")
        if len(set(self.curve_ids)) != len(self.curve_ids):
            raise ValueError("curve_ids must be unique")
        if len(set(self.dimension_names)) != len(self.dimension_names):
            raise ValueError("dimension_names must be unique")

        md = self.metadata.copy()
        if md.empty:
            md = pd.DataFrame(index=pd.Index(self.curve_ids, name="curve_id"))
        elif len(md) != values.shape[0]:
            raise ValueError("metadata must have exactly one row per curve")
        else:
            md = md.reset_index(drop=True)
            md.index = pd.Index(self.curve_ids, name="curve_id")

        object.__setattr__(self, "time", time)
        object.__setattr__(self, "values", values)
        object.__setattr__(self, "metadata", md)
        object.__setattr__(self, "curve_ids", tuple(map(str, self.curve_ids)))
        object.__setattr__(self, "dimension_names", tuple(map(str, self.dimension_names)))
        object.__setattr__(self, "provenance", dict(self.provenance))

    @property
    def n_curves(self) -> int:
        """Number of trajectories."""

        return self.values.shape[0]

    @property
    def n_time(self) -> int:
        """Number of samples on the common grid."""

        return self.values.shape[1]

    @property
    def n_dimensions(self) -> int:
        """Number of functional dimensions."""

        return self.values.shape[2]

    def dimension(self, name: str) -> np.ndarray:
        """Return one named dimension as ``(n_curves, n_time)``."""

        try:
            index = self.dimension_names.index(name)
        except ValueError as exc:
            raise KeyError(f"Unknown dimension {name!r}") from exc
        return self.values[:, :, index]

    def subset(self, indices: Sequence[int]) -> "TrajectorySet":
        """Return a curve subset while preserving metadata and provenance."""

        idx = np.asarray(indices, dtype=int)
        ids = tuple(self.curve_ids[i] for i in idx)
        md = self.metadata.iloc[idx].reset_index(drop=True)
        return replace(self, values=self.values[idx], curve_ids=ids, metadata=md)

    def with_values(
        self,
        values: np.ndarray,
        *,
        time: np.ndarray | None = None,
        provenance_update: Mapping[str, Any] | None = None,
        time_unit: str | None = None,
        coordinate_system: str | None = None,
    ) -> "TrajectorySet":
        """Create a transformed copy with appended provenance."""

        provenance = dict(self.provenance)
        if provenance_update:
            provenance.update(dict(provenance_update))
        return replace(
            self,
            values=np.asarray(values, dtype=float),
            time=self.time if time is None else np.asarray(time, dtype=float),
            provenance=provenance,
            time_unit=self.time_unit if time_unit is None else time_unit,
            coordinate_system=(
                self.coordinate_system if coordinate_system is None else coordinate_system
            ),
        )

n_curves property

n_curves: int

Number of trajectories.

n_time property

n_time: int

Number of samples on the common grid.

n_dimensions property

n_dimensions: int

Number of functional dimensions.

dimension

dimension(name: str) -> np.ndarray

Return one named dimension as (n_curves, n_time).

Source code in src/eyetrajectoriespy/types.py
113
114
115
116
117
118
119
120
def dimension(self, name: str) -> np.ndarray:
    """Return one named dimension as ``(n_curves, n_time)``."""

    try:
        index = self.dimension_names.index(name)
    except ValueError as exc:
        raise KeyError(f"Unknown dimension {name!r}") from exc
    return self.values[:, :, index]

subset

subset(indices: Sequence[int]) -> 'TrajectorySet'

Return a curve subset while preserving metadata and provenance.

Source code in src/eyetrajectoriespy/types.py
122
123
124
125
126
127
128
def subset(self, indices: Sequence[int]) -> "TrajectorySet":
    """Return a curve subset while preserving metadata and provenance."""

    idx = np.asarray(indices, dtype=int)
    ids = tuple(self.curve_ids[i] for i in idx)
    md = self.metadata.iloc[idx].reset_index(drop=True)
    return replace(self, values=self.values[idx], curve_ids=ids, metadata=md)

with_values

with_values(values: ndarray, *, time: ndarray | None = None, provenance_update: Mapping[str, Any] | None = None, time_unit: str | None = None, coordinate_system: str | None = None) -> 'TrajectorySet'

Create a transformed copy with appended provenance.

Source code in src/eyetrajectoriespy/types.py
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
def with_values(
    self,
    values: np.ndarray,
    *,
    time: np.ndarray | None = None,
    provenance_update: Mapping[str, Any] | None = None,
    time_unit: str | None = None,
    coordinate_system: str | None = None,
) -> "TrajectorySet":
    """Create a transformed copy with appended provenance."""

    provenance = dict(self.provenance)
    if provenance_update:
        provenance.update(dict(provenance_update))
    return replace(
        self,
        values=np.asarray(values, dtype=float),
        time=self.time if time is None else np.asarray(time, dtype=float),
        provenance=provenance,
        time_unit=self.time_unit if time_unit is None else time_unit,
        coordinate_system=(
            self.coordinate_system if coordinate_system is None else coordinate_system
        ),
    )

eyetrajectoriespy.DiscreteFrechetResult dataclass

Discrete Fréchet distance plus one deterministic optimal coupling.

Source code in src/eyetrajectoriespy/types.py
156
157
158
159
160
161
162
163
164
165
166
@dataclass(frozen=True)
class DiscreteFrechetResult:
    """Discrete Fréchet distance plus one deterministic optimal coupling."""

    distance: float
    coupling: np.ndarray
    local_distances: np.ndarray
    n_points_a: int
    n_points_b: int
    n_dimensions: int
    provenance: Mapping[str, Any] = field(default_factory=dict)

eyetrajectoriespy.FPCAResult dataclass

Result from grid-based functional principal component analysis.

Source code in src/eyetrajectoriespy/types.py
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
@dataclass(frozen=True)
class FPCAResult:
    """Result from grid-based functional principal component analysis."""

    mean: np.ndarray
    components: np.ndarray
    scores: np.ndarray
    explained_variance: np.ndarray
    explained_variance_ratio: np.ndarray
    time: np.ndarray
    dimension_names: tuple[str, ...]
    coordinate_system: str
    time_unit: str
    curve_ids: tuple[str, ...]
    weights: np.ndarray
    scale: np.ndarray
    provenance: Mapping[str, Any] = field(default_factory=dict)
    model: Any | None = None

    @property
    def n_components(self) -> int:
        return self.components.shape[0]

    def cumulative_explained_variance(self) -> np.ndarray:
        """Cumulative proportion of functional variance explained."""

        return np.cumsum(self.explained_variance_ratio)

cumulative_explained_variance

cumulative_explained_variance() -> np.ndarray

Cumulative proportion of functional variance explained.

Source code in src/eyetrajectoriespy/types.py
214
215
216
217
def cumulative_explained_variance(self) -> np.ndarray:
    """Cumulative proportion of functional variance explained."""

    return np.cumsum(self.explained_variance_ratio)

eyetrajectoriespy.RegistrationResult dataclass

Registered trajectories and explicit phase information.

Source code in src/eyetrajectoriespy/types.py
220
221
222
223
224
225
226
227
228
229
230
@dataclass(frozen=True)
class RegistrationResult:
    """Registered trajectories and explicit phase information."""

    registered: TrajectorySet
    original: TrajectorySet
    warping_functions: np.ndarray
    reference_landmarks: np.ndarray
    observed_landmarks: np.ndarray
    method: str
    provenance: Mapping[str, Any] = field(default_factory=dict)

eyetrajectoriespy.CompositionalFPCAResult dataclass

FPCA result for simplex-valued AOI probability functions.

Source code in src/eyetrajectoriespy/types.py
233
234
235
236
237
238
239
240
241
@dataclass(frozen=True)
class CompositionalFPCAResult:
    """FPCA result for simplex-valued AOI probability functions."""

    fpca: FPCAResult
    reference_dimension: int
    original_dimension_names: tuple[str, ...]
    epsilon: float
    provenance: Mapping[str, Any] = field(default_factory=dict)

eyetrajectoriespy.MultilevelFPCAResult dataclass

Participant- and trial-level functional variation decomposition.

Source code in src/eyetrajectoriespy/types.py
244
245
246
247
248
249
250
251
252
253
254
@dataclass(frozen=True)
class MultilevelFPCAResult:
    """Participant- and trial-level functional variation decomposition."""

    grand_mean: np.ndarray
    participant_fpca: FPCAResult
    trial_fpca: FPCAResult
    participant_scores: pd.DataFrame
    trial_scores: pd.DataFrame
    participant_column: str
    provenance: Mapping[str, Any] = field(default_factory=dict)

eyetrajectoriespy.FPCAStabilityResult dataclass

Bootstrap stability diagnostics for matched functional principal components.

Source code in src/eyetrajectoriespy/types.py
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
@dataclass(frozen=True)
class FPCAStabilityResult:
    """Bootstrap stability diagnostics for matched functional principal components."""

    reference: FPCAResult
    similarities: np.ndarray
    signed_similarities: np.ndarray
    assignments: np.ndarray
    explained_variance_ratio: np.ndarray
    bootstrap_curve_counts: np.ndarray
    resampling_unit: str
    random_state: int | None
    provenance: Mapping[str, Any] = field(default_factory=dict)

    @property
    def n_bootstrap(self) -> int:
        return self.similarities.shape[0]

eyetrajectoriespy.FPCACrossValidationResult dataclass

Held-out reconstruction diagnostics across candidate FPC counts.

Source code in src/eyetrajectoriespy/types.py
480
481
482
483
484
485
486
487
488
489
490
491
492
@dataclass(frozen=True)
class FPCACrossValidationResult:
    """Held-out reconstruction diagnostics across candidate FPC counts."""

    fold_errors: pd.DataFrame
    assignments: pd.DataFrame
    component_counts: tuple[int, ...]
    cv_unit: str
    n_splits: int
    group_column: str | None
    scaling: str
    random_state: int | None
    provenance: Mapping[str, Any] = field(default_factory=dict)

eyetrajectoriespy.FPCAComponentEnvelopeResult dataclass

Pointwise descriptive bootstrap envelopes for matched FPC functions.

Source code in src/eyetrajectoriespy/types.py
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
@dataclass(frozen=True)
class FPCAComponentEnvelopeResult:
    """Pointwise descriptive bootstrap envelopes for matched FPC functions."""

    reference: FPCAResult
    lower: np.ndarray
    median: np.ndarray
    upper: np.ndarray
    similarities: np.ndarray
    level: float
    resampling_unit: str
    random_state: int | None
    provenance: Mapping[str, Any] = field(default_factory=dict)

    @property
    def n_bootstrap(self) -> int:
        return self.similarities.shape[0]

eyetrajectoriespy.FPCARegressionCVResult dataclass

Outcome-tuned FPCA regression cross-validation diagnostics.

Source code in src/eyetrajectoriespy/types.py
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
@dataclass(frozen=True)
class FPCARegressionCVResult:
    """Outcome-tuned FPCA regression cross-validation diagnostics."""

    fold_losses: pd.DataFrame
    assignments: pd.DataFrame
    predictions: pd.DataFrame
    component_counts: tuple[int, ...]
    family: str
    loss: str
    cv_unit: str
    n_splits: int
    group_column: str | None
    scaling: str
    random_state: int | None
    provenance: Mapping[str, Any] = field(default_factory=dict)

eyetrajectoriespy.FPCANestedRegressionCVResult dataclass

Nested CV evaluation of outcome-tuned FPCA regression selection.

Source code in src/eyetrajectoriespy/types.py
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
@dataclass(frozen=True)
class FPCANestedRegressionCVResult:
    """Nested CV evaluation of outcome-tuned FPCA regression selection."""

    outer_folds: pd.DataFrame
    inner_summaries: pd.DataFrame
    predictions: pd.DataFrame
    family: str
    loss: str
    selection_rule: str
    component_counts: tuple[int, ...]
    outer_splits: int
    inner_splits: int
    cv_unit: str
    group_column: str | None
    scaling: str
    random_state: int | None
    provenance: Mapping[str, Any] = field(default_factory=dict)

eyetrajectoriespy.FPCASubspaceComparisonResult dataclass

Principal-angle comparison of corresponding FPCA component subspaces.

Source code in src/eyetrajectoriespy/types.py
618
619
620
621
622
623
624
625
626
627
628
629
@dataclass(frozen=True)
class FPCASubspaceComparisonResult:
    """Principal-angle comparison of corresponding FPCA component subspaces."""

    reference: FPCAResult
    candidate: FPCAResult
    component_indices: tuple[int, ...]
    principal_cosines: np.ndarray
    principal_angles_degrees: np.ndarray
    projector_distance_frobenius: float
    normalized_projector_distance: float
    provenance: Mapping[str, Any] = field(default_factory=dict)

eyetrajectoriespy.FPCASubspaceStabilityResult dataclass

Bootstrap stability diagnostics for an FPCA component subspace.

Source code in src/eyetrajectoriespy/types.py
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
@dataclass(frozen=True)
class FPCASubspaceStabilityResult:
    """Bootstrap stability diagnostics for an FPCA component subspace."""

    reference: FPCAResult
    component_indices: tuple[int, ...]
    principal_cosines: np.ndarray
    principal_angles_degrees: np.ndarray
    projector_distance_frobenius: np.ndarray
    normalized_projector_distance: np.ndarray
    resampling_unit: str
    random_state: int | None
    provenance: Mapping[str, Any] = field(default_factory=dict)

    @property
    def n_bootstrap(self) -> int:
        return self.principal_cosines.shape[0]

eyetrajectoriespy.SparseFPCAResult dataclass

Sparse univariate FPCA fitted to native irregular observations.

Source code in src/eyetrajectoriespy/types.py
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
@dataclass(frozen=True)
class SparseFPCAResult:
    """Sparse univariate FPCA fitted to native irregular observations."""

    scores: np.ndarray
    eigenvalues: np.ndarray
    dimension: str
    curve_ids: tuple[str, ...]
    metadata: pd.DataFrame
    coordinate_system: str
    time_unit: str
    n_components: int
    fit_method: str
    fit_smoothing: str | None
    score_method: str
    score_smoothing: str | None
    tolerance: float
    normalize: bool
    provenance: Mapping[str, Any] = field(default_factory=dict)
    backend_object: Any | None = None
    backend_data: Any | None = None
    reconstructed_backend: Any | None = None

eyetrajectoriespy.FunctionalMeanBandResult dataclass

Simultaneous multiplier-bootstrap band for a functional mean.

Source code in src/eyetrajectoriespy/types.py
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
@dataclass(frozen=True)
class FunctionalMeanBandResult:
    """Simultaneous multiplier-bootstrap band for a functional mean."""

    mean: np.ndarray
    lower: np.ndarray
    upper: np.ndarray
    pointwise_se: np.ndarray
    critical_value: float
    max_statistics: np.ndarray
    confidence_level: float
    unit: str
    unit_ids: tuple[str, ...]
    participant_column: str | None
    time: np.ndarray
    dimension_names: tuple[str, ...]
    coordinate_system: str
    time_unit: str
    provenance: Mapping[str, Any] = field(default_factory=dict)

    @property
    def n_units(self) -> int:
        return len(self.unit_ids)

eyetrajectoriespy.FPCAInfluenceResult dataclass

Leave-one-group-out sensitivity of functional principal components.

Source code in src/eyetrajectoriespy/types.py
468
469
470
471
472
473
474
475
476
477
@dataclass(frozen=True)
class FPCAInfluenceResult:
    """Leave-one-group-out sensitivity of functional principal components."""

    reference: FPCAResult
    summary: pd.DataFrame
    components: pd.DataFrame
    group_column: str | None
    n_components: int
    provenance: Mapping[str, Any] = field(default_factory=dict)

eyetrajectoriespy.FunctionalOutlierResult dataclass

Functional outlier/review diagnostics without automatic exclusion.

Source code in src/eyetrajectoriespy/types.py
457
458
459
460
461
462
463
464
465
@dataclass(frozen=True)
class FunctionalOutlierResult:
    """Functional outlier/review diagnostics without automatic exclusion."""

    diagnostics: pd.DataFrame
    method: str
    reference: FPCAResult | None = None
    provenance: Mapping[str, Any] = field(default_factory=dict)
    backend_object: Any | None = None

Native and common-grid import

eyetrajectoriespy.from_irregular_long_dataframe_native

from_irregular_long_dataframe_native(data: DataFrame, *, curve_columns: Sequence[str], time_column: str, value_columns: Sequence[str] = ('x', 'y'), metadata_columns: Sequence[str] | None = None, coordinate_system: str = 'unknown', time_unit: str = 'unknown', provenance: dict[str, Any] | None = None) -> IrregularTrajectorySet

Create an irregular trajectory set without resampling.

Duplicate time points are rejected because their interpretation is experiment-specific. Functional values may contain missing observations; those remain missing and are not converted to zeros or interpolated.

Source code in src/eyetrajectoriespy/irregular.py
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
def from_irregular_long_dataframe_native(
    data: pd.DataFrame,
    *,
    curve_columns: Sequence[str],
    time_column: str,
    value_columns: Sequence[str] = ("x", "y"),
    metadata_columns: Sequence[str] | None = None,
    coordinate_system: str = "unknown",
    time_unit: str = "unknown",
    provenance: dict[str, Any] | None = None,
) -> IrregularTrajectorySet:
    """Create an irregular trajectory set without resampling.

    Duplicate time points are rejected because their interpretation is
    experiment-specific. Functional values may contain missing observations;
    those remain missing and are not converted to zeros or interpolated.
    """

    if not isinstance(data, pd.DataFrame):
        raise TypeError("data must be a pandas DataFrame")
    curve_columns = tuple(curve_columns)
    value_columns = tuple(value_columns)
    metadata_columns = tuple(metadata_columns or ())
    if not curve_columns:
        raise ValueError("curve_columns must contain at least one identifier")
    if not value_columns:
        raise ValueError("value_columns must contain at least one functional dimension")
    required = set(curve_columns) | {time_column} | set(value_columns) | set(metadata_columns)
    missing = required - set(data.columns)
    if missing:
        raise ValueError(f"Missing columns: {sorted(missing)}")
    if data[list(curve_columns) + [time_column]].isna().any().any():
        raise ValueError("curve identifiers and time values cannot be missing")

    times: list[np.ndarray] = []
    values: list[np.ndarray] = []
    ids: list[str] = []
    metadata_rows: list[dict[str, Any]] = []

    grouped = data.groupby(list(curve_columns), sort=False, dropna=False)
    for key, frame in grouped:
        frame = frame.sort_values(time_column, kind="stable")
        time = frame[time_column].to_numpy(dtype=float)
        if len(time) < 2:
            raise ValueError(f"Curve {key!r} contains fewer than two samples")
        if len(np.unique(time)) != len(time):
            raise ValueError(f"Duplicate time points within curve {key!r}; resolve them explicitly")
        if not np.all(np.diff(time) > 0):
            raise ValueError(f"Time must be strictly increasing within curve {key!r}")

        value = frame[list(value_columns)].to_numpy(dtype=float)
        key_tuple = key if isinstance(key, tuple) else (key,)
        ids.append("|".join(str(part) for part in key_tuple))
        times.append(time)
        values.append(value)

        row = dict(zip(curve_columns, key_tuple, strict=True))
        for column in metadata_columns:
            unique = frame[column].drop_duplicates()
            if len(unique) > 1:
                raise ValueError(f"Metadata column {column!r} varies within curve {key!r}")
            row[column] = unique.iloc[0] if len(unique) else np.nan
        metadata_rows.append(row)

    if not times:
        raise ValueError("No trajectories were found")

    return IrregularTrajectorySet(
        time=tuple(times),
        values=tuple(values),
        curve_ids=tuple(ids),
        dimension_names=tuple(value_columns),
        metadata=pd.DataFrame(metadata_rows),
        coordinate_system=coordinate_system,
        time_unit=time_unit,
        provenance={
            "source": "irregular_long_dataframe_native",
            "curve_columns": list(curve_columns),
            "time_column": time_column,
            "value_columns": list(value_columns),
            **(provenance or {}),
        },
    )

eyetrajectoriespy.irregular_sampling_summary

irregular_sampling_summary(trajectories: IrregularTrajectorySet) -> pd.DataFrame

Return per-curve sampling and missingness diagnostics.

Source code in src/eyetrajectoriespy/irregular.py
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
def irregular_sampling_summary(trajectories: IrregularTrajectorySet) -> pd.DataFrame:
    """Return per-curve sampling and missingness diagnostics."""

    rows = []
    for curve_id, time, values in zip(
        trajectories.curve_ids,
        trajectories.time,
        trajectories.values,
        strict=True,
    ):
        intervals = np.diff(time)
        rows.append(
            {
                "curve_id": curve_id,
                "n_samples": len(time),
                "time_start": float(time[0]),
                "time_end": float(time[-1]),
                "median_interval": float(np.median(intervals)),
                "max_interval": float(np.max(intervals)),
                "missing_fraction": float(np.isnan(values).mean()),
            }
        )
    return pd.DataFrame(rows)

eyetrajectoriespy.common_overlap_interval

common_overlap_interval(trajectories: IrregularTrajectorySet) -> tuple[float, float]

Return the time interval observed by every curve.

Source code in src/eyetrajectoriespy/irregular.py
100
101
102
103
104
105
106
107
def common_overlap_interval(trajectories: IrregularTrajectorySet) -> tuple[float, float]:
    """Return the time interval observed by every curve."""

    start = max(float(time[0]) for time in trajectories.time)
    end = min(float(time[-1]) for time in trajectories.time)
    if end <= start:
        raise ValueError("Irregular trajectories do not share a positive common time interval")
    return start, end

eyetrajectoriespy.make_common_grid

make_common_grid(trajectories: IrregularTrajectorySet, *, n_time: int, domain: str = 'overlap', start: float | None = None, end: float | None = None) -> np.ndarray

Construct an explicit common grid without modifying trajectory values.

The 'overlap' domain uses only time observed by every curve. The 'union' domain spans the full observed range and can create edge missingness after resampling for curves with shorter domains.

Source code in src/eyetrajectoriespy/irregular.py
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
def make_common_grid(
    trajectories: IrregularTrajectorySet,
    *,
    n_time: int,
    domain: str = "overlap",
    start: float | None = None,
    end: float | None = None,
) -> np.ndarray:
    """Construct an explicit common grid without modifying trajectory values.

    The 'overlap' domain uses only time observed by every curve. The 'union'
    domain spans the full observed range and can create edge missingness after
    resampling for curves with shorter domains.
    """

    if n_time < 2:
        raise ValueError("n_time must be at least 2")
    if (start is None) ^ (end is None):
        raise ValueError("start and end must be supplied together")
    if start is not None:
        lo, hi = float(start), float(end)
    elif domain == "overlap":
        lo, hi = common_overlap_interval(trajectories)
    elif domain == "union":
        lo = min(float(time[0]) for time in trajectories.time)
        hi = max(float(time[-1]) for time in trajectories.time)
    else:
        raise ValueError("domain must be 'overlap' or 'union'")
    if hi <= lo:
        raise ValueError("Common-grid end must be greater than start")
    return np.linspace(lo, hi, n_time)

eyetrajectoriespy.resample_irregular_to_grid

resample_irregular_to_grid(trajectories: IrregularTrajectorySet, grid: ndarray, *, method: str = 'linear', max_gap: float | None = None) -> TrajectorySet

Project native irregular trajectories onto an explicit common grid.

No extrapolation is performed. Long intervals remain missing when max_gap is supplied.

Source code in src/eyetrajectoriespy/irregular.py
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
def resample_irregular_to_grid(
    trajectories: IrregularTrajectorySet,
    grid: np.ndarray,
    *,
    method: str = "linear",
    max_gap: float | None = None,
) -> TrajectorySet:
    """Project native irregular trajectories onto an explicit common grid.

    No extrapolation is performed. Long intervals remain missing when
    max_gap is supplied.
    """

    grid = np.asarray(grid, dtype=float)
    if grid.ndim != 1 or len(grid) < 2 or not np.all(np.diff(grid) > 0):
        raise ValueError("grid must be a strictly increasing one-dimensional array")
    projected = np.empty(
        (trajectories.n_curves, len(grid), trajectories.n_dimensions),
        dtype=float,
    )
    for i, (time, values) in enumerate(zip(trajectories.time, trajectories.values, strict=True)):
        projected[i] = _resample_single_curve(
            time,
            values,
            grid,
            method=method,
            max_gap=max_gap,
        )
    return TrajectorySet(
        time=grid,
        values=projected,
        curve_ids=trajectories.curve_ids,
        dimension_names=trajectories.dimension_names,
        metadata=trajectories.metadata.reset_index(drop=True),
        coordinate_system=trajectories.coordinate_system,
        time_unit=trajectories.time_unit,
        provenance={
            **dict(trajectories.provenance),
            "irregular_to_grid": {
                "method": method,
                "max_gap": max_gap,
                "n_time": len(grid),
                "grid_start": float(grid[0]),
                "grid_end": float(grid[-1]),
            },
        },
    )

eyetrajectoriespy.resample_irregular_to_common_grid

resample_irregular_to_common_grid(trajectories: IrregularTrajectorySet, *, n_time: int, domain: str = 'overlap', method: str = 'linear', max_gap: float | None = None) -> TrajectorySet

Create and apply a common grid in one explicit step.

Source code in src/eyetrajectoriespy/irregular.py
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
def resample_irregular_to_common_grid(
    trajectories: IrregularTrajectorySet,
    *,
    n_time: int,
    domain: str = "overlap",
    method: str = "linear",
    max_gap: float | None = None,
) -> TrajectorySet:
    """Create and apply a common grid in one explicit step."""

    grid = make_common_grid(trajectories, n_time=n_time, domain=domain)
    return resample_irregular_to_grid(
        trajectories,
        grid,
        method=method,
        max_gap=max_gap,
    )

Import and validation

eyetrajectoriespy.from_long_dataframe

from_long_dataframe(data: DataFrame, *, curve_columns: Sequence[str], time_column: str, value_columns: Sequence[str] = ('x', 'y'), metadata_columns: Sequence[str] | None = None, coordinate_system: str = 'unknown', time_unit: str = 'unknown', require_common_grid: bool = True, provenance: dict[str, Any] | None = None) -> TrajectorySet

Create a :class:TrajectorySet from long-format gaze samples.

The function does not interpolate, smooth, impute, average duplicate time points, or normalize time. Such operations must be requested explicitly.

Parameters:

Name Type Description Default
data DataFrame

Long-format dataframe containing one row per gaze sample.

required
curve_columns Sequence[str]

Columns identifying a trajectory, typically ("participant_id", "trial_id").

required
time_column str

Sample-time column.

required
value_columns Sequence[str]

Functional channels. For continuous planar gaze this is usually ("x", "y").

('x', 'y')
metadata_columns Sequence[str] | None

Curve-constant columns to preserve. If a requested metadata column varies within a curve, an error is raised.

None
require_common_grid bool

Retained for backward compatibility. A :class:TrajectorySet always requires a common grid. For native curve-specific sampling use :func:eyetrajectoriespy.from_irregular_long_dataframe_native and project to a common grid explicitly later.

True
Source code in src/eyetrajectoriespy/io.py
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
def from_long_dataframe(
    data: pd.DataFrame,
    *,
    curve_columns: Sequence[str],
    time_column: str,
    value_columns: Sequence[str] = ("x", "y"),
    metadata_columns: Sequence[str] | None = None,
    coordinate_system: str = "unknown",
    time_unit: str = "unknown",
    require_common_grid: bool = True,
    provenance: dict[str, Any] | None = None,
) -> TrajectorySet:
    """Create a :class:`TrajectorySet` from long-format gaze samples.

    The function does not interpolate, smooth, impute, average duplicate time
    points, or normalize time. Such operations must be requested explicitly.

    Parameters
    ----------
    data:
        Long-format dataframe containing one row per gaze sample.
    curve_columns:
        Columns identifying a trajectory, typically ``("participant_id",
        "trial_id")``.
    time_column:
        Sample-time column.
    value_columns:
        Functional channels. For continuous planar gaze this is usually
        ``("x", "y")``.
    metadata_columns:
        Curve-constant columns to preserve. If a requested metadata column
        varies within a curve, an error is raised.
    require_common_grid:
        Retained for backward compatibility. A :class:`TrajectorySet` always
        requires a common grid. For native curve-specific sampling use
        :func:`eyetrajectoriespy.from_irregular_long_dataframe_native` and
        project to a common grid explicitly later.
    """

    if not isinstance(data, pd.DataFrame):
        raise TypeError("data must be a pandas DataFrame")
    curve_columns = tuple(curve_columns)
    value_columns = tuple(value_columns)
    metadata_columns = tuple(metadata_columns or ())
    required = set(curve_columns) | {time_column} | set(value_columns) | set(metadata_columns)
    missing = required - set(data.columns)
    if missing:
        raise ValueError(f"Missing columns: {sorted(missing)}")
    if not curve_columns:
        raise ValueError("curve_columns must contain at least one identifier")
    if not value_columns:
        raise ValueError("value_columns must contain at least one functional dimension")

    work = data.loc[:, list(required)].copy()
    if work[list(curve_columns) + [time_column]].isna().any().any():
        raise ValueError("curve identifiers and time values cannot be missing")

    grouped = work.groupby(list(curve_columns), sort=False, dropna=False)
    curves: list[np.ndarray] = []
    ids: list[str] = []
    grids: list[np.ndarray] = []
    metadata_rows: list[dict[str, Any]] = []

    for key, frame in grouped:
        frame = frame.sort_values(time_column, kind="stable")
        times = frame[time_column].to_numpy(dtype=float)
        if len(np.unique(times)) != len(times):
            raise ValueError(f"Duplicate time points within curve {key!r}; resolve them explicitly")
        if not np.all(np.diff(times) > 0):
            raise ValueError(f"Time must be strictly increasing within curve {key!r}")
        grid = times
        values = frame[list(value_columns)].to_numpy(dtype=float)
        grids.append(grid)
        curves.append(values)
        key_tuple = key if isinstance(key, tuple) else (key,)
        ids.append("|".join(str(part) for part in key_tuple))

        row = dict(zip(curve_columns, key_tuple, strict=True))
        for column in metadata_columns:
            unique = frame[column].drop_duplicates()
            if len(unique) > 1:
                raise ValueError(f"Metadata column {column!r} varies within curve {key!r}")
            row[column] = unique.iloc[0] if len(unique) else np.nan
        metadata_rows.append(row)

    if not curves:
        raise ValueError("No trajectories were found")
    reference = grids[0]
    common = all(np.array_equal(reference, grid) for grid in grids[1:])
    if not common:
        if require_common_grid:
            raise ValueError(
                "Trajectories do not share a common time grid. Resample explicitly before creating a TrajectorySet."
            )
        raise ValueError(
            "TrajectorySet requires a common grid; use from_irregular_long_dataframe_native() "
            "to preserve curve-specific sampling before explicit projection."
        )

    return TrajectorySet(
        time=reference,
        values=np.stack(curves, axis=0),
        curve_ids=tuple(ids),
        dimension_names=tuple(value_columns),
        metadata=pd.DataFrame(metadata_rows),
        coordinate_system=coordinate_system,
        time_unit=time_unit,
        provenance={
            "source": "long_dataframe",
            "curve_columns": list(curve_columns),
            "time_column": time_column,
            "value_columns": list(value_columns),
            **(provenance or {}),
        },
    )

eyetrajectoriespy.from_irregular_long_dataframe

from_irregular_long_dataframe(data: DataFrame, *, curve_columns: Sequence[str], time_column: str, value_columns: Sequence[str] = ('x', 'y'), grid: ndarray, metadata_columns: Sequence[str] | None = None, method: str = 'linear', max_gap: float | None = None, coordinate_system: str = 'unknown', time_unit: str = 'unknown') -> TrajectorySet

Create a common-grid trajectory set from irregular long-format samples.

Resampling is explicit and gap-limited. Values outside each curve's observed domain remain missing rather than being extrapolated.

Source code in src/eyetrajectoriespy/io.py
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
def from_irregular_long_dataframe(
    data: pd.DataFrame,
    *,
    curve_columns: Sequence[str],
    time_column: str,
    value_columns: Sequence[str] = ("x", "y"),
    grid: np.ndarray,
    metadata_columns: Sequence[str] | None = None,
    method: str = "linear",
    max_gap: float | None = None,
    coordinate_system: str = "unknown",
    time_unit: str = "unknown",
) -> TrajectorySet:
    """Create a common-grid trajectory set from irregular long-format samples.

    Resampling is explicit and gap-limited. Values outside each curve's
    observed domain remain missing rather than being extrapolated.
    """

    from .preprocessing import _resample_single_curve

    grid = np.asarray(grid, dtype=float)
    if grid.ndim != 1 or len(grid) < 2 or not np.all(np.diff(grid) > 0):
        raise ValueError("grid must be a strictly increasing one-dimensional array")

    curve_columns = tuple(curve_columns)
    value_columns = tuple(value_columns)
    metadata_columns = tuple(metadata_columns or ())
    required = set(curve_columns) | {time_column} | set(value_columns) | set(metadata_columns)
    missing = required - set(data.columns)
    if missing:
        raise ValueError(f"Missing columns: {sorted(missing)}")

    curves: list[np.ndarray] = []
    ids: list[str] = []
    metadata_rows: list[dict[str, Any]] = []
    grouped = data.groupby(list(curve_columns), sort=False, dropna=False)
    for key, frame in grouped:
        frame = frame.sort_values(time_column, kind="stable")
        times = frame[time_column].to_numpy(dtype=float)
        if len(np.unique(times)) != len(times):
            raise ValueError(f"Duplicate time points within curve {key!r}; resolve them explicitly")
        values = frame[list(value_columns)].to_numpy(dtype=float)
        resampled = _resample_single_curve(times, values, grid, method=method, max_gap=max_gap)
        curves.append(resampled)
        key_tuple = key if isinstance(key, tuple) else (key,)
        ids.append("|".join(str(part) for part in key_tuple))
        row = dict(zip(curve_columns, key_tuple, strict=True))
        for column in metadata_columns:
            unique = frame[column].drop_duplicates()
            if len(unique) > 1:
                raise ValueError(f"Metadata column {column!r} varies within curve {key!r}")
            row[column] = unique.iloc[0] if len(unique) else np.nan
        metadata_rows.append(row)

    return TrajectorySet(
        time=grid,
        values=np.stack(curves),
        curve_ids=tuple(ids),
        dimension_names=value_columns,
        metadata=pd.DataFrame(metadata_rows),
        coordinate_system=coordinate_system,
        time_unit=time_unit,
        provenance={
            "source": "irregular_long_dataframe",
            "resampling_method": method,
            "max_gap": max_gap,
        },
    )

eyetrajectoriespy.validate_trajectory_set

validate_trajectory_set(trajectories: TrajectorySet, *, require_complete: bool = False, require_dimensions: Iterable[str] | None = None) -> TrajectorySet

Validate scientific assumptions that are not enforced by construction.

This function never repairs data. It either returns the input object or raises a descriptive error.

Source code in src/eyetrajectoriespy/validation.py
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
def validate_trajectory_set(
    trajectories: TrajectorySet,
    *,
    require_complete: bool = False,
    require_dimensions: Iterable[str] | None = None,
) -> TrajectorySet:
    """Validate scientific assumptions that are not enforced by construction.

    This function never repairs data. It either returns the input object or
    raises a descriptive error.
    """

    if not isinstance(trajectories, TrajectorySet):
        raise TypeError("trajectories must be a TrajectorySet")
    if trajectories.coordinate_system not in _ALLOWED_COORDINATE_SYSTEMS:
        raise ValueError(
            "coordinate_system must be one of " + ", ".join(sorted(_ALLOWED_COORDINATE_SYSTEMS))
        )
    if require_complete and np.isnan(trajectories.values).any():
        raise ValueError("TrajectorySet contains missing values; explicit handling is required")
    if require_dimensions is not None:
        missing = set(require_dimensions) - set(trajectories.dimension_names)
        if missing:
            raise ValueError(f"Missing required dimensions: {sorted(missing)}")
    return trajectories

eyetrajectoriespy.validate_simplex

validate_simplex(values: ndarray, *, atol: float = 1e-07) -> None

Validate non-negative functions that sum to one across dimensions.

Source code in src/eyetrajectoriespy/validation.py
60
61
62
63
64
65
66
67
68
69
70
71
72
def validate_simplex(values: np.ndarray, *, atol: float = 1e-7) -> None:
    """Validate non-negative functions that sum to one across dimensions."""

    arr = np.asarray(values, dtype=float)
    if arr.ndim != 3:
        raise ValueError("simplex values must have shape (n_curves, n_time, n_dimensions)")
    if np.isnan(arr).any():
        raise ValueError("simplex values cannot contain missing values")
    if np.any(arr < -atol):
        raise ValueError("simplex values must be non-negative")
    totals = arr.sum(axis=2)
    if not np.allclose(totals, 1.0, atol=atol):
        raise ValueError("simplex values must sum to one across dimensions at every time point")

Preprocessing

eyetrajectoriespy.resample_to_grid

resample_to_grid(trajectories: TrajectorySet, grid: ndarray, *, method: str = 'linear', max_gap: float | None = None) -> TrajectorySet

Resample common-grid trajectories to a new grid without extrapolation.

Missing observations are interpolated only when bounded by observed values. If max_gap is supplied, intervals larger than that threshold remain missing after resampling.

Source code in src/eyetrajectoriespy/preprocessing.py
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
def resample_to_grid(
    trajectories: TrajectorySet,
    grid: np.ndarray,
    *,
    method: str = "linear",
    max_gap: float | None = None,
) -> TrajectorySet:
    """Resample common-grid trajectories to a new grid without extrapolation.

    Missing observations are interpolated only when bounded by observed values.
    If ``max_gap`` is supplied, intervals larger than that threshold remain
    missing after resampling.
    """

    validate_trajectory_set(trajectories)
    grid = np.asarray(grid, dtype=float)
    if grid.ndim != 1 or len(grid) < 2 or not np.all(np.diff(grid) > 0):
        raise ValueError("grid must be a strictly increasing one-dimensional array")
    values = np.empty((trajectories.n_curves, len(grid), trajectories.n_dimensions), dtype=float)
    for i in range(trajectories.n_curves):
        values[i] = _resample_single_curve(
            trajectories.time,
            trajectories.values[i],
            grid,
            method=method,
            max_gap=max_gap,
        )
    return trajectories.with_values(
        values,
        time=grid,
        provenance_update={
            "resampling": {"method": method, "max_gap": max_gap, "n_grid": len(grid)}
        },
    )

eyetrajectoriespy.interpolate_short_gaps

interpolate_short_gaps(trajectories: TrajectorySet, *, max_gap: float, method: str = 'pchip') -> TrajectorySet

Interpolate only missing runs whose bounding samples are sufficiently close.

This function exists to make interpolation explicit. It never fills leading or trailing missing values and never bridges a gap longer than max_gap.

Source code in src/eyetrajectoriespy/preprocessing.py
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
def interpolate_short_gaps(
    trajectories: TrajectorySet,
    *,
    max_gap: float,
    method: str = "pchip",
) -> TrajectorySet:
    """Interpolate only missing runs whose bounding samples are sufficiently close.

    This function exists to make interpolation explicit. It never fills leading
    or trailing missing values and never bridges a gap longer than ``max_gap``.
    """

    if max_gap <= 0:
        raise ValueError("max_gap must be positive")
    result = trajectories.values.copy()
    time = trajectories.time
    for c in range(trajectories.n_curves):
        for d in range(trajectories.n_dimensions):
            y = result[c, :, d]
            valid_idx = np.flatnonzero(np.isfinite(y))
            if valid_idx.size < 2:
                continue
            for left_idx, right_idx in zip(valid_idx[:-1], valid_idx[1:], strict=True):
                if right_idx - left_idx <= 1:
                    continue
                gap_duration = time[right_idx] - time[left_idx]
                if gap_duration > max_gap:
                    continue
                target_idx = np.arange(left_idx + 1, right_idx)
                if method == "linear":
                    y[target_idx] = np.interp(
                        time[target_idx],
                        time[[left_idx, right_idx]],
                        y[[left_idx, right_idx]],
                    )
                elif method == "pchip":
                    y[target_idx] = PchipInterpolator(
                        time[[left_idx, right_idx]], y[[left_idx, right_idx]]
                    )(time[target_idx])
                else:
                    raise ValueError("method must be 'linear' or 'pchip'")
            result[c, :, d] = y
    return trajectories.with_values(
        result,
        provenance_update={"gap_interpolation": {"method": method, "max_gap": max_gap}},
    )

eyetrajectoriespy.smooth_trajectories

smooth_trajectories(trajectories: TrajectorySet, *, method: str, window: int | None = None, polyorder: int = 2, sigma: float | None = None) -> TrajectorySet

Smooth functional channels while preserving missing observations.

Smoothing is intentionally opt-in because genuine saccadic transitions are abrupt. The function emits a methodological warning whenever called.

Source code in src/eyetrajectoriespy/preprocessing.py
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
def smooth_trajectories(
    trajectories: TrajectorySet,
    *,
    method: str,
    window: int | None = None,
    polyorder: int = 2,
    sigma: float | None = None,
) -> TrajectorySet:
    """Smooth functional channels while preserving missing observations.

    Smoothing is intentionally opt-in because genuine saccadic transitions are
    abrupt. The function emits a methodological warning whenever called.
    """

    warnings.warn(
        "Smoothing can attenuate genuine saccadic transitions. Report the method and parameters, "
        "and compare against unsmoothed trajectories when timing/shape is scientifically important.",
        UserWarning,
        stacklevel=2,
    )
    result = trajectories.values.copy()
    for c in range(trajectories.n_curves):
        for d in range(trajectories.n_dimensions):
            y = result[c, :, d]
            valid = np.isfinite(y)
            if valid.sum() < 3:
                continue
            # Do not bridge missing runs during smoothing. Smooth each contiguous valid run.
            padded = np.r_[False, valid, False]
            changes = np.diff(padded.astype(int))
            starts = np.flatnonzero(changes == 1)
            ends = np.flatnonzero(changes == -1)
            for start, end in zip(starts, ends, strict=True):
                segment = y[start:end]
                if method == "savgol":
                    if window is None:
                        raise ValueError("window is required for Savitzky-Golay smoothing")
                    if window % 2 == 0 or window < 3:
                        raise ValueError("window must be an odd integer >= 3")
                    if window > len(segment):
                        continue
                    if polyorder >= window:
                        raise ValueError("polyorder must be smaller than window")
                    y[start:end] = savgol_filter(segment, window_length=window, polyorder=polyorder)
                elif method == "gaussian":
                    if sigma is None or sigma <= 0:
                        raise ValueError("positive sigma is required for Gaussian smoothing")
                    y[start:end] = gaussian_filter1d(segment, sigma=sigma, mode="nearest")
                else:
                    raise ValueError("method must be 'savgol' or 'gaussian'")
            result[c, :, d] = y
    params = {"method": method, "window": window, "polyorder": polyorder, "sigma": sigma}
    return trajectories.with_values(result, provenance_update={"smoothing": params})

eyetrajectoriespy.normalize_time

normalize_time(trajectories: TrajectorySet, *, start: float = 0.0, end: float = 1.0) -> TrajectorySet

Linearly normalize the common time domain to an explicit interval.

This removes absolute time units. The original time domain is retained in provenance so the transformation is auditable.

Source code in src/eyetrajectoriespy/preprocessing.py
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
def normalize_time(
    trajectories: TrajectorySet,
    *,
    start: float = 0.0,
    end: float = 1.0,
) -> TrajectorySet:
    """Linearly normalize the common time domain to an explicit interval.

    This removes absolute time units. The original time domain is retained in
    provenance so the transformation is auditable.
    """

    if end <= start:
        raise ValueError("end must be greater than start")
    old = trajectories.time
    scaled = start + (old - old[0]) / (old[-1] - old[0]) * (end - start)
    return trajectories.with_values(
        trajectories.values.copy(),
        time=scaled,
        time_unit="normalized",
        provenance_update={
            "time_normalization": {
                "original_start": float(old[0]),
                "original_end": float(old[-1]),
                "original_unit": trajectories.time_unit,
                "new_start": start,
                "new_end": end,
            }
        },
    )

eyetrajectoriespy.normalize_coordinates

normalize_coordinates(trajectories: TrajectorySet, *, width: float, height: float) -> TrajectorySet

Convert planar pixel coordinates to [0, 1] normalized coordinates.

Source code in src/eyetrajectoriespy/preprocessing.py
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
def normalize_coordinates(
    trajectories: TrajectorySet,
    *,
    width: float,
    height: float,
) -> TrajectorySet:
    """Convert planar pixel coordinates to ``[0, 1]`` normalized coordinates."""

    if width <= 0 or height <= 0:
        raise ValueError("width and height must be positive")
    if trajectories.n_dimensions < 2:
        raise ValueError("At least two dimensions are required for planar coordinate normalization")
    values = trajectories.values.copy()
    values[:, :, 0] = values[:, :, 0] / width
    values[:, :, 1] = values[:, :, 1] / height
    return trajectories.with_values(
        values,
        coordinate_system="normalized",
        provenance_update={"coordinate_normalization": {"width": width, "height": height}},
    )

eyetrajectoriespy.center_on_landmark

center_on_landmark(trajectories: TrajectorySet, *, landmark_x: float | ndarray, landmark_y: float | ndarray) -> TrajectorySet

Express planar gaze relative to a stimulus landmark.

landmark_x and landmark_y may be scalars or one value per curve.

Source code in src/eyetrajectoriespy/preprocessing.py
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
def center_on_landmark(
    trajectories: TrajectorySet,
    *,
    landmark_x: float | np.ndarray,
    landmark_y: float | np.ndarray,
) -> TrajectorySet:
    """Express planar gaze relative to a stimulus landmark.

    ``landmark_x`` and ``landmark_y`` may be scalars or one value per curve.
    """

    if trajectories.n_dimensions < 2:
        raise ValueError("At least two dimensions are required")
    lx = np.asarray(landmark_x, dtype=float)
    ly = np.asarray(landmark_y, dtype=float)
    if lx.ndim == 0:
        lx = np.repeat(lx, trajectories.n_curves)
    if ly.ndim == 0:
        ly = np.repeat(ly, trajectories.n_curves)
    if lx.shape != (trajectories.n_curves,) or ly.shape != (trajectories.n_curves,):
        raise ValueError("landmark coordinates must be scalars or one value per curve")
    values = trajectories.values.copy()
    values[:, :, 0] -= lx[:, None]
    values[:, :, 1] -= ly[:, None]
    return trajectories.with_values(
        values,
        coordinate_system="landmark_relative",
        provenance_update={"landmark_centering": {"per_curve": True}},
    )

Sparse irregular PACE FPCA

eyetrajectoriespy.sparse_dimension_summary

sparse_dimension_summary(trajectories: IrregularTrajectorySet, *, dimension: str) -> pd.DataFrame

Summarize curve-specific observation density for one sparse dimension.

Source code in src/eyetrajectoriespy/sparse.py
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
def sparse_dimension_summary(
    trajectories: IrregularTrajectorySet,
    *,
    dimension: str,
) -> pd.DataFrame:
    """Summarize curve-specific observation density for one sparse dimension."""

    if dimension not in trajectories.dimension_names:
        raise KeyError(f"Unknown dimension {dimension!r}")
    index = trajectories.dimension_names.index(dimension)
    rows = []
    for curve_id, time, values in zip(
        trajectories.curve_ids,
        trajectories.time,
        trajectories.values,
        strict=True,
    ):
        observed = np.isfinite(values[:, index])
        intervals = np.diff(time[observed]) if np.count_nonzero(observed) >= 2 else np.array([])
        rows.append(
            {
                "curve_id": curve_id,
                "n_sample_times": int(len(time)),
                "n_observed": int(np.count_nonzero(observed)),
                "n_nonfinite": int(np.count_nonzero(~observed)),
                "observed_fraction": float(np.mean(observed)),
                "time_start": float(time[0]),
                "time_end": float(time[-1]),
                "time_span": float(time[-1] - time[0]),
                "median_observed_interval": (
                    float(np.median(intervals)) if intervals.size else np.nan
                ),
                "max_observed_interval": (
                    float(np.max(intervals)) if intervals.size else np.nan
                ),
            }
        )
    return pd.DataFrame(rows)

eyetrajectoriespy.to_fdapy_irregular

to_fdapy_irregular(trajectories: IrregularTrajectorySet, *, dimension: str)

Convert one native irregular dimension to FDApy without interpolation.

Source code in src/eyetrajectoriespy/sparse.py
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
def to_fdapy_irregular(
    trajectories: IrregularTrajectorySet,
    *,
    dimension: str,
):
    """Convert one native irregular dimension to FDApy without interpolation."""

    index = _validate_sparse_dimension(trajectories, dimension=dimension)
    try:
        from FDApy import IrregularFunctionalData
        from FDApy.representation import (
            DenseArgvals,
            IrregularArgvals,
            IrregularValues,
        )
    except ImportError as exc:
        raise ImportError(
            "FDApy is optional. Install eyetrajectoriespy with the 'sparse' extra in a Python 3.11 or 3.12 environment."
        ) from exc

    argvals = IrregularArgvals(
        {
            i: DenseArgvals({"input_dim_0": time.copy()})
            for i, time in enumerate(trajectories.time)
        }
    )
    values = IrregularValues(
        {
            i: curve[:, index].copy()
            for i, curve in enumerate(trajectories.values)
        }
    )
    return IrregularFunctionalData(argvals=argvals, values=values)

eyetrajectoriespy.fit_sparse_fpca_fdapy

fit_sparse_fpca_fdapy(trajectories: IrregularTrajectorySet, *, dimension: str, n_components: int = 3, fit_smoothing: str | None = 'PS', score_smoothing: str = 'LP', tol: float = 0.0001, normalize: bool = False, evaluation_grid: ndarray | None = None, kwargs_mean: Mapping[str, Any] | None = None, kwargs_covariance: Mapping[str, Any] | None = None) -> SparseFPCAResult

Fit univariate sparse FPCA and recover scores with FDApy PACE.

The estimator uses FDApy's covariance-operator UFPCA path and PACE conditional-expectation scoring. No common-grid interpolation is performed.

Source code in src/eyetrajectoriespy/sparse.py
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
def fit_sparse_fpca_fdapy(
    trajectories: IrregularTrajectorySet,
    *,
    dimension: str,
    n_components: int = 3,
    fit_smoothing: str | None = "PS",
    score_smoothing: str = "LP",
    tol: float = 1e-4,
    normalize: bool = False,
    evaluation_grid: np.ndarray | None = None,
    kwargs_mean: Mapping[str, Any] | None = None,
    kwargs_covariance: Mapping[str, Any] | None = None,
) -> SparseFPCAResult:
    """Fit univariate sparse FPCA and recover scores with FDApy PACE.

    The estimator uses FDApy's covariance-operator UFPCA path and PACE
    conditional-expectation scoring. No common-grid interpolation is performed.
    """

    if isinstance(n_components, bool) or not isinstance(n_components, int):
        raise TypeError("n_components must be an integer")
    if n_components < 1:
        raise ValueError("n_components must be positive")
    max_rank = trajectories.n_curves - 1
    if n_components > max_rank:
        raise ValueError(
            "n_components cannot exceed the non-zero centered sample rank "
            f"(n_curves - 1 = {max_rank})"
        )
    if tol <= 0 or not np.isfinite(tol):
        raise ValueError("tol must be finite and positive")
    if not isinstance(normalize, bool):
        raise TypeError("normalize must be boolean")
    allowed_fit_smoothing = {None, "PS", "LP"}
    if fit_smoothing not in allowed_fit_smoothing:
        raise ValueError("fit_smoothing must be None, 'PS', or 'LP'")
    if score_smoothing not in {"PS", "LP"}:
        raise ValueError("score_smoothing must be 'PS' or 'LP'")
    if kwargs_mean is not None and not isinstance(kwargs_mean, Mapping):
        raise TypeError("kwargs_mean must be a mapping or None")
    if kwargs_covariance is not None and not isinstance(kwargs_covariance, Mapping):
        raise TypeError("kwargs_covariance must be a mapping or None")

    grid = None
    if evaluation_grid is not None:
        grid = np.asarray(evaluation_grid, dtype=float)
        if (
            grid.ndim != 1
            or len(grid) < 2
            or not np.all(np.isfinite(grid))
            or not np.all(np.diff(grid) > 0)
        ):
            raise ValueError(
                "evaluation_grid must be a finite, strictly increasing one-dimensional array"
            )
        pooled_start = min(float(time[0]) for time in trajectories.time)
        pooled_end = max(float(time[-1]) for time in trajectories.time)
        if grid[0] < pooled_start or grid[-1] > pooled_end:
            raise ValueError(
                "evaluation_grid must remain within the pooled observed time support "
                f"[{pooled_start}, {pooled_end}]"
            )
        pooled_grid = np.unique(np.concatenate(trajectories.time))
        if not np.array_equal(grid, pooled_grid):
            raise ValueError(
                "FDApy 1.0.x irregular PACE scoring operates on the sorted pooled "
                "observed sample-time grid. evaluation_grid must therefore equal "
                "np.unique(np.concatenate(trajectories.time)) or be None."
            )

    data = to_fdapy_irregular(trajectories, dimension=dimension)
    try:
        from FDApy.preprocessing import UFPCA
        from FDApy.representation import DenseArgvals
    except ImportError as exc:
        raise ImportError(
            "FDApy is optional. Install eyetrajectoriespy with the 'sparse' extra in a Python 3.11 or 3.12 environment."
        ) from exc

    model = UFPCA(
        n_components=n_components,
        method="covariance",
        normalize=normalize,
    )
    points = (
        None
        if grid is None
        else DenseArgvals({"input_dim_0": grid.copy()})
    )
    mean_kwargs = dict(kwargs_mean or {})
    covariance_kwargs = dict(kwargs_covariance or {})
    model.fit(
        data,
        points=points,
        method_smoothing=fit_smoothing,
        kwargs_mean=mean_kwargs,
        kwargs_covariance=covariance_kwargs,
    )
    scores = np.asarray(
        model.transform(
            data,
            method="PACE",
            method_smoothing=score_smoothing,
            tol=tol,
        ),
        dtype=float,
    )
    if scores.shape != (trajectories.n_curves, n_components):
        raise RuntimeError(
            "FDApy returned an unexpected PACE score shape; expected "
            f"{(trajectories.n_curves, n_components)}, got {scores.shape}"
        )
    if not np.all(np.isfinite(scores)):
        raise RuntimeError("FDApy returned non-finite PACE scores")

    eigenvalues = np.asarray(model.eigenvalues, dtype=float)
    if eigenvalues.shape != (n_components,) or not np.all(np.isfinite(eigenvalues)):
        raise RuntimeError("FDApy returned invalid sparse-FPCA eigenvalues")

    reconstructed = model.inverse_transform(scores)

    return SparseFPCAResult(
        scores=scores,
        eigenvalues=eigenvalues,
        dimension=dimension,
        curve_ids=trajectories.curve_ids,
        metadata=trajectories.metadata.reset_index(drop=True),
        coordinate_system=trajectories.coordinate_system,
        time_unit=trajectories.time_unit,
        n_components=n_components,
        fit_method="covariance",
        fit_smoothing=fit_smoothing,
        score_method="PACE",
        score_smoothing=score_smoothing,
        tolerance=float(tol),
        normalize=normalize,
        provenance={
            **dict(trajectories.provenance),
            "sparse_fpca": {
                "backend": "FDApy",
                "backend_version": _fdapy_version(),
                "dimension": dimension,
                "fit_method": "covariance",
                "fit_smoothing": fit_smoothing,
                "score_method": "PACE",
                "score_smoothing": score_smoothing,
                "tolerance": float(tol),
                "normalize": normalize,
                "evaluation_grid": None if grid is None else grid.tolist(),
                "kwargs_mean": mean_kwargs,
                "kwargs_covariance": covariance_kwargs,
                "sample_counts": trajectories.sample_counts.tolist(),
                "interpolation_performed": False,
            },
        },
        backend_object=model,
        backend_data=data,
        reconstructed_backend=reconstructed,
    )

eyetrajectoriespy.sparse_fpca_score_frame

sparse_fpca_score_frame(result: SparseFPCAResult) -> pd.DataFrame

Return PACE scores with curve IDs and preserved curve metadata.

Source code in src/eyetrajectoriespy/sparse.py
281
282
283
284
285
286
287
288
def sparse_fpca_score_frame(result: SparseFPCAResult) -> pd.DataFrame:
    """Return PACE scores with curve IDs and preserved curve metadata."""

    frame = result.metadata.reset_index(drop=True).copy()
    frame.insert(0, "curve_id", result.curve_ids)
    for component in range(result.n_components):
        frame[f"SFPC{component + 1}"] = result.scores[:, component]
    return frame

eyetrajectoriespy.plot_sparse_irregular_dimension

plot_sparse_irregular_dimension(trajectories: IrregularTrajectorySet, *, dimension: str, ax=None)

Plot native irregular observations for one functional dimension.

Source code in src/eyetrajectoriespy/plotting.py
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
def plot_sparse_irregular_dimension(
    trajectories: IrregularTrajectorySet,
    *,
    dimension: str,
    ax=None,
):
    """Plot native irregular observations for one functional dimension."""

    if dimension not in trajectories.dimension_names:
        raise KeyError(f"Unknown dimension {dimension!r}")
    if ax is None:
        _, ax = plt.subplots()
    index = trajectories.dimension_names.index(dimension)
    for curve_id, time, values in zip(
        trajectories.curve_ids,
        trajectories.time,
        trajectories.values,
        strict=True,
    ):
        ax.plot(
            time,
            values[:, index],
            marker="o",
            linewidth=1,
            alpha=0.55,
            label=curve_id,
        )
    ax.set_xlabel(f"Time ({trajectories.time_unit})")
    ax.set_ylabel(dimension)
    ax.set_title(f"Native irregular observations: {dimension}")
    if trajectories.n_curves <= 12:
        ax.legend()
    return ax

eyetrajectoriespy.sparse_fpca_reporting_text

sparse_fpca_reporting_text(result: SparseFPCAResult, *, digits: int = 3) -> str

Generate manuscript-oriented wording for sparse PACE FPCA.

Source code in src/eyetrajectoriespy/reporting.py
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
def sparse_fpca_reporting_text(
    result: SparseFPCAResult,
    *,
    digits: int = 3,
) -> str:
    """Generate manuscript-oriented wording for sparse PACE FPCA."""

    sample_counts = result.provenance.get("sparse_fpca", {}).get("sample_counts", [])
    if sample_counts:
        sample_range = f"{min(sample_counts)}–{max(sample_counts)}"
    else:
        sample_range = "not recorded"
    eigen = ", ".join(f"{value:.{digits}f}" for value in result.eigenvalues)
    sparse = result.provenance.get("sparse_fpca", {})
    grid = sparse.get("evaluation_grid")
    if grid:
        grid_text = f"{len(grid)} points over [{grid[0]:g}, {grid[-1]:g}]"
    else:
        grid_text = "backend-default evaluation points"
    custom = bool(sparse.get("kwargs_mean") or sparse.get("kwargs_covariance"))
    custom_text = " Custom mean/covariance smoothing parameters were supplied." if custom else ""
    return (
        f"Sparse univariate FPCA was fitted to the {result.dimension!r} trajectory "
        f"dimension using FDApy's covariance-operator estimator, with "
        f"{result.fit_smoothing!r} fitting smoothness and PACE "
        f"conditional-expectation scores ({result.n_components} components; "
        f"per-curve sample-count range={sample_range}; retained eigenvalues={eigen}). "
        "No common-grid interpolation was performed before sparse FPCA. "
        f"Eigenfunctions/covariance were evaluated on {grid_text}. "
        f"PACE tolerance was {result.tolerance:g} and score smoothing was "
        f"{result.score_smoothing!r}.{custom_text}"
    )

Functional mean inference

eyetrajectoriespy.multiplier_functional_mean_band

multiplier_functional_mean_band(trajectories: TrajectorySet, *, confidence_level: float = 0.95, n_multiplier: int = 2000, unit: str = 'curve', participant_column: str | None = None, random_state: int | None = 0) -> FunctionalMeanBandResult

Estimate a simultaneous band for the functional mean on the observed grid.

The procedure uses a Gaussian multiplier bootstrap for the studentized empirical mean process and calibrates the band with the maximum absolute standardized deviation across all observed time points and functional dimensions.

With unit="participant", repeated trajectories are first averaged within participant. The estimand is therefore the equal-weight population mean of participant-average trajectories, not a curve-weighted mean.

The band is simultaneous over the observed time x dimension grid. It does not assert continuous-domain coverage between grid points.

Source code in src/eyetrajectoriespy/inference.py
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
def multiplier_functional_mean_band(
    trajectories: TrajectorySet,
    *,
    confidence_level: float = 0.95,
    n_multiplier: int = 2000,
    unit: str = "curve",
    participant_column: str | None = None,
    random_state: int | None = 0,
) -> FunctionalMeanBandResult:
    """Estimate a simultaneous band for the functional mean on the observed grid.

    The procedure uses a Gaussian multiplier bootstrap for the studentized
    empirical mean process and calibrates the band with the maximum absolute
    standardized deviation across all observed time points and functional
    dimensions.

    With unit="participant", repeated trajectories are first averaged within
    participant. The estimand is therefore the equal-weight population mean of
    participant-average trajectories, not a curve-weighted mean.

    The band is simultaneous over the observed time x dimension grid. It does
    not assert continuous-domain coverage between grid points.
    """

    if not 0 < confidence_level < 1:
        raise ValueError("confidence_level must lie in (0, 1)")
    if isinstance(n_multiplier, bool) or not isinstance(n_multiplier, int):
        raise TypeError("n_multiplier must be an integer")
    if n_multiplier < 100:
        raise ValueError("n_multiplier must be at least 100")

    unit_values, unit_ids, unit_provenance = _functional_mean_units(
        trajectories,
        unit=unit,
        participant_column=participant_column,
    )
    n_units = len(unit_ids)
    if n_units < 2:
        raise ValueError("At least two independent inference units are required")

    mean = np.mean(unit_values, axis=0)
    residual = unit_values - mean[None, :, :]
    pointwise_sd = np.std(unit_values, axis=0, ddof=1)
    pointwise_se = pointwise_sd / np.sqrt(n_units)

    positive_variance = pointwise_sd > np.finfo(float).eps
    rng = np.random.default_rng(random_state)
    max_statistics = np.zeros(n_multiplier, dtype=float)

    if np.any(positive_variance):
        batch_size = min(256, n_multiplier)
        for start in range(0, n_multiplier, batch_size):
            stop = min(start + batch_size, n_multiplier)
            multipliers = rng.normal(size=(stop - start, n_units))
            process = np.einsum(
                "bi,itd->btd",
                multipliers,
                residual,
                optimize=True,
            ) / np.sqrt(n_units)

            standardized = np.zeros_like(process)
            np.divide(
                process,
                pointwise_sd[None, :, :],
                out=standardized,
                where=positive_variance[None, :, :],
            )
            max_statistics[start:stop] = np.max(
                np.abs(standardized),
                axis=(1, 2),
            )

        critical_value = float(
            np.quantile(
                max_statistics,
                confidence_level,
                method="higher",
            )
        )
    else:
        critical_value = 0.0

    lower = mean - critical_value * pointwise_se
    upper = mean + critical_value * pointwise_se

    return FunctionalMeanBandResult(
        mean=mean,
        lower=lower,
        upper=upper,
        pointwise_se=pointwise_se,
        critical_value=critical_value,
        max_statistics=max_statistics,
        confidence_level=float(confidence_level),
        unit=unit,
        unit_ids=unit_ids,
        participant_column=participant_column if unit == "participant" else None,
        time=trajectories.time.copy(),
        dimension_names=trajectories.dimension_names,
        coordinate_system=trajectories.coordinate_system,
        time_unit=trajectories.time_unit,
        provenance={
            **dict(trajectories.provenance),
            "functional_mean_band": {
                "method": "gaussian_multiplier_studentized_maximum",
                "confidence_level": float(confidence_level),
                "n_multiplier": n_multiplier,
                "random_state": random_state,
                "unit": unit,
                "participant_column": (
                    participant_column if unit == "participant" else None
                ),
                "n_units": n_units,
                "zero_variance_grid_points": int(
                    np.size(positive_variance) - np.count_nonzero(positive_variance)
                ),
                "simultaneous_domain": "observed_time_by_dimension_grid",
                "continuous_between_grid_points": False,
                **unit_provenance,
            },
        },
    )

eyetrajectoriespy.functional_mean_band_frame

functional_mean_band_frame(result: FunctionalMeanBandResult) -> pd.DataFrame

Return a long-form table of simultaneous functional-mean band values.

Source code in src/eyetrajectoriespy/inference.py
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
def functional_mean_band_frame(
    result: FunctionalMeanBandResult,
) -> pd.DataFrame:
    """Return a long-form table of simultaneous functional-mean band values."""

    rows: list[dict[str, float | str]] = []
    for dim, name in enumerate(result.dimension_names):
        for index, time in enumerate(result.time):
            rows.append(
                {
                    "time": float(time),
                    "dimension": name,
                    "mean": float(result.mean[index, dim]),
                    "pointwise_se": float(result.pointwise_se[index, dim]),
                    "lower": float(result.lower[index, dim]),
                    "upper": float(result.upper[index, dim]),
                }
            )
    return pd.DataFrame(rows)

eyetrajectoriespy.plot_functional_mean_band

plot_functional_mean_band(result: FunctionalMeanBandResult, *, dimension: str | None = None, ax=None)

Plot a functional mean with its simultaneous observed-grid band.

Source code in src/eyetrajectoriespy/plotting.py
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
def plot_functional_mean_band(
    result: FunctionalMeanBandResult,
    *,
    dimension: str | None = None,
    ax=None,
):
    """Plot a functional mean with its simultaneous observed-grid band."""

    if dimension is None:
        dimension = result.dimension_names[0]
    if dimension not in result.dimension_names:
        raise KeyError(f"Unknown dimension {dimension!r}")
    if ax is None:
        _, ax = plt.subplots()

    dim = result.dimension_names.index(dimension)
    level = 100 * result.confidence_level
    ax.fill_between(
        result.time,
        result.lower[:, dim],
        result.upper[:, dim],
        alpha=0.2,
        label=f"{level:.1f}% simultaneous band",
    )
    ax.plot(
        result.time,
        result.mean[:, dim],
        label="functional mean",
    )
    ax.set_xlabel(f"Time ({result.time_unit})")
    ax.set_ylabel(dimension)
    ax.set_title(f"Functional mean band: {dimension}(t)")
    ax.legend()
    return ax

eyetrajectoriespy.functional_mean_band_reporting_text

functional_mean_band_reporting_text(result: FunctionalMeanBandResult, *, digits: int = 3) -> str

Generate manuscript-oriented wording for a simultaneous mean band.

Source code in src/eyetrajectoriespy/reporting.py
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
def functional_mean_band_reporting_text(
    result: FunctionalMeanBandResult,
    *,
    digits: int = 3,
) -> str:
    """Generate manuscript-oriented wording for a simultaneous mean band."""

    settings = result.provenance.get("functional_mean_band", {})
    n_multiplier = settings.get("n_multiplier", "unknown")
    estimand = settings.get("estimand", "unspecified")
    return (
        f"The functional mean was estimated from {result.n_units} {result.unit}-level "
        f"inference units using estimand={estimand!r}. A "
        f"{100 * result.confidence_level:.1f}% simultaneous observed-grid band "
        f"was calibrated with a studentized Gaussian multiplier maximum using "
        f"{n_multiplier} multiplier replicates "
        f"(critical value={result.critical_value:.{digits}f}). The band is "
        "simultaneous across the observed time-by-dimension grid and does not "
        "claim continuous-domain coverage between sampled grid points."
    )

FPCA / MFPCA

eyetrajectoriespy.fit_fpca

fit_fpca(trajectories: TrajectorySet, *, n_components: int | float = 0.95, scaling: str = 'none') -> FPCAResult

Fit quadrature-weighted functional PCA on one or more channels.

Parameters:

Name Type Description Default
trajectories TrajectorySet

Complete trajectories on a common grid. Missing values are rejected; users must make interpolation/exclusion decisions explicitly upstream.

required
n_components int | float

Integer component count or a proportion of variance to retain.

0.95
scaling str

"none" preserves original relative channel scales. Use "dimension_sd" to equalize integrated variance across dimensions. Scaling is explicit because x/y, pupil, velocity, or other channels may have scientifically different units.

'none'
Source code in src/eyetrajectoriespy/fpca.py
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
def fit_fpca(
    trajectories: TrajectorySet,
    *,
    n_components: int | float = 0.95,
    scaling: str = "none",
) -> FPCAResult:
    """Fit quadrature-weighted functional PCA on one or more channels.

    Parameters
    ----------
    trajectories:
        Complete trajectories on a common grid. Missing values are rejected;
        users must make interpolation/exclusion decisions explicitly upstream.
    n_components:
        Integer component count or a proportion of variance to retain.
    scaling:
        ``"none"`` preserves original relative channel scales. Use
        ``"dimension_sd"`` to equalize integrated variance across dimensions.
        Scaling is explicit because x/y, pupil, velocity, or other channels may
        have scientifically different units.
    """

    validate_trajectory_set(trajectories, require_complete=True)
    x = trajectories.values
    n_curves, n_time, n_dim = x.shape
    if n_curves < 2:
        raise ValueError("At least two trajectories are required for FPCA")
    weights = functional_trapezoid_weights(trajectories.time)
    mean = x.mean(axis=0)
    scales = _dimension_scales(trajectories, scaling=scaling, weights=weights)
    centered = (x - mean[None, :, :]) / scales[None, None, :]
    weighted = centered * np.sqrt(weights)[None, :, None]
    matrix = weighted.reshape(n_curves, n_time * n_dim)
    max_components = min(n_curves, n_time * n_dim)
    resolved = _resolve_n_components(n_components, max_components)
    pca = PCA(n_components=resolved, svd_solver="full")
    scores = pca.fit_transform(matrix)

    basis = pca.components_.reshape(pca.n_components_, n_time, n_dim)
    components = basis / np.sqrt(weights)[None, :, None] * scales[None, None, :]
    return FPCAResult(
        mean=mean,
        components=components,
        scores=scores,
        explained_variance=pca.explained_variance_.copy(),
        explained_variance_ratio=pca.explained_variance_ratio_.copy(),
        time=trajectories.time.copy(),
        dimension_names=trajectories.dimension_names,
        coordinate_system=trajectories.coordinate_system,
        time_unit=trajectories.time_unit,
        curve_ids=trajectories.curve_ids,
        weights=weights,
        scale=scales,
        provenance={
            **dict(trajectories.provenance),
            "fpca": {
                "method": "quadrature_weighted_grid_pca",
                "n_components": int(pca.n_components_),
                "requested_n_components": n_components,
                "scaling": scaling,
            },
        },
        model=pca,
    )

eyetrajectoriespy.fit_mfpca

fit_mfpca(trajectories: TrajectorySet, *, n_components: int | float = 0.95, scaling: str = 'none') -> FPCAResult

Fit multivariate FPCA to two or more functional dimensions.

This is a semantic wrapper around :func:fit_fpca that enforces a multivariate input and makes intent explicit in analysis scripts.

Source code in src/eyetrajectoriespy/fpca.py
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
def fit_mfpca(
    trajectories: TrajectorySet,
    *,
    n_components: int | float = 0.95,
    scaling: str = "none",
) -> FPCAResult:
    """Fit multivariate FPCA to two or more functional dimensions.

    This is a semantic wrapper around :func:`fit_fpca` that enforces a
    multivariate input and makes intent explicit in analysis scripts.
    """

    if trajectories.n_dimensions < 2:
        raise ValueError("fit_mfpca requires at least two functional dimensions")
    result = fit_fpca(trajectories, n_components=n_components, scaling=scaling)
    provenance = dict(result.provenance)
    provenance["fpca"] = {**provenance["fpca"], "multivariate": True}
    return FPCAResult(**{**result.__dict__, "provenance": provenance})

eyetrajectoriespy.transform_fpca

transform_fpca(result: FPCAResult, trajectories: TrajectorySet) -> np.ndarray

Project compatible trajectories into an existing FPCA basis.

Source code in src/eyetrajectoriespy/fpca.py
155
156
157
158
159
160
161
162
163
164
165
166
167
168
def transform_fpca(result: FPCAResult, trajectories: TrajectorySet) -> np.ndarray:
    """Project compatible trajectories into an existing FPCA basis."""

    validate_trajectory_set(trajectories, require_complete=True)
    if not np.array_equal(trajectories.time, result.time):
        raise ValueError("Trajectory grid must match the fitted FPCA grid exactly")
    if trajectories.dimension_names != result.dimension_names:
        raise ValueError("Functional dimension names/order must match the fitted FPCA model")
    centered = (trajectories.values - result.mean[None, :, :]) / result.scale[None, None, :]
    weighted = centered * np.sqrt(result.weights)[None, :, None]
    matrix = weighted.reshape(trajectories.n_curves, -1)
    basis = result.components / result.scale[None, None, :] * np.sqrt(result.weights)[None, :, None]
    flat_basis = basis.reshape(result.n_components, -1)
    return matrix @ flat_basis.T

eyetrajectoriespy.reconstruct_fpca

reconstruct_fpca(result: FPCAResult, *, scores: ndarray | None = None, n_components: int | None = None) -> np.ndarray

Reconstruct trajectories from functional principal component scores.

Source code in src/eyetrajectoriespy/fpca.py
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
def reconstruct_fpca(
    result: FPCAResult,
    *,
    scores: np.ndarray | None = None,
    n_components: int | None = None,
) -> np.ndarray:
    """Reconstruct trajectories from functional principal component scores."""

    if scores is None:
        scores = result.scores
    scores = np.asarray(scores, dtype=float)
    if scores.ndim == 1:
        scores = scores[None, :]
    if scores.ndim != 2:
        raise ValueError("scores must be a one- or two-dimensional array")
    if n_components is None:
        n_components = min(scores.shape[1], result.n_components)
    if n_components < 1 or n_components > result.n_components:
        raise ValueError("n_components is outside the fitted range")
    if scores.shape[1] < n_components:
        raise ValueError("scores contain fewer columns than requested components")
    return result.mean[None, :, :] + np.einsum(
        "nk,ktd->ntd", scores[:, :n_components], result.components[:n_components]
    )

eyetrajectoriespy.component_trajectories

component_trajectories(result: FPCAResult, component: int, *, sd_multipliers: tuple[float, ...] = (-2.0, -1.0, 0.0, 1.0, 2.0)) -> np.ndarray

Return mean ± score-SD trajectories for interpreting one component.

Source code in src/eyetrajectoriespy/fpca.py
197
198
199
200
201
202
203
204
205
206
207
208
209
def component_trajectories(
    result: FPCAResult,
    component: int,
    *,
    sd_multipliers: tuple[float, ...] = (-2.0, -1.0, 0.0, 1.0, 2.0),
) -> np.ndarray:
    """Return mean ± score-SD trajectories for interpreting one component."""

    if component < 0 or component >= result.n_components:
        raise IndexError("component is outside the fitted range")
    sd = float(np.sqrt(result.explained_variance[component]))
    multipliers = np.asarray(sd_multipliers, dtype=float)
    return result.mean[None, :, :] + multipliers[:, None, None] * sd * result.components[component]

eyetrajectoriespy.fpca_score_frame

fpca_score_frame(result: FPCAResult, *, prefix: str = 'FPC')

Return component scores as a tidy pandas DataFrame.

Source code in src/eyetrajectoriespy/fpca.py
221
222
223
224
225
226
227
228
229
def fpca_score_frame(result: FPCAResult, *, prefix: str = "FPC"):
    """Return component scores as a tidy pandas DataFrame."""

    import pandas as pd

    columns = [f"{prefix}{i + 1}" for i in range(result.n_components)]
    frame = pd.DataFrame(result.scores, columns=columns)
    frame.insert(0, "curve_id", result.curve_ids)
    return frame

FPCA component selection

eyetrajectoriespy.cross_validate_fpca_reconstruction

cross_validate_fpca_reconstruction(trajectories: TrajectorySet, *, candidate_components: Sequence[int] = (1, 2, 3, 4, 5), n_splits: int = 5, scaling: str = 'none', cv_unit: str = 'curve', group_column: str | None = None, shuffle: bool = True, random_state: int | None = 0) -> FPCACrossValidationResult

Evaluate candidate FPC counts by held-out reconstruction error.

FPCA is refitted inside every training fold. With cv_unit="group", every curve sharing group_column is held out together so repeated measurements from the same participant or other grouping unit cannot leak into both train and test folds.

The function returns diagnostics only. It never chooses a component count unless select_fpca_components_cv() is called explicitly.

Source code in src/eyetrajectoriespy/selection.py
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
def cross_validate_fpca_reconstruction(
    trajectories: TrajectorySet,
    *,
    candidate_components: Sequence[int] = (1, 2, 3, 4, 5),
    n_splits: int = 5,
    scaling: str = "none",
    cv_unit: str = "curve",
    group_column: str | None = None,
    shuffle: bool = True,
    random_state: int | None = 0,
) -> FPCACrossValidationResult:
    """Evaluate candidate FPC counts by held-out reconstruction error.

    FPCA is refitted inside every training fold. With cv_unit="group", every
    curve sharing group_column is held out together so repeated measurements
    from the same participant or other grouping unit cannot leak into both
    train and test folds.

    The function returns diagnostics only. It never chooses a component count
    unless select_fpca_components_cv() is called explicitly.
    """

    validate_trajectory_set(trajectories, require_complete=True)
    counts = _candidate_counts(candidate_components)

    if isinstance(n_splits, bool) or not isinstance(n_splits, int) or n_splits < 2:
        raise ValueError("n_splits must be an integer >= 2")
    if cv_unit not in {"curve", "group"}:
        raise ValueError("cv_unit must be 'curve' or 'group'")

    indices = np.arange(trajectories.n_curves)
    if cv_unit == "curve":
        if n_splits > trajectories.n_curves:
            raise ValueError("n_splits cannot exceed number of trajectories")
        splitter = KFold(
            n_splits=n_splits,
            shuffle=shuffle,
            random_state=random_state if shuffle else None,
        )
        split_iter = splitter.split(indices)
        groups = None
    else:
        if group_column is None:
            raise ValueError("group_column is required for group cross-validation")
        if group_column not in trajectories.metadata.columns:
            raise ValueError(f"metadata does not contain group column {group_column!r}")
        if trajectories.metadata[group_column].isna().any():
            raise ValueError("group_column contains missing values")
        groups = trajectories.metadata[group_column].astype(str).to_numpy()
        n_groups = len(pd.unique(groups))
        if n_splits > n_groups:
            raise ValueError("n_splits cannot exceed number of unique groups")
        splitter = GroupKFold(n_splits=n_splits)
        split_iter = splitter.split(indices, groups=groups)

    fold_rows: list[dict[str, int | float]] = []
    assignment_rows: list[dict[str, int | str]] = []

    for fold, (train_idx, test_idx) in enumerate(split_iter):
        max_train_components = min(
            len(train_idx) - 1,
            trajectories.n_time * trajectories.n_dimensions,
        )
        if counts[-1] > max_train_components:
            raise ValueError(
                f"candidate component count {counts[-1]} exceeds training-fold "
                f"maximum {max_train_components}; reduce candidate_components "
                "or n_splits"
            )

        train = trajectories.subset(train_idx)
        test = trajectories.subset(test_idx)
        fit = _fit(
            train,
            n_components=counts[-1],
            scaling=scaling,
        )
        test_scores = transform_fpca(fit, test)

        for n_components in counts:
            reconstructed = reconstruct_fpca(
                fit,
                scores=test_scores,
                n_components=n_components,
            )
            rmse = _integrated_rmse(
                test.values,
                reconstructed,
                fit.weights,
            )
            fold_rows.append(
                {
                    "fold": fold,
                    "n_components": n_components,
                    "mean_integrated_rmse": float(np.mean(rmse)),
                    "median_integrated_rmse": float(np.median(rmse)),
                    "n_train_curves": int(len(train_idx)),
                    "n_test_curves": int(len(test_idx)),
                }
            )

        for index in test_idx:
            row: dict[str, int | str] = {
                "curve_id": trajectories.curve_ids[index],
                "fold": fold,
            }
            if groups is not None:
                row["group"] = str(groups[index])
            assignment_rows.append(row)

    return FPCACrossValidationResult(
        fold_errors=pd.DataFrame(fold_rows),
        assignments=pd.DataFrame(assignment_rows),
        component_counts=counts,
        cv_unit=cv_unit,
        n_splits=n_splits,
        group_column=group_column if cv_unit == "group" else None,
        scaling=scaling,
        random_state=random_state if cv_unit == "curve" and shuffle else None,
        provenance={
            "method": "heldout_reconstruction_cv",
            "fit_inside_fold": True,
            "shuffle": bool(shuffle) if cv_unit == "curve" else False,
            "group_leakage_prevented": cv_unit == "group",
        },
    )

eyetrajectoriespy.summarise_fpca_cross_validation

summarise_fpca_cross_validation(result: FPCACrossValidationResult) -> pd.DataFrame

Aggregate fold-level held-out reconstruction errors.

Source code in src/eyetrajectoriespy/selection.py
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
def summarise_fpca_cross_validation(
    result: FPCACrossValidationResult,
) -> pd.DataFrame:
    """Aggregate fold-level held-out reconstruction errors."""

    grouped = result.fold_errors.groupby(
        "n_components",
        sort=True,
    )["mean_integrated_rmse"]
    summary = grouped.agg(
        [
            ("mean_rmse", "mean"),
            ("sd_rmse", "std"),
            ("n_folds", "count"),
        ]
    ).reset_index()
    summary["sd_rmse"] = summary["sd_rmse"].fillna(0.0)
    summary["se_rmse"] = summary["sd_rmse"] / np.sqrt(summary["n_folds"])
    return summary

eyetrajectoriespy.select_fpca_components_cv

select_fpca_components_cv(result: FPCACrossValidationResult, *, rule: str = 'minimum') -> int

Select a component count using an explicit reconstruction-CV rule.

"minimum" selects the component count with the smallest mean fold RMSE.

"one_se" selects the smallest component count whose mean RMSE is within one standard error of the minimum-RMSE candidate. This is a parsimony heuristic rather than an inferential guarantee.

Source code in src/eyetrajectoriespy/selection.py
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
def select_fpca_components_cv(
    result: FPCACrossValidationResult,
    *,
    rule: str = "minimum",
) -> int:
    """Select a component count using an explicit reconstruction-CV rule.

    "minimum" selects the component count with the smallest mean fold RMSE.

    "one_se" selects the smallest component count whose mean RMSE is within one
    standard error of the minimum-RMSE candidate. This is a parsimony heuristic
    rather than an inferential guarantee.
    """

    if rule not in {"minimum", "one_se"}:
        raise ValueError("rule must be 'minimum' or 'one_se'")

    summary = summarise_fpca_cross_validation(result)
    best_index = int(summary["mean_rmse"].idxmin())
    best = summary.loc[best_index]

    if rule == "minimum":
        return int(best["n_components"])

    cutoff = float(best["mean_rmse"] + best["se_rmse"])
    eligible = summary[summary["mean_rmse"] <= cutoff]
    return int(eligible["n_components"].min())

eyetrajectoriespy.plot_fpca_cross_validation

plot_fpca_cross_validation(result: FPCACrossValidationResult, *, ax=None)

Plot mean held-out reconstruction RMSE with fold-level SE bars.

Source code in src/eyetrajectoriespy/plotting.py
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
def plot_fpca_cross_validation(
    result: FPCACrossValidationResult,
    *,
    ax=None,
):
    """Plot mean held-out reconstruction RMSE with fold-level SE bars."""

    if ax is None:
        _, ax = plt.subplots()
    summary = summarise_fpca_cross_validation(result)
    ax.errorbar(
        summary["n_components"],
        summary["mean_rmse"],
        yerr=summary["se_rmse"],
        marker="o",
        capsize=3,
    )
    ax.set_xlabel("Retained functional principal components")
    ax.set_ylabel("Held-out integrated RMSE")
    ax.set_title("FPCA reconstruction cross-validation")
    return ax

eyetrajectoriespy.fpca_cross_validation_reporting_text

fpca_cross_validation_reporting_text(result: FPCACrossValidationResult, *, rule: str = 'minimum', digits: int = 3) -> str

Generate manuscript-oriented text for held-out component selection.

Source code in src/eyetrajectoriespy/reporting.py
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
def fpca_cross_validation_reporting_text(
    result: FPCACrossValidationResult,
    *,
    rule: str = "minimum",
    digits: int = 3,
) -> str:
    """Generate manuscript-oriented text for held-out component selection."""

    summary = summarise_fpca_cross_validation(result)
    selected = select_fpca_components_cv(result, rule=rule)
    row = summary.loc[summary["n_components"] == selected].iloc[0]
    unit = "grouped" if result.cv_unit == "group" else "curve-level"
    heuristic = (
        " The one-standard-error rule was used as a parsimony heuristic."
        if rule == "one_se"
        else ""
    )
    return (
        f"FPCA component selection used {result.n_splits}-fold {unit} held-out "
        "reconstruction cross-validation, with FPCA refitted inside every "
        f"training fold. The explicit '{rule}' rule selected {selected} "
        f"component(s) (mean integrated RMSE={row['mean_rmse']:.{digits}f}, "
        f"SE={row['se_rmse']:.{digits}f}).{heuristic}"
    )

Stabilized-volatility FPCR wild-bootstrap truncation selection

eyetrajectoriespy.FPCAWildBootstrapTruncationScanResult dataclass

Shared-multiplier wild-bootstrap interval scan over inference truncations.

Source code in src/eyetrajectoriespy/types.py
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
@dataclass(frozen=True)
class FPCAWildBootstrapTruncationScanResult:
    """Shared-multiplier wild-bootstrap interval scan over inference truncations."""

    reference_fpca: FPCAResult
    target_curve_ids: tuple[str, ...]
    candidate_components: tuple[int, ...]
    residual_components: int
    pseudo_truth_projection: np.ndarray
    reference_projections: np.ndarray
    reference_se: np.ndarray
    critical_values: np.ndarray
    lower: np.ndarray
    upper: np.ndarray
    centers: np.ndarray
    widths: np.ndarray
    studentized_roots: np.ndarray
    confidence_level: float
    scaling: str
    multiplier: str
    target_source: str
    independent_unit_column: str | None
    random_state: int | None
    provenance: Mapping[str, Any] = field(default_factory=dict)

    @property
    def n_bootstrap(self) -> int:
        return self.studentized_roots.shape[1]

    @property
    def n_targets(self) -> int:
        return self.studentized_roots.shape[2]

    @property
    def n_candidates(self) -> int:
        return len(self.candidate_components)

eyetrajectoriespy.FPCAWildBootstrapTruncationSelectionResult dataclass

Stabilized-volatility selection from a wild-bootstrap truncation scan.

Source code in src/eyetrajectoriespy/types.py
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
@dataclass(frozen=True)
class FPCAWildBootstrapTruncationSelectionResult:
    """Stabilized-volatility selection from a wild-bootstrap truncation scan."""

    scan: FPCAWildBootstrapTruncationScanResult
    width_changes: np.ndarray
    center_changes: np.ndarray
    stable_width: np.ndarray
    stable_center: np.ndarray
    stable_both: np.ndarray
    selected_candidate_indices: np.ndarray
    selected_components: np.ndarray
    selected_centers: np.ndarray
    selected_widths: np.ndarray
    selected_lower: np.ndarray
    selected_upper: np.ndarray
    width_threshold: float
    center_threshold: float
    stability_run: int
    on_failure: str
    provenance: Mapping[str, Any] = field(default_factory=dict)

    @property
    def n_targets(self) -> int:
        return self.scan.n_targets

eyetrajectoriespy.scan_wild_bootstrap_fpca_truncations

scan_wild_bootstrap_fpca_truncations(trajectories: TrajectorySet, outcome: ndarray | Series, *, candidate_components, targets: TrajectorySet | None = None, n_bootstrap: int = 1000, residual_components: int = 2, scaling: str = 'none', multiplier: str = 'normal', confidence_level: float = 0.95, independent_unit_column: str | None = None, random_state: int | None = 0) -> FPCAWildBootstrapTruncationScanResult

Scan target-wise wild-bootstrap intervals over consecutive h values.

One FPCA/MFPCA basis is fitted at the maximum candidate truncation. The same wild multiplier draw is then reused across every candidate h for a given bootstrap replicate so adjacent interval changes are not contaminated by independent Monte Carlo draws.

Residual estimation and the bootstrap pseudo-truth use k=g equal to residual_components. Every candidate h must satisfy h greater than or equal to g.

Source code in src/eyetrajectoriespy/wild_selection.py
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
def scan_wild_bootstrap_fpca_truncations(
    trajectories: TrajectorySet,
    outcome: np.ndarray | pd.Series,
    *,
    candidate_components,
    targets: TrajectorySet | None = None,
    n_bootstrap: int = 1000,
    residual_components: int = 2,
    scaling: str = "none",
    multiplier: str = "normal",
    confidence_level: float = 0.95,
    independent_unit_column: str | None = None,
    random_state: int | None = 0,
) -> FPCAWildBootstrapTruncationScanResult:
    """Scan target-wise wild-bootstrap intervals over consecutive h values.

    One FPCA/MFPCA basis is fitted at the maximum candidate truncation. The same
    wild multiplier draw is then reused across every candidate h for a given
    bootstrap replicate so adjacent interval changes are not contaminated by
    independent Monte Carlo draws.

    Residual estimation and the bootstrap pseudo-truth use k=g equal to
    residual_components. Every candidate h must satisfy h greater than or equal
    to g.
    """

    _validate_complete_finite(trajectories, name="training trajectories")
    y = np.asarray(outcome, dtype=float)
    if y.shape != (trajectories.n_curves,):
        raise ValueError(
            "outcome must contain exactly one value per training trajectory"
        )
    if not np.all(np.isfinite(y)):
        raise ValueError("outcome must contain only finite values")

    if targets is None:
        target_set = trajectories
        target_source = "training"
    else:
        target_set = targets
        target_source = "external"
        _validate_targets(trajectories, target_set)

    if isinstance(n_bootstrap, bool) or not isinstance(
        n_bootstrap,
        (int, np.integer),
    ):
        raise TypeError("n_bootstrap must be an integer")
    n_bootstrap = int(n_bootstrap)
    if n_bootstrap < 20:
        raise ValueError("n_bootstrap must be at least 20")

    maximum = min(
        trajectories.n_curves - 1,
        trajectories.n_time * trajectories.n_dimensions,
    )
    residual_components = _validate_component_count(
        residual_components,
        name="residual_components",
        maximum=maximum,
    )
    candidates = _validate_candidate_components(
        candidate_components,
        residual_components=residual_components,
        maximum=maximum,
    )
    if scaling not in {"none", "dimension_sd"}:
        raise ValueError("scaling must be 'none' or 'dimension_sd'")
    if multiplier not in {"normal", "mammen"}:
        raise ValueError("multiplier must be 'normal' or 'mammen'")
    if not 0 < confidence_level < 1:
        raise ValueError("confidence_level must lie in (0, 1)")

    _validate_independent_curve_rows(
        trajectories,
        independent_unit_column=independent_unit_column,
    )

    max_components = candidates[-1]
    reference_fpca = _fit_for_trajectories(
        trajectories,
        n_components=max_components,
        scaling=scaling,
    )
    scores = np.asarray(
        reference_fpca.scores[:, :max_components],
        dtype=float,
    )
    target_scores = np.asarray(
        transform_fpca(reference_fpca, target_set)[:, :max_components],
        dtype=float,
    )
    if not np.all(np.isfinite(scores)) or not np.all(np.isfinite(target_scores)):
        raise RuntimeError("FPCA score geometry contains non-finite values")

    params_k, fitted_k, residuals_k = _fixed_score_fit(
        scores,
        y,
        n_components=residual_components,
        context="reference residual-truncation",
    )
    pseudo_truth_projection = (
        target_scores[:, :residual_components] @ params_k[1:]
    )

    rng = np.random.default_rng(random_state)
    multiplier_draws = np.empty(
        (n_bootstrap, trajectories.n_curves),
        dtype=float,
    )
    pseudo_outcomes = np.empty_like(multiplier_draws)
    bootstrap_residuals_k = np.empty_like(multiplier_draws)
    for bootstrap_index in range(n_bootstrap):
        multiplier_draws[bootstrap_index] = _draw_wild_multipliers(
            rng,
            n=trajectories.n_curves,
            multiplier=multiplier,
        )
        pseudo_outcomes[bootstrap_index] = (
            fitted_k + residuals_k * multiplier_draws[bootstrap_index]
        )
        _, _, bootstrap_residuals_k[bootstrap_index] = _fixed_score_fit(
            scores,
            pseudo_outcomes[bootstrap_index],
            n_components=residual_components,
            context=(
                f"wild truncation scan replicate {bootstrap_index} "
                "residual-truncation"
            ),
        )

    n_candidates = len(candidates)
    n_targets = target_set.n_curves
    reference_projections = np.empty((n_candidates, n_targets), dtype=float)
    reference_se = np.empty_like(reference_projections)
    critical_values = np.empty_like(reference_projections)
    lower = np.empty_like(reference_projections)
    upper = np.empty_like(reference_projections)
    studentized_roots = np.empty(
        (n_candidates, n_bootstrap, n_targets),
        dtype=float,
    )

    for candidate_index, h in enumerate(candidates):
        target_h = target_scores[:, :h]
        score_h = scores[:, :h]
        params_h, _, _ = _fixed_score_fit(
            scores,
            y,
            n_components=h,
            context=f"reference inference-truncation h={h}",
        )
        projection_h = target_h @ params_h[1:]
        se_h = _heteroscedastic_projection_se(
            score_h,
            residuals_k,
            target_h,
            context=f"reference h={h}",
        )
        reference_projections[candidate_index] = projection_h
        reference_se[candidate_index] = se_h

        root_scale = max(
            1.0,
            float(np.max(np.abs(projection_h))) if projection_h.size else 1.0,
        )
        root_tolerance = 100.0 * np.finfo(float).eps * root_scale

        for bootstrap_index in range(n_bootstrap):
            params_star_h, _, _ = _fixed_score_fit(
                scores,
                pseudo_outcomes[bootstrap_index],
                n_components=h,
                context=(
                    f"wild truncation scan replicate {bootstrap_index} "
                    f"inference-truncation h={h}"
                ),
            )
            projection_star = target_h @ params_star_h[1:]
            se_star = _heteroscedastic_projection_se(
                score_h,
                bootstrap_residuals_k[bootstrap_index],
                target_h,
                context=(
                    f"wild truncation scan replicate {bootstrap_index} h={h}"
                ),
            )
            root = projection_star - pseudo_truth_projection

            statistic = np.zeros_like(root)
            se_scale = max(
                1.0,
                float(np.max(se_star)) if se_star.size else 1.0,
            )
            positive = se_star > np.finfo(float).eps * se_scale
            np.divide(root, se_star, out=statistic, where=positive)
            degenerate = (~positive) & (np.abs(root) > root_tolerance)
            if np.any(degenerate):
                bad = np.flatnonzero(degenerate).tolist()
                raise RuntimeError(
                    f"wild truncation scan replicate {bootstrap_index}, h={h} "
                    "has zero bootstrap standard error with non-zero projection "
                    f"root for target index/indices {bad[:8]}"
                )
            studentized_roots[
                candidate_index,
                bootstrap_index,
            ] = statistic

        critical_h = np.quantile(
            np.abs(studentized_roots[candidate_index]),
            confidence_level,
            axis=0,
            method="higher",
        )
        critical_values[candidate_index] = critical_h
        lower[candidate_index] = projection_h - critical_h * se_h
        upper[candidate_index] = projection_h + critical_h * se_h

    centers = (lower + upper) / 2.0
    widths = upper - lower

    return FPCAWildBootstrapTruncationScanResult(
        reference_fpca=reference_fpca,
        target_curve_ids=target_set.curve_ids,
        candidate_components=candidates,
        residual_components=residual_components,
        pseudo_truth_projection=np.asarray(
            pseudo_truth_projection,
            dtype=float,
        ),
        reference_projections=reference_projections,
        reference_se=reference_se,
        critical_values=critical_values,
        lower=lower,
        upper=upper,
        centers=centers,
        widths=widths,
        studentized_roots=studentized_roots,
        confidence_level=float(confidence_level),
        scaling=scaling,
        multiplier=multiplier,
        target_source=target_source,
        independent_unit_column=independent_unit_column,
        random_state=random_state,
        provenance={
            **dict(trajectories.provenance),
            "fpca_wild_bootstrap_truncation_scan": {
                "method": "shared_multiplier_wild_bootstrap_truncation_scan",
                "family": "gaussian",
                "residual_components_k": residual_components,
                "pseudo_truth_components_g": residual_components,
                "candidate_inference_components_h": list(candidates),
                "g_equals_k": True,
                "all_h_at_least_g": True,
                "shared_multiplier_draws_across_h": True,
                "n_bootstrap": n_bootstrap,
                "confidence_level": float(confidence_level),
                "scaling": scaling,
                "multiplier": multiplier,
                "interval": "symmetrized_studentized_targetwise",
                "functional_regressors_fixed": True,
                "fpca_basis_refit_in_bootstrap": False,
                "target_curves_fixed": True,
                "independence_assumption": "independent_curve_rows",
                "independent_unit_column": independent_unit_column,
                "simultaneous_across_targets": False,
                "random_state": random_state,
            },
        },
    )

eyetrajectoriespy.select_fpca_wild_bootstrap_truncation

select_fpca_wild_bootstrap_truncation(scan: FPCAWildBootstrapTruncationScanResult, *, width_threshold: float, center_threshold: float, stability_run: int, on_failure: str = 'error') -> FPCAWildBootstrapTruncationSelectionResult

Select target-specific h values by the stabilized-volatility rule.

For consecutive candidate h values, a transition at h is width-stable when abs(width[h+1] - width[h]) <= width_threshold and center-stable under the analogous center threshold. A stable transition satisfies both.

stability_run is the paper's integer r. Selection therefore requires r+1 consecutive stable transitions beginning at h and chooses the earliest such h for each target.

Source code in src/eyetrajectoriespy/wild_selection.py
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
def select_fpca_wild_bootstrap_truncation(
    scan: FPCAWildBootstrapTruncationScanResult,
    *,
    width_threshold: float,
    center_threshold: float,
    stability_run: int,
    on_failure: str = "error",
) -> FPCAWildBootstrapTruncationSelectionResult:
    """Select target-specific h values by the stabilized-volatility rule.

    For consecutive candidate h values, a transition at h is width-stable when
    abs(width[h+1] - width[h]) <= width_threshold and center-stable under
    the analogous center threshold. A stable transition satisfies both.

    stability_run is the paper's integer r. Selection therefore requires
    r+1 consecutive stable transitions beginning at h and chooses the earliest
    such h for each target.
    """

    if not isinstance(scan, FPCAWildBootstrapTruncationScanResult):
        raise TypeError(
            "scan must be an FPCAWildBootstrapTruncationScanResult"
        )
    for value, name in [
        (width_threshold, "width_threshold"),
        (center_threshold, "center_threshold"),
    ]:
        if not np.isscalar(value) or isinstance(value, (bool, np.bool_)):
            raise TypeError(f"{name} must be a finite positive scalar")
        value_float = float(value)
        if not np.isfinite(value_float) or value_float <= 0:
            raise ValueError(f"{name} must be finite and strictly positive")

    width_threshold = float(width_threshold)
    center_threshold = float(center_threshold)

    if isinstance(stability_run, bool) or not isinstance(
        stability_run,
        (int, np.integer),
    ):
        raise TypeError("stability_run must be an integer")
    stability_run = int(stability_run)
    if stability_run < 0:
        raise ValueError("stability_run must be non-negative")
    required_transitions = stability_run + 1
    if required_transitions > scan.n_candidates - 1:
        raise ValueError(
            "stability_run requires more consecutive stable transitions than "
            "the candidate grid can provide"
        )
    if on_failure not in {"error", "warn", "ignore"}:
        raise ValueError("on_failure must be 'error', 'warn', or 'ignore'")

    width_changes = np.abs(np.diff(scan.widths, axis=0))
    center_changes = np.abs(np.diff(scan.centers, axis=0))
    stable_width = width_changes <= width_threshold
    stable_center = center_changes <= center_threshold
    stable_both = stable_width & stable_center

    selected_indices = np.full(scan.n_targets, -1, dtype=int)
    selected_components = np.full(scan.n_targets, np.nan, dtype=float)
    selected_centers = np.full(scan.n_targets, np.nan, dtype=float)
    selected_widths = np.full(scan.n_targets, np.nan, dtype=float)
    selected_lower = np.full(scan.n_targets, np.nan, dtype=float)
    selected_upper = np.full(scan.n_targets, np.nan, dtype=float)

    last_start = stable_both.shape[0] - required_transitions
    for target_index in range(scan.n_targets):
        for start in range(last_start + 1):
            stop = start + required_transitions
            if np.all(stable_both[start:stop, target_index]):
                selected_indices[target_index] = start
                selected_components[target_index] = float(
                    scan.candidate_components[start]
                )
                selected_centers[target_index] = scan.centers[
                    start,
                    target_index,
                ]
                selected_widths[target_index] = scan.widths[
                    start,
                    target_index,
                ]
                selected_lower[target_index] = scan.lower[
                    start,
                    target_index,
                ]
                selected_upper[target_index] = scan.upper[
                    start,
                    target_index,
                ]
                break

    failed = np.flatnonzero(selected_indices < 0)
    if failed.size:
        failed_ids = [scan.target_curve_ids[i] for i in failed]
        message = (
            "stabilized-volatility selection found no qualifying run for "
            f"{failed.size} target(s): {failed_ids[:8]}. Expand the pre-specified "
            "candidate grid or reconsider the pre-specified thresholds; the "
            "package will not silently choose the largest h."
        )
        if on_failure == "error":
            raise RuntimeError(message)
        if on_failure == "warn":
            warnings.warn(message, RuntimeWarning, stacklevel=2)

    return FPCAWildBootstrapTruncationSelectionResult(
        scan=scan,
        width_changes=width_changes,
        center_changes=center_changes,
        stable_width=stable_width,
        stable_center=stable_center,
        stable_both=stable_both,
        selected_candidate_indices=selected_indices,
        selected_components=selected_components,
        selected_centers=selected_centers,
        selected_widths=selected_widths,
        selected_lower=selected_lower,
        selected_upper=selected_upper,
        width_threshold=width_threshold,
        center_threshold=center_threshold,
        stability_run=stability_run,
        on_failure=on_failure,
        provenance={
            **dict(scan.provenance),
            "fpca_wild_bootstrap_truncation_selection": {
                "method": "stabilized_volatility",
                "width_threshold_rho_w": width_threshold,
                "center_threshold_rho_c": center_threshold,
                "stability_run_r": stability_run,
                "required_consecutive_stable_transitions": required_transitions,
                "selection": (
                    "earliest_candidate_h_starting_qualifying_stable_run"
                ),
                "threshold_units": "scalar_outcome_units",
                "thresholds_package_defaulted": False,
                "on_failure": on_failure,
                "silent_largest_h_fallback": False,
                "target_specific_selection": True,
            },
        },
    )

eyetrajectoriespy.fpca_wild_bootstrap_truncation_scan_frame

fpca_wild_bootstrap_truncation_scan_frame(result: FPCAWildBootstrapTruncationScanResult) -> pd.DataFrame

Return one row per candidate h and fixed target trajectory.

Source code in src/eyetrajectoriespy/wild_selection.py
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
def fpca_wild_bootstrap_truncation_scan_frame(
    result: FPCAWildBootstrapTruncationScanResult,
) -> pd.DataFrame:
    """Return one row per candidate h and fixed target trajectory."""

    rows: list[dict[str, object]] = []
    for i, h in enumerate(result.candidate_components):
        for j, curve_id in enumerate(result.target_curve_ids):
            rows.append(
                {
                    "curve_id": curve_id,
                    "inference_components": h,
                    "reference_projection": result.reference_projections[i, j],
                    "heteroscedastic_se": result.reference_se[i, j],
                    "critical_value": result.critical_values[i, j],
                    "lower": result.lower[i, j],
                    "upper": result.upper[i, j],
                    "center": result.centers[i, j],
                    "width": result.widths[i, j],
                }
            )
    return pd.DataFrame(rows)

eyetrajectoriespy.fpca_wild_bootstrap_truncation_selection_frame

fpca_wild_bootstrap_truncation_selection_frame(result: FPCAWildBootstrapTruncationSelectionResult) -> pd.DataFrame

Return one row per target with stabilized-volatility selection.

Source code in src/eyetrajectoriespy/wild_selection.py
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
def fpca_wild_bootstrap_truncation_selection_frame(
    result: FPCAWildBootstrapTruncationSelectionResult,
) -> pd.DataFrame:
    """Return one row per target with stabilized-volatility selection."""

    if not isinstance(result, FPCAWildBootstrapTruncationSelectionResult):
        raise TypeError(
            "result must be an FPCAWildBootstrapTruncationSelectionResult"
        )
    selected = result.selected_components
    return pd.DataFrame(
        {
            "curve_id": result.scan.target_curve_ids,
            "selected_inference_components": pd.array(
                [
                    pd.NA if np.isnan(value) else int(value)
                    for value in selected
                ],
                dtype="Int64",
            ),
            "selected_center": result.selected_centers,
            "selected_width": result.selected_widths,
            "selected_lower": result.selected_lower,
            "selected_upper": result.selected_upper,
        }
    )

eyetrajectoriespy.plot_fpca_wild_bootstrap_truncation_scan

plot_fpca_wild_bootstrap_truncation_scan(result, *, target=0, metric='width', ax=None)

Plot interval width or center across candidate wild-bootstrap truncations.

Source code in src/eyetrajectoriespy/plotting.py
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
def plot_fpca_wild_bootstrap_truncation_scan(
    result,
    *,
    target=0,
    metric="width",
    ax=None,
):
    """Plot interval width or center across candidate wild-bootstrap truncations."""

    if isinstance(result, FPCAWildBootstrapTruncationSelectionResult):
        scan = result.scan
        selection = result
    elif isinstance(result, FPCAWildBootstrapTruncationScanResult):
        scan = result
        selection = None
    else:
        raise TypeError(
            "result must be a wild-bootstrap truncation scan or selection result"
        )

    if isinstance(target, str):
        if target not in scan.target_curve_ids:
            raise ValueError(f"unknown target curve_id {target!r}")
        target_index = scan.target_curve_ids.index(target)
    elif isinstance(target, bool) or not isinstance(target, (int, np.integer)):
        raise TypeError("target must be an integer index or curve_id string")
    else:
        target_index = int(target)
        if target_index < 0 or target_index >= scan.n_targets:
            raise IndexError("target index is outside the scan")

    if metric not in {"width", "center"}:
        raise ValueError("metric must be 'width' or 'center'")
    if ax is None:
        _, ax = plt.subplots()

    values = (
        scan.widths[:, target_index]
        if metric == "width"
        else scan.centers[:, target_index]
    )
    x = np.asarray(scan.candidate_components, dtype=int)
    ax.plot(x, values, marker="o")
    if selection is not None:
        selected = selection.selected_components[target_index]
        if np.isfinite(selected):
            ax.axvline(
                int(selected),
                linestyle="--",
                label=f"selected h={int(selected)}",
            )
            ax.legend()
    ax.set_xlabel("Inference truncation h")
    ax.set_ylabel("Interval width" if metric == "width" else "Interval center")
    ax.set_title(
        f"Wild-bootstrap truncation scan: {scan.target_curve_ids[target_index]}"
    )
    return ax

eyetrajectoriespy.fpca_wild_bootstrap_truncation_reporting_text

fpca_wild_bootstrap_truncation_reporting_text(result: FPCAWildBootstrapTruncationSelectionResult, *, digits: int = 3) -> str

Generate reporting text for stabilized-volatility truncation selection.

Source code in src/eyetrajectoriespy/reporting.py
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
def fpca_wild_bootstrap_truncation_reporting_text(
    result: FPCAWildBootstrapTruncationSelectionResult,
    *,
    digits: int = 3,
) -> str:
    """Generate reporting text for stabilized-volatility truncation selection."""

    if not isinstance(result, FPCAWildBootstrapTruncationSelectionResult):
        raise TypeError(
            "result must be an FPCAWildBootstrapTruncationSelectionResult"
        )
    selected = result.selected_components
    n_selected = int(np.sum(np.isfinite(selected)))
    if n_selected:
        selected_values = selected[np.isfinite(selected)].astype(int)
        selection_summary = (
            f"Selected h ranged from {selected_values.min()} to "
            f"{selected_values.max()} across {n_selected} target(s)."
        )
    else:
        selection_summary = "No target received a qualifying h."

    return (
        "Wild-bootstrap inference truncation was evaluated by a shared-multiplier "
        "stabilized-volatility scan over consecutive h values "
        f"{result.scan.candidate_components}. Residual estimation and the "
        f"bootstrap pseudo-truth used k=g={result.scan.residual_components}. "
        f"A transition was width-stable when its absolute width change was <= "
        f"{result.width_threshold:.{digits}f} and center-stable when its absolute "
        f"center change was <= {result.center_threshold:.{digits}f}; both "
        f"conditions were required. The paper run parameter was r="
        f"{result.stability_run}, requiring {result.stability_run + 1} "
        "consecutive stable transitions, and the earliest qualifying h was "
        f"selected separately for each target. {selection_summary} Thresholds "
        "were analyst supplied in scalar-outcome units; the package imposed no "
        "0.01 default and did not silently substitute the largest candidate when "
        "stability was absent."
    )

Heteroscedastic Gaussian FPCR wild-bootstrap projection inference

eyetrajectoriespy.FPCAWildBootstrapProjectionResult dataclass

Studentized wild-bootstrap inference for centered Gaussian FPCR projections.

Source code in src/eyetrajectoriespy/types.py
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
@dataclass(frozen=True)
class FPCAWildBootstrapProjectionResult:
    """Studentized wild-bootstrap inference for centered Gaussian FPCR projections."""

    reference_fpca: FPCAResult
    target_curve_ids: tuple[str, ...]
    reference_projection: np.ndarray
    pseudo_truth_projection: np.ndarray
    reference_se: np.ndarray
    bootstrap_projections: np.ndarray
    bootstrap_se: np.ndarray
    studentized_roots: np.ndarray
    critical_values: np.ndarray
    lower: np.ndarray
    upper: np.ndarray
    residuals: np.ndarray
    confidence_level: float
    residual_components: int
    inference_components: int
    scaling: str
    multiplier: str
    target_source: str
    independent_unit_column: str | None
    random_state: int | None
    provenance: Mapping[str, Any] = field(default_factory=dict)

    @property
    def n_bootstrap(self) -> int:
        return self.bootstrap_projections.shape[0]

    @property
    def n_targets(self) -> int:
        return self.bootstrap_projections.shape[1]

eyetrajectoriespy.wild_bootstrap_fpca_projection

wild_bootstrap_fpca_projection(trajectories: TrajectorySet, outcome: ndarray | Series, *, targets: TrajectorySet | None = None, n_bootstrap: int = 1000, residual_components: int = 2, inference_components: int = 3, scaling: str = 'none', multiplier: str = 'normal', confidence_level: float = 0.95, independent_unit_column: str | None = None, random_state: int | None = 0) -> FPCAWildBootstrapProjectionResult

Studentized wild-bootstrap intervals for centered Gaussian FPCR projections.

The functional regressors and their FPCA basis stay fixed. Residuals are estimated with residual_components=k. The bootstrap pseudo-truth uses the same truncation g=k. Inference uses inference_components=h and requires h greater than or equal to k.

Each pseudo-response is the k-component fitted response plus a mean-zero, variance-one wild multiplier times the k-component residual. Bootstrap projection roots are studentized with a heteroscedastic scale recomputed from the pseudo-fit residuals.

The estimand is the centered functional projection for each fixed target, relative to the training functional mean. It is not a future-outcome prediction interval and is not a clustered or repeated-participant bootstrap.

Source code in src/eyetrajectoriespy/wild_regression.py
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
def wild_bootstrap_fpca_projection(
    trajectories: TrajectorySet,
    outcome: np.ndarray | pd.Series,
    *,
    targets: TrajectorySet | None = None,
    n_bootstrap: int = 1000,
    residual_components: int = 2,
    inference_components: int = 3,
    scaling: str = "none",
    multiplier: str = "normal",
    confidence_level: float = 0.95,
    independent_unit_column: str | None = None,
    random_state: int | None = 0,
) -> FPCAWildBootstrapProjectionResult:
    """Studentized wild-bootstrap intervals for centered Gaussian FPCR projections.

    The functional regressors and their FPCA basis stay fixed. Residuals are
    estimated with residual_components=k. The bootstrap pseudo-truth uses the
    same truncation g=k. Inference uses inference_components=h and requires
    h greater than or equal to k.

    Each pseudo-response is the k-component fitted response plus a mean-zero,
    variance-one wild multiplier times the k-component residual. Bootstrap
    projection roots are studentized with a heteroscedastic scale recomputed
    from the pseudo-fit residuals.

    The estimand is the centered functional projection for each fixed target,
    relative to the training functional mean. It is not a future-outcome
    prediction interval and is not a clustered or repeated-participant bootstrap.
    """

    _validate_complete_finite(trajectories, name="training trajectories")
    y = np.asarray(outcome, dtype=float)
    if y.shape != (trajectories.n_curves,):
        raise ValueError(
            "outcome must contain exactly one value per training trajectory"
        )
    if not np.all(np.isfinite(y)):
        raise ValueError("outcome must contain only finite values")

    if targets is None:
        target_set = trajectories
        target_source = "training"
    else:
        target_set = targets
        target_source = "external"
        _validate_targets(trajectories, target_set)

    if isinstance(n_bootstrap, bool) or not isinstance(
        n_bootstrap,
        (int, np.integer),
    ):
        raise TypeError("n_bootstrap must be an integer")
    n_bootstrap = int(n_bootstrap)
    if n_bootstrap < 20:
        raise ValueError("n_bootstrap must be at least 20")

    maximum = min(
        trajectories.n_curves - 1,
        trajectories.n_time * trajectories.n_dimensions,
    )
    residual_components = _validate_component_count(
        residual_components,
        name="residual_components",
        maximum=maximum,
    )
    inference_components = _validate_component_count(
        inference_components,
        name="inference_components",
        maximum=maximum,
    )
    if inference_components < residual_components:
        raise ValueError(
            "inference_components must be greater than or equal to "
            "residual_components"
        )
    if scaling not in {"none", "dimension_sd"}:
        raise ValueError("scaling must be 'none' or 'dimension_sd'")
    if multiplier not in {"normal", "mammen"}:
        raise ValueError("multiplier must be 'normal' or 'mammen'")
    if not 0 < confidence_level < 1:
        raise ValueError("confidence_level must lie in (0, 1)")

    _validate_independent_curve_rows(
        trajectories,
        independent_unit_column=independent_unit_column,
    )

    reference_fpca = _fit_for_trajectories(
        trajectories,
        n_components=inference_components,
        scaling=scaling,
    )
    scores = np.asarray(
        reference_fpca.scores[:, :inference_components],
        dtype=float,
    )
    target_scores = np.asarray(
        transform_fpca(reference_fpca, target_set)[:, :inference_components],
        dtype=float,
    )
    if not np.all(np.isfinite(scores)) or not np.all(np.isfinite(target_scores)):
        raise RuntimeError("FPCA score geometry contains non-finite values")

    params_k, fitted_k, residuals_k = _fixed_score_fit(
        scores,
        y,
        n_components=residual_components,
        context="reference residual-truncation",
    )
    params_h, _, _ = _fixed_score_fit(
        scores,
        y,
        n_components=inference_components,
        context="reference inference-truncation",
    )

    target_k = target_scores[:, :residual_components]
    target_h = target_scores[:, :inference_components]
    pseudo_truth_projection = target_k @ params_k[1:]
    reference_projection = target_h @ params_h[1:]
    reference_se = _heteroscedastic_projection_se(
        scores[:, :inference_components],
        residuals_k,
        target_h,
        context="reference",
    )

    rng = np.random.default_rng(random_state)
    bootstrap_projections = np.empty(
        (n_bootstrap, target_set.n_curves),
        dtype=float,
    )
    bootstrap_se = np.empty_like(bootstrap_projections)
    studentized_roots = np.empty_like(bootstrap_projections)

    root_scale = max(
        1.0,
        float(np.max(np.abs(reference_projection)))
        if reference_projection.size
        else 1.0,
    )
    root_tolerance = 100.0 * np.finfo(float).eps * root_scale

    for bootstrap_index in range(n_bootstrap):
        wild = _draw_wild_multipliers(
            rng,
            n=trajectories.n_curves,
            multiplier=multiplier,
        )
        pseudo_outcome = fitted_k + residuals_k * wild

        params_star_h, _, _ = _fixed_score_fit(
            scores,
            pseudo_outcome,
            n_components=inference_components,
            context=f"wild bootstrap replicate {bootstrap_index} inference-truncation",
        )
        _, _, residuals_star_k = _fixed_score_fit(
            scores,
            pseudo_outcome,
            n_components=residual_components,
            context=f"wild bootstrap replicate {bootstrap_index} residual-truncation",
        )

        projection_star = target_h @ params_star_h[1:]
        se_star = _heteroscedastic_projection_se(
            scores[:, :inference_components],
            residuals_star_k,
            target_h,
            context=f"wild bootstrap replicate {bootstrap_index}",
        )
        root = projection_star - pseudo_truth_projection

        statistic = np.zeros_like(root)
        se_scale = max(
            1.0,
            float(np.max(se_star)) if se_star.size else 1.0,
        )
        positive = se_star > np.finfo(float).eps * se_scale
        np.divide(root, se_star, out=statistic, where=positive)
        degenerate = (~positive) & (np.abs(root) > root_tolerance)
        if np.any(degenerate):
            bad = np.flatnonzero(degenerate).tolist()
            raise RuntimeError(
                f"wild bootstrap replicate {bootstrap_index} has zero "
                "bootstrap standard error with non-zero projection root for "
                f"target index/indices {bad[:8]}"
            )

        bootstrap_projections[bootstrap_index] = projection_star
        bootstrap_se[bootstrap_index] = se_star
        studentized_roots[bootstrap_index] = statistic

    critical_values = np.quantile(
        np.abs(studentized_roots),
        confidence_level,
        axis=0,
        method="higher",
    )
    lower = reference_projection - critical_values * reference_se
    upper = reference_projection + critical_values * reference_se

    sqrt5 = np.sqrt(5.0)
    multiplier_contract = (
        {"mean": 0.0, "variance": 1.0}
        if multiplier == "normal"
        else {
            "mean": 0.0,
            "variance": 1.0,
            "negative_support": -(sqrt5 - 1.0) / 2.0,
            "positive_support": (sqrt5 + 1.0) / 2.0,
            "negative_probability": (sqrt5 + 1.0) / (2.0 * sqrt5),
        }
    )

    return FPCAWildBootstrapProjectionResult(
        reference_fpca=reference_fpca,
        target_curve_ids=target_set.curve_ids,
        reference_projection=np.asarray(reference_projection, dtype=float),
        pseudo_truth_projection=np.asarray(
            pseudo_truth_projection,
            dtype=float,
        ),
        reference_se=np.asarray(reference_se, dtype=float),
        bootstrap_projections=bootstrap_projections,
        bootstrap_se=bootstrap_se,
        studentized_roots=studentized_roots,
        critical_values=np.asarray(critical_values, dtype=float),
        lower=np.asarray(lower, dtype=float),
        upper=np.asarray(upper, dtype=float),
        residuals=np.asarray(residuals_k, dtype=float),
        confidence_level=float(confidence_level),
        residual_components=residual_components,
        inference_components=inference_components,
        scaling=scaling,
        multiplier=multiplier,
        target_source=target_source,
        independent_unit_column=independent_unit_column,
        random_state=random_state,
        provenance={
            **dict(trajectories.provenance),
            "fpca_wild_bootstrap_projection": {
                "method": (
                    "fixed_regressor_studentized_multiplier_wild_bootstrap"
                ),
                "family": "gaussian",
                "estimand": (
                    "centered_projection_relative_to_training_functional_mean"
                ),
                "n_bootstrap": n_bootstrap,
                "residual_components_k": residual_components,
                "pseudo_truth_components_g": residual_components,
                "inference_components_h": inference_components,
                "g_equals_k": True,
                "h_at_least_g": True,
                "scaling": scaling,
                "multiplier": multiplier,
                "multiplier_contract": multiplier_contract,
                "studentization": (
                    "bootstrap_level_heteroscedastic_score_covariance"
                ),
                "interval": "symmetrized_studentized_targetwise",
                "confidence_level": float(confidence_level),
                "fpca_basis_refit_in_bootstrap": False,
                "functional_regressors_fixed": True,
                "target_curves_fixed": True,
                "independence_assumption": "independent_curve_rows",
                "independent_unit_column": independent_unit_column,
                "clustered_wild_bootstrap": False,
                "future_outcome_prediction_interval": False,
                "simultaneous_across_targets": False,
                "component_selection_uncertainty_included": False,
                "random_state": random_state,
            },
        },
    )

eyetrajectoriespy.fpca_wild_bootstrap_projection_frame

fpca_wild_bootstrap_projection_frame(result: FPCAWildBootstrapProjectionResult) -> pd.DataFrame

Return fixed-target centered-projection wild-bootstrap summaries.

Source code in src/eyetrajectoriespy/wild_regression.py
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
def fpca_wild_bootstrap_projection_frame(
    result: FPCAWildBootstrapProjectionResult,
) -> pd.DataFrame:
    """Return fixed-target centered-projection wild-bootstrap summaries."""

    return pd.DataFrame(
        {
            "curve_id": result.target_curve_ids,
            "reference_projection": result.reference_projection,
            "pseudo_truth_projection": result.pseudo_truth_projection,
            "heteroscedastic_se": result.reference_se,
            "critical_value": result.critical_values,
            "lower": result.lower,
            "upper": result.upper,
        }
    )

eyetrajectoriespy.plot_fpca_wild_bootstrap_projection

plot_fpca_wild_bootstrap_projection(result: FPCAWildBootstrapProjectionResult, *, max_targets: int = 30, ax=None)

Plot target-wise studentized wild-bootstrap FPCR projection intervals.

Source code in src/eyetrajectoriespy/plotting.py
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
def plot_fpca_wild_bootstrap_projection(
    result: FPCAWildBootstrapProjectionResult,
    *,
    max_targets: int = 30,
    ax=None,
):
    """Plot target-wise studentized wild-bootstrap FPCR projection intervals."""

    if isinstance(max_targets, bool) or not isinstance(max_targets, (int, np.integer)):
        raise TypeError("max_targets must be an integer")
    if max_targets < 1:
        raise ValueError("max_targets must be positive")
    if ax is None:
        _, ax = plt.subplots()

    n = min(max_targets, result.n_targets)
    x = np.arange(n)
    center = result.reference_projection[:n]
    lower = result.lower[:n]
    upper = result.upper[:n]
    yerr = np.vstack((center - lower, upper - center))
    ax.errorbar(
        x,
        center,
        yerr=yerr,
        marker="o",
        linestyle="none",
        capsize=3,
        label="studentized wild-bootstrap interval",
    )
    ax.axhline(0.0, linestyle="--")
    ax.set_xticks(x)
    ax.set_xticklabels(result.target_curve_ids[:n], rotation=90)
    ax.set_xlabel("Fixed target trajectory")
    ax.set_ylabel("Centered FPCR projection")
    ax.set_title("Heteroscedastic Gaussian FPCR projection inference")
    ax.legend()
    return ax

eyetrajectoriespy.fpca_wild_bootstrap_projection_reporting_text

fpca_wild_bootstrap_projection_reporting_text(result: FPCAWildBootstrapProjectionResult, *, digits: int = 3) -> str

Generate reporting text for heteroscedastic FPCR wild-bootstrap intervals.

Source code in src/eyetrajectoriespy/reporting.py
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
def fpca_wild_bootstrap_projection_reporting_text(
    result: FPCAWildBootstrapProjectionResult,
    *,
    digits: int = 3,
) -> str:
    """Generate reporting text for heteroscedastic FPCR wild-bootstrap intervals."""

    median_width = float(np.median(result.upper - result.lower))
    median_se = float(np.median(result.reference_se))
    unit_note = (
        f" Independence was declared by unique values of "
        f"{result.independent_unit_column!r}."
        if result.independent_unit_column is not None
        else " Curve rows were assumed to be independent sampling units."
    )
    return (
        f"Centered Gaussian FPCR projections were evaluated with "
        f"{result.n_bootstrap} fixed-regressor multiplier wild-bootstrap "
        f"replicates using {result.multiplier!r} multipliers. Residuals and the "
        f"bootstrap pseudo-truth used k=g={result.residual_components} FPCs, "
        f"while target inference used h={result.inference_components} FPCs. "
        "Each bootstrap root was studentized with a bootstrap-level "
        "heteroscedastic score-covariance scale. "
        f"The {100 * result.confidence_level:.1f}% target-wise symmetrized "
        f"intervals had median reference SE={median_se:.{digits}f} and median "
        f"width={median_width:.{digits}f}.{unit_note} The estimand is the "
        "centered projection relative to the training functional mean; these "
        "are not future-outcome prediction intervals, not clustered wild "
        "bootstrap intervals, and not simultaneous across targets."
    )

Simultaneous fixed-target Gaussian FPCR wild-bootstrap inference

eyetrajectoriespy.FPCAWildBootstrapSimultaneousResult dataclass

Familywise simultaneous inference across fixed FPCR target projections.

Source code in src/eyetrajectoriespy/types.py
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
@dataclass(frozen=True)
class FPCAWildBootstrapSimultaneousResult:
    """Familywise simultaneous inference across fixed FPCR target projections."""

    projection_result: FPCAWildBootstrapProjectionResult
    targetwise_critical_values: np.ndarray
    critical_value: float
    max_statistics: np.ndarray
    lower: np.ndarray
    upper: np.ndarray
    confidence_level: float
    provenance: Mapping[str, Any] = field(default_factory=dict)

    @property
    def n_bootstrap(self) -> int:
        return self.projection_result.n_bootstrap

    @property
    def n_targets(self) -> int:
        return self.projection_result.n_targets

eyetrajectoriespy.fpca_wild_bootstrap_projection_simultaneous_interval

fpca_wild_bootstrap_projection_simultaneous_interval(result: FPCAWildBootstrapProjectionResult, *, confidence_level: float | None = None) -> FPCAWildBootstrapSimultaneousResult

Calibrate one max-|t| critical value across all fixed target projections.

The function is a post-calibration layer. It reuses the studentized roots produced by :func:wild_bootstrap_fpca_projection and does not rerun FPCA, score regression, residual estimation, or wild multiplier generation.

The simultaneous family is exactly the complete set of fixed targets stored in result. To define a different family, create the base wild-bootstrap result with that target set before calling this function.

Source code in src/eyetrajectoriespy/wild_simultaneous.py
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
def fpca_wild_bootstrap_projection_simultaneous_interval(
    result: FPCAWildBootstrapProjectionResult,
    *,
    confidence_level: float | None = None,
) -> FPCAWildBootstrapSimultaneousResult:
    """Calibrate one max-|t| critical value across all fixed target projections.

    The function is a post-calibration layer. It reuses the studentized roots
    produced by :func:`wild_bootstrap_fpca_projection` and does not rerun FPCA,
    score regression, residual estimation, or wild multiplier generation.

    The simultaneous family is exactly the complete set of fixed targets stored
    in ``result``. To define a different family, create the base wild-bootstrap
    result with that target set before calling this function.
    """

    if not isinstance(result, FPCAWildBootstrapProjectionResult):
        raise TypeError("result must be an FPCAWildBootstrapProjectionResult")

    level = result.confidence_level if confidence_level is None else confidence_level
    if isinstance(level, bool) or not isinstance(level, (int, float, np.integer, np.floating)):
        raise TypeError("confidence_level must be numeric")
    level = float(level)
    if not np.isfinite(level) or not 0.0 < level < 1.0:
        raise ValueError("confidence_level must lie in (0, 1)")

    roots = np.asarray(result.studentized_roots, dtype=float)
    expected = (result.n_bootstrap, result.n_targets)
    if roots.shape != expected:
        raise ValueError("studentized_roots shape is inconsistent with the base result")
    if not np.all(np.isfinite(roots)):
        raise ValueError("studentized_roots must contain only finite values")

    reference = np.asarray(result.reference_projection, dtype=float)
    standard_error = np.asarray(result.reference_se, dtype=float)
    if reference.shape != (result.n_targets,) or standard_error.shape != (result.n_targets,):
        raise ValueError("reference projection arrays are inconsistent with the base result")
    if not np.all(np.isfinite(reference)):
        raise ValueError("reference projections must contain only finite values")
    if not np.all(np.isfinite(standard_error)) or np.any(standard_error < 0.0):
        raise ValueError("reference standard errors must be finite and non-negative")

    absolute_roots = np.abs(roots)
    targetwise_critical_values = np.quantile(
        absolute_roots,
        level,
        axis=0,
        method="higher",
    )
    max_statistics = np.max(absolute_roots, axis=1)
    critical_value = float(
        np.quantile(max_statistics, level, method="higher")
    )

    lower = reference - critical_value * standard_error
    upper = reference + critical_value * standard_error

    base_settings = result.provenance.get(
        "fpca_wild_bootstrap_projection", {}
    )
    return FPCAWildBootstrapSimultaneousResult(
        projection_result=result,
        targetwise_critical_values=np.asarray(targetwise_critical_values, dtype=float),
        critical_value=critical_value,
        max_statistics=np.asarray(max_statistics, dtype=float),
        lower=np.asarray(lower, dtype=float),
        upper=np.asarray(upper, dtype=float),
        confidence_level=level,
        provenance={
            **dict(result.provenance),
            "fpca_wild_bootstrap_simultaneous": {
                "method": "postcalibrated_max_abs_studentized_root",
                "family": "gaussian",
                "estimand": "centered_projection_relative_to_training_functional_mean",
                "confidence_level": level,
                "n_targets": result.n_targets,
                "family_definition": "all_fixed_targets_in_base_projection_result",
                "bootstrap_roots_reused": True,
                "bootstrap_rerun": False,
                "studentization": base_settings.get("studentization"),
                "multiplier": result.multiplier,
                "residual_components_k": result.residual_components,
                "pseudo_truth_components_g": result.residual_components,
                "inference_components_h": result.inference_components,
                "simultaneous_across_targets": True,
                "future_outcome_prediction_interval": False,
                "clustered_wild_bootstrap": False,
                "component_selection_uncertainty_included": False,
            },
        },
    )

eyetrajectoriespy.fpca_wild_bootstrap_simultaneous_frame

fpca_wild_bootstrap_simultaneous_frame(result: FPCAWildBootstrapSimultaneousResult) -> pd.DataFrame

Return target-wise and familywise wild-bootstrap interval summaries.

Source code in src/eyetrajectoriespy/wild_simultaneous.py
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
def fpca_wild_bootstrap_simultaneous_frame(
    result: FPCAWildBootstrapSimultaneousResult,
) -> pd.DataFrame:
    """Return target-wise and familywise wild-bootstrap interval summaries."""

    if not isinstance(result, FPCAWildBootstrapSimultaneousResult):
        raise TypeError("result must be an FPCAWildBootstrapSimultaneousResult")
    base = result.projection_result
    targetwise_lower = (
        base.reference_projection - result.targetwise_critical_values * base.reference_se
    )
    targetwise_upper = (
        base.reference_projection + result.targetwise_critical_values * base.reference_se
    )
    return pd.DataFrame(
        {
            "curve_id": base.target_curve_ids,
            "reference_projection": base.reference_projection,
            "heteroscedastic_se": base.reference_se,
            "targetwise_critical_value": result.targetwise_critical_values,
            "familywise_critical_value": np.repeat(result.critical_value, result.n_targets),
            "targetwise_lower": targetwise_lower,
            "targetwise_upper": targetwise_upper,
            "simultaneous_lower": result.lower,
            "simultaneous_upper": result.upper,
        }
    )

eyetrajectoriespy.plot_fpca_wild_bootstrap_simultaneous_interval

plot_fpca_wild_bootstrap_simultaneous_interval(result: FPCAWildBootstrapSimultaneousResult, *, max_targets: int = 30, show_targetwise: bool = True, ax=None)

Plot familywise fixed-target FPCR projection intervals.

Source code in src/eyetrajectoriespy/plotting.py
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
def plot_fpca_wild_bootstrap_simultaneous_interval(
    result: FPCAWildBootstrapSimultaneousResult,
    *,
    max_targets: int = 30,
    show_targetwise: bool = True,
    ax=None,
):
    """Plot familywise fixed-target FPCR projection intervals."""

    if not isinstance(result, FPCAWildBootstrapSimultaneousResult):
        raise TypeError("result must be an FPCAWildBootstrapSimultaneousResult")
    if isinstance(max_targets, bool) or not isinstance(max_targets, (int, np.integer)):
        raise TypeError("max_targets must be an integer")
    if max_targets < 1:
        raise ValueError("max_targets must be positive")
    if not isinstance(show_targetwise, bool):
        raise TypeError("show_targetwise must be boolean")
    if ax is None:
        _, ax = plt.subplots()

    base = result.projection_result
    n = min(max_targets, result.n_targets)
    x = np.arange(n)
    center = base.reference_projection[:n]
    simultaneous_lower = result.lower[:n]
    simultaneous_upper = result.upper[:n]
    simultaneous_yerr = np.vstack(
        (center - simultaneous_lower, simultaneous_upper - center)
    )
    ax.errorbar(
        x,
        center,
        yerr=simultaneous_yerr,
        marker="o",
        linestyle="none",
        capsize=3,
        label="familywise simultaneous interval",
    )
    if show_targetwise:
        point_lower = (
            center - result.targetwise_critical_values[:n] * base.reference_se[:n]
        )
        point_upper = (
            center + result.targetwise_critical_values[:n] * base.reference_se[:n]
        )
        point_yerr = np.vstack((center - point_lower, point_upper - center))
        ax.errorbar(
            x,
            center,
            yerr=point_yerr,
            marker=".",
            linestyle="none",
            capsize=2,
            label="target-wise interval",
        )
    ax.axhline(0.0, linestyle="--")
    ax.set_xticks(x)
    ax.set_xticklabels(base.target_curve_ids[:n], rotation=90)
    ax.set_xlabel("Fixed target trajectory")
    ax.set_ylabel("Centered FPCR projection")
    ax.set_title("Familywise heteroscedastic Gaussian FPCR projection inference")
    ax.legend()
    return ax

eyetrajectoriespy.fpca_wild_bootstrap_simultaneous_reporting_text

fpca_wild_bootstrap_simultaneous_reporting_text(result: FPCAWildBootstrapSimultaneousResult, *, digits: int = 3) -> str

Generate reporting text for simultaneous fixed-target wild-bootstrap inference.

Source code in src/eyetrajectoriespy/reporting.py
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
def fpca_wild_bootstrap_simultaneous_reporting_text(
    result: FPCAWildBootstrapSimultaneousResult,
    *,
    digits: int = 3,
) -> str:
    """Generate reporting text for simultaneous fixed-target wild-bootstrap inference."""

    if not isinstance(result, FPCAWildBootstrapSimultaneousResult):
        raise TypeError("result must be an FPCAWildBootstrapSimultaneousResult")
    base = result.projection_result
    median_width = float(np.median(result.upper - result.lower))
    return (
        f"A {100 * result.confidence_level:.1f}% familywise simultaneous interval "
        f"was calibrated across {result.n_targets} fixed Gaussian FPCR target "
        "projection(s) by taking the bootstrap distribution of the maximum "
        "absolute studentized root across the complete target family. The "
        f"resulting max-|t| critical value was {result.critical_value:.{digits}f} "
        f"and the median simultaneous width was {median_width:.{digits}f}. "
        f"The calibration reused the {base.n_bootstrap} fixed-regressor wild-bootstrap "
        f"replicates from the base result (k=g={base.residual_components}, "
        f"h={base.inference_components}, multiplier={base.multiplier!r}) without "
        "rerunning FPCA or the bootstrap. Simultaneity applies only to the fixed "
        "target trajectories contained in that base result; these intervals are "
        "not future-outcome prediction intervals and do not provide clustered "
        "or repeated-participant wild-bootstrap inference."
    )

Fixed-family Gaussian FPCR wild-bootstrap hypothesis tests

eyetrajectoriespy.FPCAWildBootstrapFamilyTestResult dataclass

Bootstrap maxT tests for a fixed family of Gaussian FPCR projections.

Source code in src/eyetrajectoriespy/types.py
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
@dataclass(frozen=True)
class FPCAWildBootstrapFamilyTestResult:
    """Bootstrap maxT tests for a fixed family of Gaussian FPCR projections."""

    projection_result: FPCAWildBootstrapProjectionResult
    null_values: np.ndarray
    observed_statistics: np.ndarray
    targetwise_p_values: np.ndarray
    adjusted_p_values: np.ndarray
    max_statistics: np.ndarray
    global_statistic: float
    global_p_value: float
    reject_targetwise: np.ndarray
    reject_familywise: np.ndarray
    reject_global: bool
    significance_level: float
    pvalue_correction: str
    provenance: Mapping[str, Any] = field(default_factory=dict)

    @property
    def n_bootstrap(self) -> int:
        return self.projection_result.n_bootstrap

    @property
    def n_targets(self) -> int:
        return self.projection_result.n_targets

    @property
    def minimum_attainable_p(self) -> float:
        if self.pvalue_correction == "plus_one":
            return 1.0 / (self.n_bootstrap + 1.0)
        return 0.0

eyetrajectoriespy.fpca_wild_bootstrap_projection_family_test

fpca_wild_bootstrap_projection_family_test(result: FPCAWildBootstrapProjectionResult, *, null_values=0.0, significance_level: float = 0.05, pvalue_correction: str = 'plus_one') -> FPCAWildBootstrapFamilyTestResult

Test a fixed family of centered FPCR projection null hypotheses.

This post-processing function reuses the studentized wild-bootstrap roots stored in the supplied result. No FPCA fit, score regression, residual calculation, multiplier draw, or bootstrap replicate is rerun.

Marginal tail probabilities use each target's absolute studentized roots. Single-step adjusted probabilities use the replicate-wise maximum absolute studentized root across the complete declared target family.

The default plus-one correction avoids zero Monte Carlo p-values. Strong family-wise error control for arbitrary subsets of null hypotheses is not claimed without additional subset-pivotality conditions.

Source code in src/eyetrajectoriespy/wild_testing.py
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
def fpca_wild_bootstrap_projection_family_test(
    result: FPCAWildBootstrapProjectionResult,
    *,
    null_values=0.0,
    significance_level: float = 0.05,
    pvalue_correction: str = "plus_one",
) -> FPCAWildBootstrapFamilyTestResult:
    """Test a fixed family of centered FPCR projection null hypotheses.

    This post-processing function reuses the studentized wild-bootstrap roots
    stored in the supplied result. No FPCA fit, score regression, residual
    calculation, multiplier draw, or bootstrap replicate is rerun.

    Marginal tail probabilities use each target's absolute studentized roots.
    Single-step adjusted probabilities use the replicate-wise maximum absolute
    studentized root across the complete declared target family.

    The default plus-one correction avoids zero Monte Carlo p-values. Strong
    family-wise error control for arbitrary subsets of null hypotheses is not
    claimed without additional subset-pivotality conditions.
    """

    if not isinstance(result, FPCAWildBootstrapProjectionResult):
        raise TypeError("result must be an FPCAWildBootstrapProjectionResult")
    if isinstance(significance_level, bool) or not isinstance(
        significance_level, (int, float, np.integer, np.floating)
    ):
        raise TypeError("significance_level must be numeric")
    significance_level = float(significance_level)
    if not np.isfinite(significance_level) or not 0.0 < significance_level < 1.0:
        raise ValueError("significance_level must lie in (0, 1)")
    if pvalue_correction not in {"plus_one", "none"}:
        raise ValueError("pvalue_correction must be 'plus_one' or 'none'")

    roots = np.asarray(result.studentized_roots, dtype=float)
    expected = (result.n_bootstrap, result.n_targets)
    if roots.shape != expected:
        raise ValueError("studentized_roots shape is inconsistent with the base result")
    if not np.all(np.isfinite(roots)):
        raise ValueError("studentized_roots must contain only finite values")
    reference = np.asarray(result.reference_projection, dtype=float)
    standard_error = np.asarray(result.reference_se, dtype=float)
    if reference.shape != (result.n_targets,) or standard_error.shape != (result.n_targets,):
        raise ValueError("reference projection arrays are inconsistent with the base result")
    if not np.all(np.isfinite(reference)):
        raise ValueError("reference projections must contain only finite values")
    if not np.all(np.isfinite(standard_error)) or np.any(standard_error < 0.0):
        raise ValueError("reference standard errors must be finite and non-negative")

    null = _validate_null_values(null_values, n_targets=result.n_targets)
    difference = reference - null
    se_scale = max(1.0, float(np.max(standard_error)) if standard_error.size else 1.0)
    positive = standard_error > np.finfo(float).eps * se_scale
    difference_scale = max(
        1.0,
        float(np.max(np.abs(reference))) if reference.size else 1.0,
        float(np.max(np.abs(null))) if null.size else 1.0,
    )
    tolerance = 100.0 * np.finfo(float).eps * difference_scale
    observed = np.zeros_like(difference)
    np.divide(difference, standard_error, out=observed, where=positive)
    degenerate = (~positive) & (np.abs(difference) > tolerance)
    if np.any(degenerate):
        bad = np.flatnonzero(degenerate).tolist()
        raise RuntimeError(
            "zero reference standard error with a non-zero null discrepancy for "
            f"target index/indices {bad[:8]}"
        )

    absolute_roots = np.abs(roots)
    absolute_observed = np.abs(observed)
    max_statistics = np.max(absolute_roots, axis=1)
    global_statistic = float(np.max(absolute_observed))
    targetwise_exceedances = np.sum(absolute_roots >= absolute_observed[None, :], axis=0)
    adjusted_exceedances = np.sum(max_statistics[:, None] >= absolute_observed[None, :], axis=0)
    global_exceedances = int(np.sum(max_statistics >= global_statistic))
    targetwise_p = np.asarray(_bootstrap_tail_probability(
        targetwise_exceedances, n_bootstrap=result.n_bootstrap, correction=pvalue_correction
    ), dtype=float)
    adjusted_p = np.asarray(_bootstrap_tail_probability(
        adjusted_exceedances, n_bootstrap=result.n_bootstrap, correction=pvalue_correction
    ), dtype=float)
    global_p = float(_bootstrap_tail_probability(
        global_exceedances, n_bootstrap=result.n_bootstrap, correction=pvalue_correction
    ))

    base_settings = result.provenance.get("fpca_wild_bootstrap_projection", {})
    minimum_p = 1.0 / (result.n_bootstrap + 1.0) if pvalue_correction == "plus_one" else 0.0
    return FPCAWildBootstrapFamilyTestResult(
        projection_result=result,
        null_values=null,
        observed_statistics=np.asarray(observed, dtype=float),
        targetwise_p_values=targetwise_p,
        adjusted_p_values=adjusted_p,
        max_statistics=np.asarray(max_statistics, dtype=float),
        global_statistic=global_statistic,
        global_p_value=global_p,
        reject_targetwise=np.asarray(targetwise_p <= significance_level, dtype=bool),
        reject_familywise=np.asarray(adjusted_p <= significance_level, dtype=bool),
        reject_global=bool(global_p <= significance_level),
        significance_level=significance_level,
        pvalue_correction=pvalue_correction,
        provenance={
            **dict(result.provenance),
            "fpca_wild_bootstrap_family_test": {
                "method": "single_step_max_abs_studentized_root",
                "family": "gaussian",
                "estimand": "centered_projection_relative_to_training_functional_mean",
                "alternative": "two_sided",
                "global_null": "all_target_projections_equal_supplied_null_values",
                "n_targets": result.n_targets,
                "family_definition": "all_fixed_targets_in_base_projection_result",
                "significance_level": significance_level,
                "pvalue_correction": pvalue_correction,
                "minimum_attainable_p": minimum_p,
                "bootstrap_roots_reused": True,
                "bootstrap_rerun": False,
                "null_enforced_bootstrap": False,
                "studentization": base_settings.get("studentization"),
                "multiplier": result.multiplier,
                "residual_components_k": result.residual_components,
                "pseudo_truth_components_g": result.residual_components,
                "inference_components_h": result.inference_components,
                "single_step_familywise_adjustment": True,
                "strong_fwer_for_arbitrary_subset_nulls_claimed": False,
                "subset_pivotality_assumed_by_package": False,
                "future_outcome_test": False,
                "clustered_wild_bootstrap": False,
                "component_selection_uncertainty_included": False,
            },
        },
    )

eyetrajectoriespy.fpca_wild_bootstrap_family_test_frame

fpca_wild_bootstrap_family_test_frame(result: FPCAWildBootstrapFamilyTestResult) -> pd.DataFrame

Return target-level fixed-family wild-bootstrap test summaries.

Source code in src/eyetrajectoriespy/wild_testing.py
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
def fpca_wild_bootstrap_family_test_frame(
    result: FPCAWildBootstrapFamilyTestResult,
) -> pd.DataFrame:
    """Return target-level fixed-family wild-bootstrap test summaries."""
    if not isinstance(result, FPCAWildBootstrapFamilyTestResult):
        raise TypeError("result must be an FPCAWildBootstrapFamilyTestResult")
    base = result.projection_result
    return pd.DataFrame({
        "curve_id": base.target_curve_ids,
        "reference_projection": base.reference_projection,
        "null_value": result.null_values,
        "heteroscedastic_se": base.reference_se,
        "observed_statistic": result.observed_statistics,
        "targetwise_p_value": result.targetwise_p_values,
        "adjusted_p_value": result.adjusted_p_values,
        "reject_targetwise": result.reject_targetwise,
        "reject_familywise": result.reject_familywise,
        "global_statistic": np.repeat(result.global_statistic, result.n_targets),
        "global_p_value": np.repeat(result.global_p_value, result.n_targets),
    })

eyetrajectoriespy.plot_fpca_wild_bootstrap_family_test

plot_fpca_wild_bootstrap_family_test(result: FPCAWildBootstrapFamilyTestResult, *, max_targets: int = 30, show_targetwise: bool = True, ax=None)

Plot target-wise and single-step adjusted bootstrap p-values.

Source code in src/eyetrajectoriespy/plotting.py
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
def plot_fpca_wild_bootstrap_family_test(
    result: FPCAWildBootstrapFamilyTestResult,
    *,
    max_targets: int = 30,
    show_targetwise: bool = True,
    ax=None,
):
    """Plot target-wise and single-step adjusted bootstrap p-values."""

    if not isinstance(result, FPCAWildBootstrapFamilyTestResult):
        raise TypeError("result must be an FPCAWildBootstrapFamilyTestResult")
    if isinstance(max_targets, bool) or not isinstance(max_targets, (int, np.integer)):
        raise TypeError("max_targets must be an integer")
    if max_targets < 1:
        raise ValueError("max_targets must be positive")
    if not isinstance(show_targetwise, bool):
        raise TypeError("show_targetwise must be boolean")
    if ax is None:
        _, ax = plt.subplots()

    n = min(max_targets, result.n_targets)
    x = np.arange(n)
    ax.scatter(x, result.adjusted_p_values[:n], marker="o", label="single-step maxT adjusted")
    if show_targetwise:
        ax.scatter(x, result.targetwise_p_values[:n], marker="x", label="target-wise")
    ax.axhline(
        result.significance_level,
        linestyle="--",
        label=f"alpha={result.significance_level:g}",
    )
    ax.set_xticks(x)
    ax.set_xticklabels(result.projection_result.target_curve_ids[:n], rotation=90)
    ax.set_ylim(-0.02, 1.02)
    ax.set_xlabel("Fixed target trajectory")
    ax.set_ylabel("Bootstrap tail probability")
    ax.set_title("Fixed-family heteroscedastic Gaussian FPCR tests")
    ax.legend()
    return ax

eyetrajectoriespy.fpca_wild_bootstrap_family_test_reporting_text

fpca_wild_bootstrap_family_test_reporting_text(result: FPCAWildBootstrapFamilyTestResult, *, digits: int = 3) -> str

Generate reporting text for fixed-family wild-bootstrap tests.

Source code in src/eyetrajectoriespy/reporting.py
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
def fpca_wild_bootstrap_family_test_reporting_text(
    result: FPCAWildBootstrapFamilyTestResult,
    *,
    digits: int = 3,
) -> str:
    """Generate reporting text for fixed-family wild-bootstrap tests."""

    if not isinstance(result, FPCAWildBootstrapFamilyTestResult):
        raise TypeError("result must be an FPCAWildBootstrapFamilyTestResult")
    base = result.projection_result
    rejected = int(np.sum(result.reject_familywise))
    correction = (
        "(exceedances + 1)/(B + 1)"
        if result.pvalue_correction == "plus_one"
        else "empirical exceedance proportion"
    )
    return (
        f"Two-sided fixed-target Gaussian FPCR projection hypotheses were evaluated "
        f"for a declared family of {result.n_targets} target(s) using the exact "
        f"studentized roots retained from {base.n_bootstrap} heteroscedastic "
        f"wild-bootstrap replicates. Target-wise bootstrap tail probabilities and "
        f"single-step max-|t| adjusted values used {correction}. At alpha="
        f"{result.significance_level:.{digits}f}, {rejected} target(s) were rejected "
        f"after familywise adjustment; the complete-family global max statistic was "
        f"{result.global_statistic:.{digits}f} with bootstrap p="
        f"{result.global_p_value:.{digits}f}. No second bootstrap was run. "
        "The resampling distribution was not generated under an explicitly imposed "
        "null, and strong family-wise error control for arbitrary subsets of null "
        "hypotheses is not claimed without additional subset-pivotality conditions. "
        "The tests concern fixed centered projections, not future observed outcomes "
        "or clustered/repeated-participant inference."
    )

Wild-bootstrap finite Monte Carlo precision diagnostics

eyetrajectoriespy.FPCAWildBootstrapMonteCarloDiagnosticResult dataclass

Monte Carlo precision diagnostics for a fixed-family wild-bootstrap test.

Source code in src/eyetrajectoriespy/types.py
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
@dataclass(frozen=True)
class FPCAWildBootstrapMonteCarloDiagnosticResult:
    """Monte Carlo precision diagnostics for a fixed-family wild-bootstrap test."""

    family_test_result: FPCAWildBootstrapFamilyTestResult
    confidence_level: float
    targetwise_exceedances: np.ndarray
    adjusted_exceedances: np.ndarray
    global_exceedances: int
    targetwise_tail_probabilities: np.ndarray
    adjusted_tail_probabilities: np.ndarray
    global_tail_probability: float
    targetwise_mcse: np.ndarray
    adjusted_mcse: np.ndarray
    global_mcse: float
    targetwise_interval_lower: np.ndarray
    targetwise_interval_upper: np.ndarray
    adjusted_interval_lower: np.ndarray
    adjusted_interval_upper: np.ndarray
    global_interval_lower: float
    global_interval_upper: float
    targetwise_decision_stable: np.ndarray
    adjusted_decision_stable: np.ndarray
    global_decision_stable: bool
    provenance: Mapping[str, Any] = field(default_factory=dict)

    @property
    def n_bootstrap(self) -> int:
        return self.family_test_result.n_bootstrap

    @property
    def n_targets(self) -> int:
        return self.family_test_result.n_targets

    @property
    def significance_level(self) -> float:
        return self.family_test_result.significance_level

eyetrajectoriespy.fpca_wild_bootstrap_family_test_monte_carlo_diagnostics

fpca_wild_bootstrap_family_test_monte_carlo_diagnostics(result: FPCAWildBootstrapFamilyTestResult, *, confidence_level: float = 0.95) -> FPCAWildBootstrapMonteCarloDiagnosticResult

Quantify finite-bootstrap Monte Carlo precision for family-test tail counts.

The diagnostic reuses the already retained bootstrap roots and does not redraw multipliers or refit any model. Exact Clopper-Pearson intervals describe uncertainty in the binomial exceedance probabilities induced by a finite number of bootstrap replicates. They are Monte Carlo diagnostics, not confidence intervals for the scientific estimand and not stronger family-wise error guarantees.

Decision-stability flags are conservative diagnostics: a reported rejection is stable only when the complete Monte Carlo interval lies below alpha; a reported non-rejection is stable only when the complete interval lies above alpha. A False flag therefore means that the finite-resample precision is insufficient to separate the tail probability from alpha at the requested diagnostic confidence level. It does not reverse the original test result.

Source code in src/eyetrajectoriespy/wild_testing.py
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
def fpca_wild_bootstrap_family_test_monte_carlo_diagnostics(
    result: FPCAWildBootstrapFamilyTestResult,
    *,
    confidence_level: float = 0.95,
) -> FPCAWildBootstrapMonteCarloDiagnosticResult:
    """Quantify finite-bootstrap Monte Carlo precision for family-test tail counts.

    The diagnostic reuses the already retained bootstrap roots and does not
    redraw multipliers or refit any model. Exact Clopper-Pearson intervals
    describe uncertainty in the binomial exceedance probabilities induced by
    a finite number of bootstrap replicates. They are Monte Carlo diagnostics,
    not confidence intervals for the scientific estimand and not stronger
    family-wise error guarantees.

    Decision-stability flags are conservative diagnostics: a reported rejection
    is stable only when the complete Monte Carlo interval lies below alpha; a
    reported non-rejection is stable only when the complete interval lies above
    alpha. A False flag therefore means that the finite-resample precision is
    insufficient to separate the tail probability from alpha at the requested
    diagnostic confidence level. It does not reverse the original test result.
    """

    if not isinstance(result, FPCAWildBootstrapFamilyTestResult):
        raise TypeError("result must be an FPCAWildBootstrapFamilyTestResult")
    if isinstance(confidence_level, bool) or not isinstance(
        confidence_level, (int, float, np.integer, np.floating)
    ):
        raise TypeError("confidence_level must be numeric")
    confidence_level = float(confidence_level)
    if not np.isfinite(confidence_level) or not 0.0 < confidence_level < 1.0:
        raise ValueError("confidence_level must lie in (0, 1)")

    base = result.projection_result
    roots = np.asarray(base.studentized_roots, dtype=float)
    expected = (result.n_bootstrap, result.n_targets)
    if roots.shape != expected:
        raise ValueError("studentized_roots shape is inconsistent with the family test")
    if not np.all(np.isfinite(roots)):
        raise ValueError("studentized_roots must contain only finite values")

    observed = np.asarray(result.observed_statistics, dtype=float)
    if observed.shape != (result.n_targets,) or not np.all(np.isfinite(observed)):
        raise ValueError("observed_statistics are inconsistent with the family test")

    maximum = np.asarray(result.max_statistics, dtype=float)
    if maximum.shape != (result.n_bootstrap,) or not np.all(np.isfinite(maximum)):
        raise ValueError("max_statistics are inconsistent with the family test")

    global_statistic = float(result.global_statistic)
    if not np.isfinite(global_statistic):
        raise ValueError("global_statistic must be finite")

    targetwise_reject = np.asarray(result.reject_targetwise, dtype=bool)
    adjusted_reject = np.asarray(result.reject_familywise, dtype=bool)
    if targetwise_reject.shape != (result.n_targets,):
        raise ValueError("reject_targetwise shape is inconsistent with the family test")
    if adjusted_reject.shape != (result.n_targets,):
        raise ValueError("reject_familywise shape is inconsistent with the family test")

    absolute_roots = np.abs(roots)
    absolute_observed = np.abs(observed)
    targetwise_exceedances = np.sum(
        absolute_roots >= absolute_observed[None, :],
        axis=0,
    ).astype(int)
    adjusted_exceedances = np.sum(
        maximum[:, None] >= absolute_observed[None, :],
        axis=0,
    ).astype(int)
    global_exceedances = int(np.sum(maximum >= global_statistic))

    n_bootstrap = result.n_bootstrap
    targetwise_tail = targetwise_exceedances.astype(float) / float(n_bootstrap)
    adjusted_tail = adjusted_exceedances.astype(float) / float(n_bootstrap)
    global_tail = global_exceedances / float(n_bootstrap)

    targetwise_mcse = np.sqrt(
        targetwise_tail * (1.0 - targetwise_tail) / float(n_bootstrap)
    )
    adjusted_mcse = np.sqrt(
        adjusted_tail * (1.0 - adjusted_tail) / float(n_bootstrap)
    )
    global_mcse = float(np.sqrt(global_tail * (1.0 - global_tail) / float(n_bootstrap)))

    targetwise_lower, targetwise_upper = _exact_binomial_interval(
        targetwise_exceedances,
        n_bootstrap=n_bootstrap,
        confidence_level=confidence_level,
    )
    adjusted_lower, adjusted_upper = _exact_binomial_interval(
        adjusted_exceedances,
        n_bootstrap=n_bootstrap,
        confidence_level=confidence_level,
    )
    global_lower_array, global_upper_array = _exact_binomial_interval(
        np.asarray([global_exceedances], dtype=int),
        n_bootstrap=n_bootstrap,
        confidence_level=confidence_level,
    )
    global_lower = float(global_lower_array[0])
    global_upper = float(global_upper_array[0])

    targetwise_stable = _decision_stability(
        targetwise_lower,
        targetwise_upper,
        rejected=targetwise_reject,
        significance_level=result.significance_level,
    )
    adjusted_stable = _decision_stability(
        adjusted_lower,
        adjusted_upper,
        rejected=adjusted_reject,
        significance_level=result.significance_level,
    )
    global_stable = bool(
        _decision_stability(
            np.asarray([global_lower]),
            np.asarray([global_upper]),
            rejected=np.asarray([result.reject_global]),
            significance_level=result.significance_level,
        )[0]
    )

    return FPCAWildBootstrapMonteCarloDiagnosticResult(
        family_test_result=result,
        confidence_level=confidence_level,
        targetwise_exceedances=targetwise_exceedances,
        adjusted_exceedances=adjusted_exceedances,
        global_exceedances=global_exceedances,
        targetwise_tail_probabilities=targetwise_tail,
        adjusted_tail_probabilities=adjusted_tail,
        global_tail_probability=global_tail,
        targetwise_mcse=targetwise_mcse,
        adjusted_mcse=adjusted_mcse,
        global_mcse=global_mcse,
        targetwise_interval_lower=targetwise_lower,
        targetwise_interval_upper=targetwise_upper,
        adjusted_interval_lower=adjusted_lower,
        adjusted_interval_upper=adjusted_upper,
        global_interval_lower=global_lower,
        global_interval_upper=global_upper,
        targetwise_decision_stable=np.asarray(targetwise_stable, dtype=bool),
        adjusted_decision_stable=np.asarray(adjusted_stable, dtype=bool),
        global_decision_stable=global_stable,
        provenance={
            **dict(result.provenance),
            "fpca_wild_bootstrap_monte_carlo_diagnostics": {
                "method": "binomial_monte_carlo_precision_diagnostic",
                "interval_method": "clopper_pearson_exact_binomial",
                "confidence_level": confidence_level,
                "n_bootstrap": n_bootstrap,
                "exceedance_model": (
                    "binomial_conditional_on_observed_statistic_and_bootstrap_design"
                ),
                "tail_probability_estimator": "exceedances_over_B",
                "monte_carlo_standard_error": "sqrt(qhat*(1-qhat)/B)",
                "reported_test_pvalue_correction": result.pvalue_correction,
                "reported_test_minimum_attainable_p": result.minimum_attainable_p,
                "decision_stability_rule": (
                    "complete_monte_carlo_interval_on_reported_decision_side_of_alpha"
                ),
                "changes_reported_test_decisions": False,
                "additional_bootstrap_draws": False,
                "bootstrap_roots_reused": True,
                "scientific_sampling_uncertainty_quantified": False,
                "monte_carlo_sampling_uncertainty_quantified": True,
                "strong_fwer_claim_added": False,
                "clustered_wild_bootstrap": False,
                "component_selection_uncertainty_included": False,
            },
        },
    )

eyetrajectoriespy.fpca_wild_bootstrap_monte_carlo_diagnostic_frame

fpca_wild_bootstrap_monte_carlo_diagnostic_frame(result: FPCAWildBootstrapMonteCarloDiagnosticResult) -> pd.DataFrame

Return target-level Monte Carlo precision diagnostics.

Source code in src/eyetrajectoriespy/wild_testing.py
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
def fpca_wild_bootstrap_monte_carlo_diagnostic_frame(
    result: FPCAWildBootstrapMonteCarloDiagnosticResult,
) -> pd.DataFrame:
    """Return target-level Monte Carlo precision diagnostics."""

    if not isinstance(result, FPCAWildBootstrapMonteCarloDiagnosticResult):
        raise TypeError("result must be an FPCAWildBootstrapMonteCarloDiagnosticResult")
    family = result.family_test_result
    base = family.projection_result

    targetwise_status = np.where(
        result.targetwise_decision_stable,
        np.where(family.reject_targetwise, "stable_reject", "stable_non_reject"),
        "monte_carlo_sensitive",
    )
    adjusted_status = np.where(
        result.adjusted_decision_stable,
        np.where(family.reject_familywise, "stable_reject", "stable_non_reject"),
        "monte_carlo_sensitive",
    )

    return pd.DataFrame(
        {
            "curve_id": base.target_curve_ids,
            "targetwise_exceedances": result.targetwise_exceedances,
            "targetwise_tail_probability": result.targetwise_tail_probabilities,
            "targetwise_mcse": result.targetwise_mcse,
            "targetwise_mc_lower": result.targetwise_interval_lower,
            "targetwise_mc_upper": result.targetwise_interval_upper,
            "targetwise_precision_status": targetwise_status,
            "adjusted_exceedances": result.adjusted_exceedances,
            "adjusted_tail_probability": result.adjusted_tail_probabilities,
            "adjusted_mcse": result.adjusted_mcse,
            "adjusted_mc_lower": result.adjusted_interval_lower,
            "adjusted_mc_upper": result.adjusted_interval_upper,
            "adjusted_precision_status": adjusted_status,
            "reported_targetwise_p_value": family.targetwise_p_values,
            "reported_adjusted_p_value": family.adjusted_p_values,
            "alpha": np.repeat(family.significance_level, result.n_targets),
        }
    )

eyetrajectoriespy.plot_fpca_wild_bootstrap_monte_carlo_diagnostics

plot_fpca_wild_bootstrap_monte_carlo_diagnostics(result: FPCAWildBootstrapMonteCarloDiagnosticResult, *, max_targets: int = 30, show_targetwise: bool = False, ax=None)

Plot finite-bootstrap tail estimates with exact binomial intervals.

Source code in src/eyetrajectoriespy/plotting.py
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
def plot_fpca_wild_bootstrap_monte_carlo_diagnostics(
    result: FPCAWildBootstrapMonteCarloDiagnosticResult,
    *,
    max_targets: int = 30,
    show_targetwise: bool = False,
    ax=None,
):
    """Plot finite-bootstrap tail estimates with exact binomial intervals."""

    if not isinstance(result, FPCAWildBootstrapMonteCarloDiagnosticResult):
        raise TypeError("result must be an FPCAWildBootstrapMonteCarloDiagnosticResult")
    if isinstance(max_targets, bool) or not isinstance(max_targets, (int, np.integer)):
        raise TypeError("max_targets must be an integer")
    if max_targets < 1:
        raise ValueError("max_targets must be positive")
    if not isinstance(show_targetwise, bool):
        raise TypeError("show_targetwise must be boolean")
    if ax is None:
        _, ax = plt.subplots()

    n = min(max_targets, result.n_targets)
    x = np.arange(n)
    adjusted = result.adjusted_tail_probabilities[:n]
    adjusted_yerr = np.vstack(
        (
            adjusted - result.adjusted_interval_lower[:n],
            result.adjusted_interval_upper[:n] - adjusted,
        )
    )
    ax.errorbar(
        x,
        adjusted,
        yerr=adjusted_yerr,
        marker="o",
        linestyle="none",
        capsize=3,
        label="maxT tail probability",
    )

    if show_targetwise:
        targetwise = result.targetwise_tail_probabilities[:n]
        targetwise_yerr = np.vstack(
            (
                targetwise - result.targetwise_interval_lower[:n],
                result.targetwise_interval_upper[:n] - targetwise,
            )
        )
        ax.errorbar(
            x,
            targetwise,
            yerr=targetwise_yerr,
            marker="x",
            linestyle="none",
            capsize=3,
            label="target-wise tail probability",
        )

    alpha = result.significance_level
    ax.axhline(alpha, linestyle="--", label=f"alpha={alpha:g}")
    ax.set_xticks(x)
    ax.set_xticklabels(
        result.family_test_result.projection_result.target_curve_ids[:n],
        rotation=90,
    )
    ax.set_ylim(-0.02, 1.02)
    ax.set_xlabel("Fixed target trajectory")
    ax.set_ylabel("Bootstrap exceedance probability")
    ax.set_title("Monte Carlo precision of fixed-family wild-bootstrap tests")
    ax.legend()
    return ax

eyetrajectoriespy.fpca_wild_bootstrap_monte_carlo_reporting_text

fpca_wild_bootstrap_monte_carlo_reporting_text(result: FPCAWildBootstrapMonteCarloDiagnosticResult, *, digits: int = 3) -> str

Generate reporting text for finite-bootstrap Monte Carlo precision.

Source code in src/eyetrajectoriespy/reporting.py
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
def fpca_wild_bootstrap_monte_carlo_reporting_text(
    result: FPCAWildBootstrapMonteCarloDiagnosticResult,
    *,
    digits: int = 3,
) -> str:
    """Generate reporting text for finite-bootstrap Monte Carlo precision."""

    if not isinstance(result, FPCAWildBootstrapMonteCarloDiagnosticResult):
        raise TypeError("result must be an FPCAWildBootstrapMonteCarloDiagnosticResult")
    if isinstance(digits, bool) or not isinstance(digits, (int, np.integer)):
        raise TypeError("digits must be an integer")
    if digits < 0:
        raise ValueError("digits must be non-negative")

    family = result.family_test_result
    sensitive = int(np.sum(~result.adjusted_decision_stable))
    return (
        f"Finite-resample Monte Carlo precision was assessed for the existing "
        f"fixed-family wild-bootstrap test using the same {result.n_bootstrap} "
        f"retained bootstrap replicates; no additional multipliers or model fits "
        f"were generated. Raw exceedance proportions were accompanied by "
        f"{100 * result.confidence_level:.1f}% Clopper-Pearson exact binomial "
        f"intervals and plug-in binomial Monte Carlo standard errors. The global "
        f"max-statistic exceedance proportion was "
        f"{result.global_tail_probability:.{digits}f} "
        f"({result.global_interval_lower:.{digits}f}, "
        f"{result.global_interval_upper:.{digits}f}); "
        f"{sensitive} of {result.n_targets} maxT-adjusted target decision(s) were "
        f"Monte-Carlo-sensitive at alpha={family.significance_level:.{digits}f}. "
        "These diagnostics quantify only finite-bootstrap simulation precision. "
        "They do not replace the reported plus-one/raw test p-values, reverse test "
        "decisions, quantify scientific sampling uncertainty, add subset-pivotality "
        "or strong-FWER guarantees, or address clustered/repeated-participant or "
        "component-selection uncertainty."
    )

Gaussian FPCR future-outcome prediction

eyetrajectoriespy.FPCARegressionPredictionIntervalResult dataclass

Future-outcome predictive distribution for Gaussian FPCR fixed targets.

Source code in src/eyetrajectoriespy/types.py
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
@dataclass(frozen=True)
class FPCARegressionPredictionIntervalResult:
    """Future-outcome predictive distribution for Gaussian FPCR fixed targets."""

    regression_uncertainty: FPCARegressionUncertaintyResult
    centered_residuals: np.ndarray
    sampled_residuals: np.ndarray
    predictive_draws: np.ndarray
    lower: np.ndarray
    median: np.ndarray
    upper: np.ndarray
    predictive_se: np.ndarray
    confidence_level: float
    residual_method: str
    random_state: int | None
    provenance: Mapping[str, Any] = field(default_factory=dict)

    @property
    def n_bootstrap(self) -> int:
        return self.predictive_draws.shape[0]

    @property
    def n_targets(self) -> int:
        return self.predictive_draws.shape[1]

eyetrajectoriespy.fpca_regression_future_prediction_interval

fpca_regression_future_prediction_interval(result: FPCARegressionUncertaintyResult, outcome: ndarray | Series, *, confidence_level: float = 0.95, residual_method: str = 'empirical_centered', random_state: int | None = 0) -> FPCARegressionPredictionIntervalResult

Construct marginal future-outcome prediction intervals for fixed targets.

The function reuses paired-bootstrap conditional-mean predictions stored in the result and adds an independent draw from the centered empirical residual distribution of the full-sample Gaussian FPCR fit.

This is a residual-resampling predictive approximation. It assumes the response residual distribution is exchangeable across target curves and is therefore not heteroscedasticity-robust. Intervals are marginal per target, not simultaneous or joint across multiple targets.

Source code in src/eyetrajectoriespy/regression_inference.py
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
def fpca_regression_future_prediction_interval(
    result: FPCARegressionUncertaintyResult,
    outcome: np.ndarray | pd.Series,
    *,
    confidence_level: float = 0.95,
    residual_method: str = "empirical_centered",
    random_state: int | None = 0,
) -> FPCARegressionPredictionIntervalResult:
    """Construct marginal future-outcome prediction intervals for fixed targets.

    The function reuses paired-bootstrap conditional-mean predictions stored in
    the result and adds an independent draw from the centered empirical residual
    distribution of the full-sample Gaussian FPCR fit.

    This is a residual-resampling predictive approximation. It assumes the
    response residual distribution is exchangeable across target curves and is
    therefore not heteroscedasticity-robust. Intervals are marginal per target,
    not simultaneous or joint across multiple targets.
    """

    if not isinstance(result, FPCARegressionUncertaintyResult):
        raise TypeError(
            "result must be an FPCARegressionUncertaintyResult from "
            "bootstrap_fpca_regression_uncertainty()"
        )
    if result.reference_regression.family != "gaussian":
        raise ValueError("future prediction intervals require a Gaussian FPCR fit")
    if not 0 < confidence_level < 1:
        raise ValueError("confidence_level must lie in (0, 1)")
    if residual_method != "empirical_centered":
        raise ValueError("residual_method must be 'empirical_centered'")

    y = np.asarray(outcome, dtype=float)
    fitted = np.asarray(result.reference_regression.predictions, dtype=float)
    if y.shape != fitted.shape:
        raise ValueError(
            "outcome must contain exactly one value per trajectory in the "
            "reference FPCR training fit"
        )
    if y.ndim != 1 or y.size < 2:
        raise ValueError("at least two training outcomes are required")
    if not np.all(np.isfinite(y)) or not np.all(np.isfinite(fitted)):
        raise ValueError("outcome and reference fitted values must be finite")

    bootstrap_mean = np.asarray(result.bootstrap_mean_predictions, dtype=float)
    if bootstrap_mean.ndim != 2:
        raise ValueError("bootstrap mean predictions must be a two-dimensional array")
    if bootstrap_mean.shape[0] != result.n_bootstrap:
        raise ValueError("bootstrap mean prediction count is inconsistent")
    if bootstrap_mean.shape[1] != len(result.target_curve_ids):
        raise ValueError("bootstrap target prediction count is inconsistent")
    if not np.all(np.isfinite(bootstrap_mean)):
        raise ValueError("bootstrap mean predictions must be finite")

    residuals = y - fitted
    centered_residuals = residuals - float(np.mean(residuals))
    if not np.all(np.isfinite(centered_residuals)):
        raise RuntimeError("centered FPCR residuals contain non-finite values")

    residual_seed = np.random.SeedSequence(random_state).spawn(1)[0]
    rng = np.random.default_rng(residual_seed)
    residual_indices = rng.integers(
        0,
        centered_residuals.size,
        size=bootstrap_mean.shape,
    )
    sampled_residuals = centered_residuals[residual_indices]
    predictive_draws = bootstrap_mean + sampled_residuals

    alpha = (1.0 - float(confidence_level)) / 2.0
    lower = np.quantile(predictive_draws, alpha, axis=0)
    median = np.quantile(predictive_draws, 0.5, axis=0)
    upper = np.quantile(predictive_draws, 1.0 - alpha, axis=0)
    predictive_se = np.std(predictive_draws, axis=0, ddof=1)

    return FPCARegressionPredictionIntervalResult(
        regression_uncertainty=result,
        centered_residuals=np.asarray(centered_residuals, dtype=float),
        sampled_residuals=np.asarray(sampled_residuals, dtype=float),
        predictive_draws=np.asarray(predictive_draws, dtype=float),
        lower=np.asarray(lower, dtype=float),
        median=np.asarray(median, dtype=float),
        upper=np.asarray(upper, dtype=float),
        predictive_se=np.asarray(predictive_se, dtype=float),
        confidence_level=float(confidence_level),
        residual_method=residual_method,
        random_state=random_state,
        provenance={
            **dict(result.provenance),
            "fpca_regression_future_prediction_interval": {
                "method": "paired_bootstrap_mean_plus_independent_centered_empirical_residual",
                "family": "gaussian",
                "confidence_level": float(confidence_level),
                "residual_method": residual_method,
                "residual_source": "full_sample_reference_fpcr_fit",
                "residuals_centered": True,
                "residual_rng_stream": "spawned_domain_separated_stream",
                "residual_exchangeability_assumed": True,
                "heteroscedasticity_robust": False,
                "future_outcome_prediction_interval": True,
                "marginal_per_target": True,
                "simultaneous_across_targets": False,
                "joint_target_distribution_claimed": False,
                "bootstrap_mean_predictions_reused": True,
                "n_bootstrap": result.n_bootstrap,
                "n_training_residuals": int(centered_residuals.size),
                "target_source": result.target_source,
                "random_state": random_state,
            },
        },
    )

eyetrajectoriespy.fpca_regression_future_prediction_frame

fpca_regression_future_prediction_frame(result: FPCARegressionPredictionIntervalResult) -> pd.DataFrame

Return fixed-target future-outcome prediction interval summaries.

Source code in src/eyetrajectoriespy/regression_inference.py
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
def fpca_regression_future_prediction_frame(
    result: FPCARegressionPredictionIntervalResult,
) -> pd.DataFrame:
    """Return fixed-target future-outcome prediction interval summaries."""

    base = result.regression_uncertainty
    return pd.DataFrame(
        {
            "curve_id": base.target_curve_ids,
            "reference_mean_prediction": base.reference_mean_predictions,
            "mean_response_lower": base.prediction_lower,
            "mean_response_upper": base.prediction_upper,
            "future_prediction_median": result.median,
            "future_prediction_se": result.predictive_se,
            "future_prediction_lower": result.lower,
            "future_prediction_upper": result.upper,
        }
    )

eyetrajectoriespy.plot_fpca_regression_future_prediction_interval

plot_fpca_regression_future_prediction_interval(result: FPCARegressionPredictionIntervalResult, *, max_targets: int = 30, ax=None)

Plot marginal future-outcome prediction intervals for fixed targets.

Source code in src/eyetrajectoriespy/plotting.py
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
def plot_fpca_regression_future_prediction_interval(
    result: FPCARegressionPredictionIntervalResult,
    *,
    max_targets: int = 30,
    ax=None,
):
    """Plot marginal future-outcome prediction intervals for fixed targets."""

    if isinstance(max_targets, bool) or not isinstance(max_targets, (int, np.integer)):
        raise TypeError("max_targets must be an integer")
    if max_targets < 1:
        raise ValueError("max_targets must be positive")
    if ax is None:
        _, ax = plt.subplots()

    base = result.regression_uncertainty
    n = min(max_targets, result.n_targets)
    x = np.arange(n)
    median = result.median[:n]
    lower = result.lower[:n]
    upper = result.upper[:n]
    yerr = np.vstack((median - lower, upper - median))
    ax.errorbar(
        x,
        median,
        yerr=yerr,
        marker="o",
        linestyle="none",
        capsize=3,
        label="future-outcome predictive interval",
    )
    ax.scatter(
        x,
        base.reference_mean_predictions[:n],
        marker="x",
        label="full-sample conditional mean",
    )
    ax.set_xticks(x)
    ax.set_xticklabels(base.target_curve_ids[:n], rotation=90)
    ax.set_xlabel("Fixed target trajectory")
    ax.set_ylabel("Scalar response")
    ax.set_title("Gaussian FPCR future-outcome prediction")
    ax.legend()
    return ax

eyetrajectoriespy.fpca_regression_future_prediction_reporting_text

fpca_regression_future_prediction_reporting_text(result: FPCARegressionPredictionIntervalResult, *, digits: int = 3) -> str

Generate reporting text for Gaussian FPCR future-outcome intervals.

Source code in src/eyetrajectoriespy/reporting.py
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
def fpca_regression_future_prediction_reporting_text(
    result: FPCARegressionPredictionIntervalResult,
    *,
    digits: int = 3,
) -> str:
    """Generate reporting text for Gaussian FPCR future-outcome intervals."""

    median_width = float(np.median(result.upper - result.lower))
    residual_sd = float(np.std(result.centered_residuals, ddof=1))
    return (
        f"Future scalar outcomes for {result.n_targets} fixed target trajectory(ies) "
        f"were summarized with {100 * result.confidence_level:.1f}% marginal "
        "Gaussian FPCR prediction intervals. The predictive distribution reused "
        f"{result.n_bootstrap} paired-bootstrap conditional-mean predictions and "
        "added independent draws from the centered empirical residual distribution "
        f"of the full-sample FPCR fit (residual SD={residual_sd:.{digits}f}; "
        f"median predictive width={median_width:.{digits}f}). Residual exchangeability "
        "and a common response-error distribution across targets are assumed. The "
        "intervals are not heteroscedasticity-robust and are not simultaneous or "
        "joint across multiple target trajectories."
    )

Gaussian FPCR simultaneous slope band

eyetrajectoriespy.FPCARegressionSlopeBandResult dataclass

Observed-grid simultaneous bootstrap band for a Gaussian FPCR slope.

Source code in src/eyetrajectoriespy/types.py
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
@dataclass(frozen=True)
class FPCARegressionSlopeBandResult:
    """Observed-grid simultaneous bootstrap band for a Gaussian FPCR slope."""

    regression_uncertainty: FPCARegressionUncertaintyResult
    lower: np.ndarray
    upper: np.ndarray
    pointwise_se: np.ndarray
    critical_values: np.ndarray
    max_statistics: np.ndarray
    confidence_level: float
    simultaneous_scope: str
    provenance: Mapping[str, Any] = field(default_factory=dict)

    @property
    def n_bootstrap(self) -> int:
        return self.regression_uncertainty.n_bootstrap

    @property
    def n_time(self) -> int:
        return self.lower.shape[0]

    @property
    def n_dimensions(self) -> int:
        return self.lower.shape[1]

eyetrajectoriespy.fpca_regression_slope_simultaneous_band

fpca_regression_slope_simultaneous_band(result: FPCARegressionUncertaintyResult, *, confidence_level: float = 0.95, simultaneous_scope: str = 'global') -> FPCARegressionSlopeBandResult

Calibrate an observed-grid simultaneous band from paired FPCR bootstraps.

Global scope uses one maximum over the full observed time-by-dimension slope grid. Dimension scope calibrates one maximum over time separately within each functional dimension.

The procedure is a studentized maximum-deviation bootstrap approximation derived from already-computed paired-bootstrap slope replicates. It is not a continuous-domain confidence band and is not the operator-scaled FPCR significance test from recent asymptotic theory.

Source code in src/eyetrajectoriespy/regression_inference.py
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
def fpca_regression_slope_simultaneous_band(
    result: FPCARegressionUncertaintyResult,
    *,
    confidence_level: float = 0.95,
    simultaneous_scope: str = "global",
) -> FPCARegressionSlopeBandResult:
    """Calibrate an observed-grid simultaneous band from paired FPCR bootstraps.

    Global scope uses one maximum over the full observed time-by-dimension
    slope grid. Dimension scope calibrates one maximum over time separately
    within each functional dimension.

    The procedure is a studentized maximum-deviation bootstrap approximation
    derived from already-computed paired-bootstrap slope replicates. It is not
    a continuous-domain confidence band and is not the operator-scaled FPCR
    significance test from recent asymptotic theory.
    """

    if not isinstance(result, FPCARegressionUncertaintyResult):
        raise TypeError(
            "result must be an FPCARegressionUncertaintyResult from "
            "bootstrap_fpca_regression_uncertainty()"
        )
    if not 0 < confidence_level < 1:
        raise ValueError("confidence_level must lie in (0, 1)")
    if simultaneous_scope not in {"global", "dimension"}:
        raise ValueError(
            "simultaneous_scope must be 'global' or 'dimension'"
        )

    reference = np.asarray(result.reference_slope, dtype=float)
    bootstrap = np.asarray(result.bootstrap_slopes, dtype=float)
    if bootstrap.ndim != 3 or bootstrap.shape[1:] != reference.shape:
        raise ValueError("bootstrap slope array is incompatible with reference slope")
    if bootstrap.shape[0] < 2:
        raise ValueError("at least two bootstrap slope replicates are required")
    if not np.all(np.isfinite(reference)) or not np.all(np.isfinite(bootstrap)):
        raise ValueError("reference and bootstrap slopes must be finite")

    deviations = bootstrap - reference[None, :, :]
    pointwise_se = np.std(deviations, axis=0, ddof=1)
    scale = max(1.0, float(np.max(np.abs(reference))))
    positive_variance = pointwise_se > np.finfo(float).eps * scale
    tolerance = 100.0 * np.finfo(float).eps * scale

    degenerate = (~positive_variance) & (
        np.max(np.abs(deviations), axis=0) > tolerance
    )
    if np.any(degenerate):
        positions = np.argwhere(degenerate)
        preview = [
            {
                "time_index": int(time_index),
                "dimension": result.reference_fpca.dimension_names[int(dimension_index)],
            }
            for time_index, dimension_index in positions[:8]
        ]
        raise RuntimeError(
            "FPCR slope-band calibration is degenerate: zero bootstrap SE with "
            f"non-zero reference discrepancy at {len(positions)} grid cell(s); "
            f"first cells={preview}"
        )

    standardized = np.zeros_like(deviations)
    np.divide(
        deviations,
        pointwise_se[None, :, :],
        out=standardized,
        where=positive_variance[None, :, :],
    )
    absolute_statistics = np.abs(standardized)

    if simultaneous_scope == "global":
        max_statistics = np.max(absolute_statistics, axis=(1, 2))
        critical = float(
            np.quantile(
                max_statistics,
                confidence_level,
                method="higher",
            )
        )
        critical_values = np.full(reference.shape[1], critical, dtype=float)
    else:
        max_statistics = np.max(absolute_statistics, axis=1)
        critical_values = np.quantile(
            max_statistics,
            confidence_level,
            axis=0,
            method="higher",
        ).astype(float)

    half_width = pointwise_se * critical_values[None, :]
    lower = reference - half_width
    upper = reference + half_width

    return FPCARegressionSlopeBandResult(
        regression_uncertainty=result,
        lower=lower,
        upper=upper,
        pointwise_se=pointwise_se,
        critical_values=np.asarray(critical_values, dtype=float),
        max_statistics=np.asarray(max_statistics, dtype=float),
        confidence_level=float(confidence_level),
        simultaneous_scope=simultaneous_scope,
        provenance={
            **dict(result.provenance),
            "fpca_regression_slope_band": {
                "method": "studentized_bootstrap_maximum_observed_grid",
                "confidence_level": float(confidence_level),
                "simultaneous_scope": simultaneous_scope,
                "domain": "observed_time_by_dimension_grid",
                "continuous_between_grid_points": False,
                "operator_scaled_fpcr_test": False,
                "bootstrap_reused_from_regression_uncertainty": True,
                "n_bootstrap": result.n_bootstrap,
                "zero_variance_cells": int((~positive_variance).sum()),
            },
        },
    )

eyetrajectoriespy.fpca_regression_slope_band_frame

fpca_regression_slope_band_frame(result: FPCARegressionSlopeBandResult) -> pd.DataFrame

Return long-form observed-grid simultaneous slope-band summaries.

Source code in src/eyetrajectoriespy/regression_inference.py
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
def fpca_regression_slope_band_frame(
    result: FPCARegressionSlopeBandResult,
) -> pd.DataFrame:
    """Return long-form observed-grid simultaneous slope-band summaries."""

    rows: list[dict[str, float | str]] = []
    reference = result.regression_uncertainty.reference_slope
    fpca = result.regression_uncertainty.reference_fpca
    for time_index, time in enumerate(fpca.time):
        for dimension_index, dimension in enumerate(fpca.dimension_names):
            rows.append(
                {
                    "time": float(time),
                    "dimension": dimension,
                    "reference_slope": float(
                        reference[time_index, dimension_index]
                    ),
                    "pointwise_se": float(
                        result.pointwise_se[time_index, dimension_index]
                    ),
                    "critical_value": float(
                        result.critical_values[dimension_index]
                    ),
                    "lower": float(result.lower[time_index, dimension_index]),
                    "upper": float(result.upper[time_index, dimension_index]),
                }
            )
    return pd.DataFrame(rows)

eyetrajectoriespy.plot_fpca_regression_slope_band

plot_fpca_regression_slope_band(result: FPCARegressionSlopeBandResult, *, dimension: str | None = None, ax=None)

Plot an observed-grid simultaneous Gaussian FPCR slope band.

Source code in src/eyetrajectoriespy/plotting.py
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
def plot_fpca_regression_slope_band(
    result: FPCARegressionSlopeBandResult,
    *,
    dimension: str | None = None,
    ax=None,
):
    """Plot an observed-grid simultaneous Gaussian FPCR slope band."""

    fpca = result.regression_uncertainty.reference_fpca
    if dimension is None:
        dimension = fpca.dimension_names[0]
    if dimension not in fpca.dimension_names:
        raise KeyError(f"Unknown dimension {dimension!r}")
    if ax is None:
        _, ax = plt.subplots()

    dim = fpca.dimension_names.index(dimension)
    time = fpca.time
    reference = result.regression_uncertainty.reference_slope[:, dim]
    ax.fill_between(
        time,
        result.lower[:, dim],
        result.upper[:, dim],
        alpha=0.2,
        label=(
            f"{100 * result.confidence_level:.1f}% observed-grid "
            f"{result.simultaneous_scope} band"
        ),
    )
    ax.plot(time, reference, label="full-sample FPCR slope")
    ax.axhline(0.0, linestyle="--")
    ax.set_xlabel(f"Time ({fpca.time_unit})")
    ax.set_ylabel(f"Slope for {dimension}")
    ax.set_title("Gaussian FPCR simultaneous slope band")
    ax.legend()
    return ax

eyetrajectoriespy.fpca_regression_slope_band_reporting_text

fpca_regression_slope_band_reporting_text(result: FPCARegressionSlopeBandResult, *, digits: int = 3) -> str

Generate reporting text for observed-grid simultaneous FPCR slope bands.

Source code in src/eyetrajectoriespy/reporting.py
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
def fpca_regression_slope_band_reporting_text(
    result: FPCARegressionSlopeBandResult,
    *,
    digits: int = 3,
) -> str:
    """Generate reporting text for observed-grid simultaneous FPCR slope bands."""

    critical = ", ".join(
        f"{dimension}={value:.{digits}f}"
        for dimension, value in zip(
            result.regression_uncertainty.reference_fpca.dimension_names,
            result.critical_values,
            strict=True,
        )
    )
    scope = (
        "one maximum over the full observed time-by-dimension grid"
        if result.simultaneous_scope == "global"
        else "a separate maximum over observed time within each functional dimension"
    )
    return (
        f"A {100 * result.confidence_level:.1f}% observed-grid simultaneous "
        "Gaussian FPCR slope band was calibrated from the retained paired "
        f"bootstrap slope refits using {scope}. Studentized maximum-deviation "
        f"critical value(s) were {critical}. The band is simultaneous only over "
        "the sampled grid represented in the fit and does not claim coverage "
        "between grid points. This calibration reuses the 0.12 paired-bootstrap "
        "FPCR distribution and is not the operator-scaled FPCR significance "
        "test developed in recent asymptotic theory."
    )

Gaussian FPCR bootstrap uncertainty

eyetrajectoriespy.FPCARegressionUncertaintyResult dataclass

Paired-bootstrap uncertainty for Gaussian FPCA scalar regression.

Source code in src/eyetrajectoriespy/types.py
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
@dataclass(frozen=True)
class FPCARegressionUncertaintyResult:
    """Paired-bootstrap uncertainty for Gaussian FPCA scalar regression."""

    reference_fpca: FPCAResult
    reference_regression: FunctionalRegressionResult
    reference_slope: np.ndarray
    bootstrap_slopes: np.ndarray
    slope_lower: np.ndarray
    slope_median: np.ndarray
    slope_upper: np.ndarray
    slope_se: np.ndarray
    reference_intercept: float
    bootstrap_intercepts: np.ndarray
    target_curve_ids: tuple[str, ...]
    reference_mean_predictions: np.ndarray
    bootstrap_mean_predictions: np.ndarray
    prediction_lower: np.ndarray
    prediction_median: np.ndarray
    prediction_upper: np.ndarray
    prediction_se: np.ndarray
    level: float
    n_components: int
    scaling: str
    resampling_unit: str
    participant_column: str | None
    target_source: str
    random_state: int | None
    provenance: Mapping[str, Any] = field(default_factory=dict)

    @property
    def n_bootstrap(self) -> int:
        return self.bootstrap_slopes.shape[0]

    @property
    def n_targets(self) -> int:
        return self.bootstrap_mean_predictions.shape[1]

eyetrajectoriespy.bootstrap_fpca_regression_uncertainty

bootstrap_fpca_regression_uncertainty(trajectories: TrajectorySet, outcome: ndarray | Series, *, targets: TrajectorySet | None = None, n_bootstrap: int = 500, n_components: int = 3, scaling: str = 'none', resample_unit: str = 'curve', participant_column: str | None = None, level: float = 0.95, random_state: int | None = 0) -> FPCARegressionUncertaintyResult

Paired-bootstrap uncertainty for Gaussian scalar-on-function FPCR.

The independent sampling unit is resampled together with its scalar outcome. FPCA/MFPCA and the Gaussian score regression are refitted in every bootstrap replicate. The component count is held fixed.

Returned slope envelopes are pointwise percentile bootstrap summaries for the reconstructed slope in original trajectory coordinate units. Returned target intervals are uncertainty intervals for the fitted conditional mean response of fixed target curves, not prediction intervals for future noisy outcomes.

This routine does not implement the operator-scaled bootstrap test proposed in the 2026 FPCR inference literature, does not reselect the component count inside bootstrap replicates, and does not support binomial regression.

Source code in src/eyetrajectoriespy/regression_inference.py
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
def bootstrap_fpca_regression_uncertainty(
    trajectories: TrajectorySet,
    outcome: np.ndarray | pd.Series,
    *,
    targets: TrajectorySet | None = None,
    n_bootstrap: int = 500,
    n_components: int = 3,
    scaling: str = "none",
    resample_unit: str = "curve",
    participant_column: str | None = None,
    level: float = 0.95,
    random_state: int | None = 0,
) -> FPCARegressionUncertaintyResult:
    """Paired-bootstrap uncertainty for Gaussian scalar-on-function FPCR.

    The independent sampling unit is resampled together with its scalar outcome.
    FPCA/MFPCA and the Gaussian score regression are refitted in every bootstrap
    replicate. The component count is held fixed.

    Returned slope envelopes are pointwise percentile bootstrap summaries for
    the reconstructed slope in original trajectory coordinate units. Returned
    target intervals are uncertainty intervals for the fitted *conditional mean*
    response of fixed target curves, not prediction intervals for future noisy
    outcomes.

    This routine does not implement the operator-scaled bootstrap test proposed
    in the 2026 FPCR inference literature, does not reselect the component count
    inside bootstrap replicates, and does not support binomial regression.
    """

    _validate_complete_finite(trajectories, name="training trajectories")
    y = np.asarray(outcome, dtype=float)
    if y.shape != (trajectories.n_curves,):
        raise ValueError("outcome must contain exactly one value per training trajectory")
    if not np.all(np.isfinite(y)):
        raise ValueError("outcome must contain only finite values")

    if targets is None:
        target_set = trajectories
        target_source = "training"
    else:
        target_set = targets
        target_source = "external"
        _validate_targets(trajectories, target_set)

    if isinstance(n_bootstrap, bool) or not isinstance(
        n_bootstrap,
        (int, np.integer),
    ):
        raise TypeError("n_bootstrap must be an integer")
    n_bootstrap = int(n_bootstrap)
    if n_bootstrap < 20:
        raise ValueError("n_bootstrap must be at least 20")

    if isinstance(n_components, bool) or not isinstance(
        n_components,
        (int, np.integer),
    ):
        raise TypeError("n_components must be an integer")
    n_components = int(n_components)
    max_nonzero_rank = min(
        trajectories.n_curves - 1,
        trajectories.n_time * trajectories.n_dimensions,
    )
    if n_components < 1 or n_components > max_nonzero_rank:
        raise ValueError(
            f"n_components must be in [1, {max_nonzero_rank}] for non-zero-rank FPCA"
        )

    if scaling not in {"none", "dimension_sd"}:
        raise ValueError("scaling must be 'none' or 'dimension_sd'")
    if resample_unit not in {"curve", "participant"}:
        raise ValueError("resample_unit must be 'curve' or 'participant'")
    if resample_unit == "curve" and participant_column is not None:
        raise ValueError(
            "participant_column must be None when resample_unit='curve'"
        )
    if resample_unit == "participant" and not participant_column:
        raise ValueError(
            "participant_column is required when resample_unit='participant'"
        )
    if not 0 < level < 1:
        raise ValueError("level must lie in (0, 1)")

    reference_fpca = _fit_for_trajectories(
        trajectories,
        n_components=n_components,
        scaling=scaling,
    )
    _check_regression_design(
        reference_fpca,
        n_components=n_components,
        context="reference",
    )
    reference_regression = fit_scalar_on_function_regression(
        reference_fpca,
        y,
        n_components=n_components,
        family="gaussian",
    )
    reference_slope = _functional_slope(
        reference_fpca,
        reference_regression,
        n_components=n_components,
    )
    reference_intercept = float(reference_regression.coefficients.loc["const"])
    reference_predictions = _mean_predictions(
        reference_fpca,
        reference_regression,
        target_set,
        n_components=n_components,
    )

    rng = np.random.default_rng(random_state)
    bootstrap_slopes = np.empty(
        (
            n_bootstrap,
            trajectories.n_time,
            trajectories.n_dimensions,
        ),
        dtype=float,
    )
    bootstrap_intercepts = np.empty(n_bootstrap, dtype=float)
    bootstrap_predictions = np.empty(
        (n_bootstrap, target_set.n_curves),
        dtype=float,
    )

    for bootstrap_index in range(n_bootstrap):
        if resample_unit == "curve":
            indices = _curve_bootstrap_indices(trajectories, rng)
        else:
            indices = _participant_bootstrap_indices(
                trajectories,
                rng,
                participant_column=str(participant_column),
            )

        if indices.size <= n_components:
            raise RuntimeError(
                f"bootstrap replicate {bootstrap_index} has too few paired observations "
                f"for {n_components} FPC predictors"
            )

        sample = _bootstrap_sample(
            trajectories,
            indices,
            replicate=bootstrap_index,
        )
        candidate_fpca = _fit_for_trajectories(
            sample,
            n_components=n_components,
            scaling=scaling,
        )
        _check_regression_design(
            candidate_fpca,
            n_components=n_components,
            context=f"bootstrap replicate {bootstrap_index}",
        )
        candidate_regression = fit_scalar_on_function_regression(
            candidate_fpca,
            y[indices],
            n_components=n_components,
            family="gaussian",
        )

        bootstrap_slopes[bootstrap_index] = _functional_slope(
            candidate_fpca,
            candidate_regression,
            n_components=n_components,
        )
        bootstrap_intercepts[bootstrap_index] = float(
            candidate_regression.coefficients.loc["const"]
        )
        bootstrap_predictions[bootstrap_index] = _mean_predictions(
            candidate_fpca,
            candidate_regression,
            target_set,
            n_components=n_components,
        )

    alpha = (1.0 - float(level)) / 2.0
    slope_lower = np.quantile(bootstrap_slopes, alpha, axis=0)
    slope_median = np.quantile(bootstrap_slopes, 0.5, axis=0)
    slope_upper = np.quantile(bootstrap_slopes, 1.0 - alpha, axis=0)
    slope_se = np.std(bootstrap_slopes, axis=0, ddof=1)

    prediction_lower = np.quantile(bootstrap_predictions, alpha, axis=0)
    prediction_median = np.quantile(bootstrap_predictions, 0.5, axis=0)
    prediction_upper = np.quantile(bootstrap_predictions, 1.0 - alpha, axis=0)
    prediction_se = np.std(bootstrap_predictions, axis=0, ddof=1)

    return FPCARegressionUncertaintyResult(
        reference_fpca=reference_fpca,
        reference_regression=reference_regression,
        reference_slope=reference_slope,
        bootstrap_slopes=bootstrap_slopes,
        slope_lower=slope_lower,
        slope_median=slope_median,
        slope_upper=slope_upper,
        slope_se=slope_se,
        reference_intercept=reference_intercept,
        bootstrap_intercepts=bootstrap_intercepts,
        target_curve_ids=target_set.curve_ids,
        reference_mean_predictions=reference_predictions,
        bootstrap_mean_predictions=bootstrap_predictions,
        prediction_lower=prediction_lower,
        prediction_median=prediction_median,
        prediction_upper=prediction_upper,
        prediction_se=prediction_se,
        level=float(level),
        n_components=n_components,
        scaling=scaling,
        resampling_unit=resample_unit,
        participant_column=(
            participant_column if resample_unit == "participant" else None
        ),
        target_source=target_source,
        random_state=random_state,
        provenance={
            **dict(trajectories.provenance),
            "fpca_regression_uncertainty": {
                "method": "paired_nonparametric_bootstrap_full_fpcr_refit",
                "family": "gaussian",
                "n_bootstrap": n_bootstrap,
                "n_components": n_components,
                "component_count_fixed": True,
                "component_selection_uncertainty_included": False,
                "scaling": scaling,
                "resample_unit": resample_unit,
                "participant_column": (
                    participant_column if resample_unit == "participant" else None
                ),
                "level": float(level),
                "slope_interval": "pointwise_percentile_bootstrap",
                "prediction_interval": "fixed_target_conditional_mean_percentile_bootstrap",
                "future_outcome_prediction_interval": False,
                "target_source": target_source,
                "target_curves_fixed": True,
                "fpc_label_matching_required": False,
                "fpc_label_invariance_reason": (
                    "slope_and_mean_prediction_reconstructed_from_each_complete_refit"
                ),
                "operator_scaled_fpcr_test": False,
                "random_state": random_state,
            },
        },
    )

eyetrajectoriespy.fpca_regression_slope_uncertainty_frame

fpca_regression_slope_uncertainty_frame(result: FPCARegressionUncertaintyResult) -> pd.DataFrame

Return long-form pointwise functional-slope uncertainty summaries.

Source code in src/eyetrajectoriespy/regression_inference.py
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
def fpca_regression_slope_uncertainty_frame(
    result: FPCARegressionUncertaintyResult,
) -> pd.DataFrame:
    """Return long-form pointwise functional-slope uncertainty summaries."""

    rows: list[dict[str, float | str]] = []
    for time_index, time in enumerate(result.reference_fpca.time):
        for dimension_index, dimension in enumerate(
            result.reference_fpca.dimension_names
        ):
            rows.append(
                {
                    "time": float(time),
                    "dimension": dimension,
                    "reference_slope": float(
                        result.reference_slope[time_index, dimension_index]
                    ),
                    "bootstrap_median": float(
                        result.slope_median[time_index, dimension_index]
                    ),
                    "bootstrap_se": float(
                        result.slope_se[time_index, dimension_index]
                    ),
                    "lower": float(
                        result.slope_lower[time_index, dimension_index]
                    ),
                    "upper": float(
                        result.slope_upper[time_index, dimension_index]
                    ),
                }
            )
    return pd.DataFrame(rows)

eyetrajectoriespy.fpca_regression_prediction_uncertainty_frame

fpca_regression_prediction_uncertainty_frame(result: FPCARegressionUncertaintyResult) -> pd.DataFrame

Return fixed-target conditional-mean uncertainty summaries.

Source code in src/eyetrajectoriespy/regression_inference.py
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
def fpca_regression_prediction_uncertainty_frame(
    result: FPCARegressionUncertaintyResult,
) -> pd.DataFrame:
    """Return fixed-target conditional-mean uncertainty summaries."""

    return pd.DataFrame(
        {
            "curve_id": result.target_curve_ids,
            "reference_mean_prediction": result.reference_mean_predictions,
            "bootstrap_median": result.prediction_median,
            "bootstrap_se": result.prediction_se,
            "lower": result.prediction_lower,
            "upper": result.prediction_upper,
        }
    )

eyetrajectoriespy.plot_fpca_regression_slope_uncertainty

plot_fpca_regression_slope_uncertainty(result: FPCARegressionUncertaintyResult, *, dimension: str | None = None, ax=None)

Plot the Gaussian FPCR slope with pointwise bootstrap uncertainty.

Source code in src/eyetrajectoriespy/plotting.py
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
def plot_fpca_regression_slope_uncertainty(
    result: FPCARegressionUncertaintyResult,
    *,
    dimension: str | None = None,
    ax=None,
):
    """Plot the Gaussian FPCR slope with pointwise bootstrap uncertainty."""

    if dimension is None:
        dimension = result.reference_fpca.dimension_names[0]
    if dimension not in result.reference_fpca.dimension_names:
        raise KeyError(f"Unknown dimension {dimension!r}")
    if ax is None:
        _, ax = plt.subplots()

    dim = result.reference_fpca.dimension_names.index(dimension)
    time = result.reference_fpca.time
    ax.fill_between(
        time,
        result.slope_lower[:, dim],
        result.slope_upper[:, dim],
        alpha=0.2,
        label=f"{100 * result.level:.1f}% pointwise percentile envelope",
    )
    ax.plot(
        time,
        result.reference_slope[:, dim],
        label="full-sample FPCR slope",
    )
    ax.axhline(0.0, linestyle="--")
    ax.set_xlabel(f"Time ({result.reference_fpca.time_unit})")
    ax.set_ylabel(f"Slope for {dimension}")
    ax.set_title("Gaussian FPCR slope uncertainty")
    ax.legend()
    return ax

eyetrajectoriespy.plot_fpca_regression_mean_prediction_uncertainty

plot_fpca_regression_mean_prediction_uncertainty(result: FPCARegressionUncertaintyResult, *, max_targets: int = 30, ax=None)

Plot fixed-target conditional-mean uncertainty from paired FPCR bootstrap.

Source code in src/eyetrajectoriespy/plotting.py
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
def plot_fpca_regression_mean_prediction_uncertainty(
    result: FPCARegressionUncertaintyResult,
    *,
    max_targets: int = 30,
    ax=None,
):
    """Plot fixed-target conditional-mean uncertainty from paired FPCR bootstrap."""

    if isinstance(max_targets, bool) or not isinstance(max_targets, (int, np.integer)):
        raise TypeError("max_targets must be an integer")
    if max_targets < 1:
        raise ValueError("max_targets must be positive")
    if ax is None:
        _, ax = plt.subplots()

    n = min(max_targets, result.n_targets)
    x = np.arange(n)
    median = result.prediction_median[:n]
    lower = result.prediction_lower[:n]
    upper = result.prediction_upper[:n]
    yerr = np.vstack((median - lower, upper - median))
    ax.errorbar(
        x,
        median,
        yerr=yerr,
        marker="o",
        linestyle="none",
        capsize=3,
        label="bootstrap median + conditional-mean envelope",
    )
    ax.scatter(
        x,
        result.reference_mean_predictions[:n],
        marker="x",
        label="full-sample conditional mean",
    )
    ax.set_xticks(x)
    ax.set_xticklabels(result.target_curve_ids[:n], rotation=90)
    ax.set_xlabel("Fixed target trajectory")
    ax.set_ylabel("Conditional mean response")
    ax.set_title("FPCR conditional-mean uncertainty")
    ax.legend()
    return ax

eyetrajectoriespy.fpca_regression_uncertainty_reporting_text

fpca_regression_uncertainty_reporting_text(result: FPCARegressionUncertaintyResult, *, digits: int = 3) -> str

Generate manuscript-oriented wording for Gaussian FPCR bootstrap uncertainty.

Source code in src/eyetrajectoriespy/reporting.py
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
def fpca_regression_uncertainty_reporting_text(
    result: FPCARegressionUncertaintyResult,
    *,
    digits: int = 3,
) -> str:
    """Generate manuscript-oriented wording for Gaussian FPCR bootstrap uncertainty."""

    slope_width = float(np.median(result.slope_upper - result.slope_lower))
    prediction_width = float(
        np.median(result.prediction_upper - result.prediction_lower)
    )
    unit = (
        "participant"
        if result.resampling_unit == "participant"
        else "curve"
    )
    return (
        "Gaussian scalar-on-function FPCR uncertainty was evaluated with "
        f"{result.n_bootstrap} paired {unit}-level bootstrap refits. FPCA/MFPCA "
        f"and the score regression were refitted in every replicate using "
        f"{result.n_components} fixed component(s) and scaling={result.scaling!r}. "
        f"Pointwise {100 * result.level:.1f}% percentile envelopes were formed "
        "for the reconstructed functional slope in the original trajectory "
        f"units (median grid-point width={slope_width:.{digits}f}). Fixed-target "
        "response intervals summarize uncertainty in the fitted conditional "
        f"mean (median width={prediction_width:.{digits}f}); they are not "
        "prediction intervals for future noisy outcomes. Component count was "
        "not reselected inside bootstrap replicates, and this routine does not "
        "implement the operator-scaled FPCR hypothesis test from recent theory."
    )

Predictive FPCA regression selection

eyetrajectoriespy.cross_validate_fpca_regression

cross_validate_fpca_regression(trajectories: TrajectorySet, outcome: ndarray | Series, *, candidate_components: Sequence[int] = (1, 2, 3, 4, 5), family: str = 'gaussian', loss: str | None = None, covariates: DataFrame | None = None, n_splits: int = 5, scaling: str = 'none', cv_unit: str = 'curve', group_column: str | None = None, shuffle: bool = True, random_state: int | None = 0) -> FPCARegressionCVResult

Tune retained FPC count for scalar-outcome prediction.

FPCA and scalar regression are both fitted inside every training fold. Group cross-validation holds all curves from a participant/group together. The function tunes an ordinary unsupervised FPCA basis for prediction; it does not construct supervised principal components.

Source code in src/eyetrajectoriespy/prediction.py
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
def cross_validate_fpca_regression(
    trajectories: TrajectorySet,
    outcome: np.ndarray | pd.Series,
    *,
    candidate_components: Sequence[int] = (1, 2, 3, 4, 5),
    family: str = "gaussian",
    loss: str | None = None,
    covariates: pd.DataFrame | None = None,
    n_splits: int = 5,
    scaling: str = "none",
    cv_unit: str = "curve",
    group_column: str | None = None,
    shuffle: bool = True,
    random_state: int | None = 0,
) -> FPCARegressionCVResult:
    """Tune retained FPC count for scalar-outcome prediction.

    FPCA and scalar regression are both fitted inside every training fold.
    Group cross-validation holds all curves from a participant/group together.
    The function tunes an ordinary unsupervised FPCA basis for prediction; it
    does not construct supervised principal components.
    """

    validate_trajectory_set(trajectories, require_complete=True)
    counts = _candidate_counts(candidate_components)
    values = _validate_outcome(
        outcome,
        n_curves=trajectories.n_curves,
        family=family,
    )
    covariate_frame = _validate_covariates(
        covariates,
        n_curves=trajectories.n_curves,
    )
    resolved_loss = _resolve_loss(family, loss)

    splits, groups = _make_splits(
        trajectories,
        n_splits=n_splits,
        cv_unit=cv_unit,
        group_column=group_column,
        shuffle=shuffle,
        random_state=random_state,
    )

    fold_rows = []
    assignment_rows = []
    prediction_rows = []

    for fold, (train_idx, test_idx) in enumerate(splits):
        max_train_components = min(
            len(train_idx) - 1,
            trajectories.n_time * trajectories.n_dimensions,
        )
        if counts[-1] > max_train_components:
            raise ValueError(
                f"candidate component count {counts[-1]} exceeds training-fold "
                f"maximum {max_train_components}; reduce candidate_components "
                "or n_splits"
            )

        train = trajectories.subset(train_idx)
        test = trajectories.subset(test_idx)
        fpca = _fit(
            train,
            n_components=counts[-1],
            scaling=scaling,
        )
        test_scores = transform_fpca(fpca, test)

        covariates_train = (
            None
            if covariate_frame is None
            else covariate_frame.iloc[train_idx].reset_index(drop=True)
        )
        covariates_test = (
            None
            if covariate_frame is None
            else covariate_frame.iloc[test_idx].reset_index(drop=True)
        )

        for n_components in counts:
            prediction = _fit_predict_regression(
                values[train_idx],
                fpca.scores,
                test_scores,
                n_components=n_components,
                family=family,
                covariates_train=covariates_train,
                covariates_test=covariates_test,
            )
            fold_loss = _loss_value(
                values[test_idx],
                prediction,
                family=family,
                loss=resolved_loss,
            )
            fold_rows.append(
                {
                    "fold": fold,
                    "n_components": n_components,
                    "loss": fold_loss,
                    "n_train_curves": int(len(train_idx)),
                    "n_test_curves": int(len(test_idx)),
                }
            )
            for position, index in enumerate(test_idx):
                prediction_rows.append(
                    {
                        "curve_id": trajectories.curve_ids[index],
                        "fold": fold,
                        "n_components": n_components,
                        "observed": float(values[index]),
                        "prediction": float(prediction[position]),
                    }
                )

        for index in test_idx:
            row = {
                "curve_id": trajectories.curve_ids[index],
                "fold": fold,
            }
            if groups is not None:
                row["group"] = str(groups[index])
            assignment_rows.append(row)

    return FPCARegressionCVResult(
        fold_losses=pd.DataFrame(fold_rows),
        assignments=pd.DataFrame(assignment_rows),
        predictions=pd.DataFrame(prediction_rows),
        component_counts=counts,
        family=family,
        loss=resolved_loss,
        cv_unit=cv_unit,
        n_splits=n_splits,
        group_column=group_column if cv_unit == "group" else None,
        scaling=scaling,
        random_state=random_state if cv_unit == "curve" and shuffle else None,
        provenance={
            "method": "outcome_tuned_fpca_regression_cv",
            "fit_fpca_inside_fold": True,
            "fit_regression_inside_fold": True,
            "group_leakage_prevented": cv_unit == "group",
            "covariates": (
                [] if covariate_frame is None else list(covariate_frame.columns)
            ),
            "scientific_warning": (
                "Grouped folds prevent train/test leakage but do not by themselves "
                "model within-group residual dependence or equalize group weights."
            ),
        },
    )

eyetrajectoriespy.summarise_fpca_regression_cv

summarise_fpca_regression_cv(result: FPCARegressionCVResult) -> pd.DataFrame

Aggregate fold-level predictive loss by candidate FPC count.

Source code in src/eyetrajectoriespy/prediction.py
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
def summarise_fpca_regression_cv(
    result: FPCARegressionCVResult,
) -> pd.DataFrame:
    """Aggregate fold-level predictive loss by candidate FPC count."""

    grouped = result.fold_losses.groupby(
        "n_components",
        sort=True,
    )["loss"]
    summary = grouped.agg(
        [
            ("mean_loss", "mean"),
            ("sd_loss", "std"),
            ("n_folds", "count"),
        ]
    ).reset_index()
    summary["sd_loss"] = summary["sd_loss"].fillna(0.0)
    summary["se_loss"] = summary["sd_loss"] / np.sqrt(summary["n_folds"])
    return summary

eyetrajectoriespy.select_fpca_regression_components

select_fpca_regression_components(result: FPCARegressionCVResult, *, rule: str = 'minimum') -> int

Choose retained FPC count from predictive CV losses.

Source code in src/eyetrajectoriespy/prediction.py
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
def select_fpca_regression_components(
    result: FPCARegressionCVResult,
    *,
    rule: str = "minimum",
) -> int:
    """Choose retained FPC count from predictive CV losses."""

    if rule not in {"minimum", "one_se"}:
        raise ValueError("rule must be 'minimum' or 'one_se'")

    summary = summarise_fpca_regression_cv(result)
    best_index = int(summary["mean_loss"].idxmin())
    best = summary.loc[best_index]

    if rule == "minimum":
        return int(best["n_components"])

    cutoff = float(best["mean_loss"] + best["se_loss"])
    eligible = summary[summary["mean_loss"] <= cutoff]
    return int(eligible["n_components"].min())

eyetrajectoriespy.nested_cross_validate_fpca_regression

nested_cross_validate_fpca_regression(trajectories: TrajectorySet, outcome: ndarray | Series, *, candidate_components: Sequence[int] = (1, 2, 3, 4, 5), family: str = 'gaussian', loss: str | None = None, covariates: DataFrame | None = None, outer_splits: int = 5, inner_splits: int = 4, selection_rule: str = 'minimum', scaling: str = 'none', cv_unit: str = 'curve', group_column: str | None = None, shuffle: bool = True, random_state: int | None = 0) -> FPCANestedRegressionCVResult

Estimate predictive performance with nested FPC-count selection.

Source code in src/eyetrajectoriespy/prediction.py
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
def nested_cross_validate_fpca_regression(
    trajectories: TrajectorySet,
    outcome: np.ndarray | pd.Series,
    *,
    candidate_components: Sequence[int] = (1, 2, 3, 4, 5),
    family: str = "gaussian",
    loss: str | None = None,
    covariates: pd.DataFrame | None = None,
    outer_splits: int = 5,
    inner_splits: int = 4,
    selection_rule: str = "minimum",
    scaling: str = "none",
    cv_unit: str = "curve",
    group_column: str | None = None,
    shuffle: bool = True,
    random_state: int | None = 0,
) -> FPCANestedRegressionCVResult:
    """Estimate predictive performance with nested FPC-count selection."""

    validate_trajectory_set(trajectories, require_complete=True)
    counts = _candidate_counts(candidate_components)
    values = _validate_outcome(
        outcome,
        n_curves=trajectories.n_curves,
        family=family,
    )
    covariate_frame = _validate_covariates(
        covariates,
        n_curves=trajectories.n_curves,
    )
    resolved_loss = _resolve_loss(family, loss)
    if selection_rule not in {"minimum", "one_se"}:
        raise ValueError("selection_rule must be 'minimum' or 'one_se'")

    outer, groups = _make_splits(
        trajectories,
        n_splits=outer_splits,
        cv_unit=cv_unit,
        group_column=group_column,
        shuffle=shuffle,
        random_state=random_state,
    )

    outer_rows = []
    inner_rows = []
    prediction_rows = []

    for outer_fold, (train_idx, test_idx) in enumerate(outer):
        train = trajectories.subset(train_idx)
        outcome_train = values[train_idx]
        covariates_train = (
            None
            if covariate_frame is None
            else covariate_frame.iloc[train_idx].reset_index(drop=True)
        )

        inner = cross_validate_fpca_regression(
            train,
            outcome_train,
            candidate_components=counts,
            family=family,
            loss=resolved_loss,
            covariates=covariates_train,
            n_splits=inner_splits,
            scaling=scaling,
            cv_unit=cv_unit,
            group_column=group_column,
            shuffle=shuffle,
            random_state=(
                None if random_state is None else int(random_state) + outer_fold + 1
            ),
        )
        selected = select_fpca_regression_components(
            inner,
            rule=selection_rule,
        )
        inner_summary = summarise_fpca_regression_cv(inner).copy()
        inner_summary.insert(0, "outer_fold", outer_fold)
        inner_rows.append(inner_summary)

        fpca = _fit(
            train,
            n_components=selected,
            scaling=scaling,
        )
        test_scores = transform_fpca(
            fpca,
            trajectories.subset(test_idx),
        )
        covariates_test = (
            None
            if covariate_frame is None
            else covariate_frame.iloc[test_idx].reset_index(drop=True)
        )
        prediction = _fit_predict_regression(
            outcome_train,
            fpca.scores,
            test_scores,
            n_components=selected,
            family=family,
            covariates_train=covariates_train,
            covariates_test=covariates_test,
        )
        outer_loss = _loss_value(
            values[test_idx],
            prediction,
            family=family,
            loss=resolved_loss,
        )
        outer_rows.append(
            {
                "outer_fold": outer_fold,
                "selected_n_components": selected,
                "loss": outer_loss,
                "n_train_curves": int(len(train_idx)),
                "n_test_curves": int(len(test_idx)),
            }
        )

        for position, index in enumerate(test_idx):
            row = {
                "curve_id": trajectories.curve_ids[index],
                "outer_fold": outer_fold,
                "observed": float(values[index]),
                "prediction": float(prediction[position]),
                "selected_n_components": selected,
            }
            if groups is not None:
                row["group"] = str(groups[index])
            prediction_rows.append(row)

    return FPCANestedRegressionCVResult(
        outer_folds=pd.DataFrame(outer_rows),
        inner_summaries=pd.concat(inner_rows, ignore_index=True),
        predictions=pd.DataFrame(prediction_rows),
        family=family,
        loss=resolved_loss,
        selection_rule=selection_rule,
        component_counts=counts,
        outer_splits=outer_splits,
        inner_splits=inner_splits,
        cv_unit=cv_unit,
        group_column=group_column if cv_unit == "group" else None,
        scaling=scaling,
        random_state=random_state if cv_unit == "curve" and shuffle else None,
        provenance={
            "method": "nested_outcome_tuned_fpca_regression_cv",
            "nested_selection": True,
            "outer_performance_unseen_by_inner_selection": True,
            "group_leakage_prevented": cv_unit == "group",
            "covariates": (
                [] if covariate_frame is None else list(covariate_frame.columns)
            ),
            "scientific_warning": (
                "Outer-fold performance estimates the predictive pipeline with "
                "inner FPC-count selection. It does not remove within-group "
                "dependence from a curve-level outcome model."
            ),
        },
    )

eyetrajectoriespy.plot_fpca_regression_cv

plot_fpca_regression_cv(result: FPCARegressionCVResult, *, ax=None)

Plot mean held-out predictive loss against retained FPC count.

Source code in src/eyetrajectoriespy/plotting.py
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
def plot_fpca_regression_cv(
    result: FPCARegressionCVResult,
    *,
    ax=None,
):
    """Plot mean held-out predictive loss against retained FPC count."""

    if ax is None:
        _, ax = plt.subplots()
    summary = summarise_fpca_regression_cv(result)
    ax.errorbar(
        summary["n_components"],
        summary["mean_loss"],
        yerr=summary["se_loss"],
        marker="o",
        capsize=3,
    )
    ax.set_xlabel("Retained functional principal components")
    ax.set_ylabel(f"Held-out {result.loss}")
    ax.set_title(f"Predictive FPCA regression CV ({result.family})")
    return ax

eyetrajectoriespy.plot_nested_fpca_regression_cv

plot_nested_fpca_regression_cv(result: FPCANestedRegressionCVResult, *, ax=None)

Plot outer-fold predictive loss from nested FPCA regression CV.

Source code in src/eyetrajectoriespy/plotting.py
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
def plot_nested_fpca_regression_cv(
    result: FPCANestedRegressionCVResult,
    *,
    ax=None,
):
    """Plot outer-fold predictive loss from nested FPCA regression CV."""

    if ax is None:
        _, ax = plt.subplots()
    frame = result.outer_folds
    ax.plot(
        frame["outer_fold"] + 1,
        frame["loss"],
        marker="o",
    )
    ax.set_xlabel("Outer fold")
    ax.set_ylabel(f"Outer held-out {result.loss}")
    ax.set_title("Nested predictive FPCA regression")
    return ax

eyetrajectoriespy.fpca_regression_cv_reporting_text

fpca_regression_cv_reporting_text(result: FPCARegressionCVResult, *, rule: str = 'minimum', digits: int = 3) -> str

Generate manuscript-oriented text for outcome-tuned FPC selection.

Source code in src/eyetrajectoriespy/reporting.py
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
def fpca_regression_cv_reporting_text(
    result: FPCARegressionCVResult,
    *,
    rule: str = "minimum",
    digits: int = 3,
) -> str:
    """Generate manuscript-oriented text for outcome-tuned FPC selection."""

    summary = summarise_fpca_regression_cv(result)
    selected = select_fpca_regression_components(result, rule=rule)
    row = summary.loc[summary["n_components"] == selected].iloc[0]
    unit = "grouped" if result.cv_unit == "group" else "curve-level"
    heuristic = (
        " The one-standard-error rule was used as a parsimony heuristic."
        if rule == "one_se"
        else ""
    )
    return (
        f"Outcome-tuned FPCA regression used {result.n_splits}-fold {unit} "
        f"cross-validation with family={result.family!r} and held-out "
        f"{result.loss}. FPCA and the scalar regression were refitted inside "
        f"every training fold. The explicit {rule!r} rule selected {selected} "
        f"component(s) (mean held-out loss={row['mean_loss']:.{digits}f}, "
        f"SE={row['se_loss']:.{digits}f}).{heuristic}"
    )

eyetrajectoriespy.fpca_nested_regression_cv_reporting_text

fpca_nested_regression_cv_reporting_text(result: FPCANestedRegressionCVResult, *, digits: int = 3) -> str

Describe nested predictive performance after inner FPC-count selection.

Source code in src/eyetrajectoriespy/reporting.py
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
def fpca_nested_regression_cv_reporting_text(
    result: FPCANestedRegressionCVResult,
    *,
    digits: int = 3,
) -> str:
    """Describe nested predictive performance after inner FPC-count selection."""

    mean_loss = float(result.outer_folds["loss"].mean())
    sd_loss = float(result.outer_folds["loss"].std(ddof=1))
    counts = result.outer_folds["selected_n_components"].astype(int)
    selected_text = ", ".join(
        f"{int(component)}×{int((counts == component).sum())}"
        for component in sorted(counts.unique())
    )
    unit = "grouped" if result.cv_unit == "group" else "curve-level"
    return (
        f"Nested FPCA regression used {result.outer_splits} outer and "
        f"{result.inner_splits} inner {unit} folds. Inner folds selected the "
        f"retained FPC count using the {result.selection_rule!r} rule; outer "
        f"folds were untouched by selection. Mean outer held-out {result.loss} "
        f"was {mean_loss:.{digits}f} (SD={sd_loss:.{digits}f}). Selected "
        f"component counts across outer fits were {selected_text}. This outer "
        "loss estimates the complete selection-and-fit pipeline rather than "
        "reusing the inner selection loss as performance evidence."
    )

FPC shape uncertainty

eyetrajectoriespy.FPCAComponentBandResult dataclass

Bootstrap-calibrated simultaneous uncertainty bands for FPC functions.

Source code in src/eyetrajectoriespy/types.py
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
@dataclass(frozen=True)
class FPCAComponentBandResult:
    """Bootstrap-calibrated simultaneous uncertainty bands for FPC functions."""

    reference: FPCAResult
    lower: np.ndarray
    upper: np.ndarray
    pointwise_se: np.ndarray
    critical_values: np.ndarray
    max_statistics: np.ndarray
    similarities: np.ndarray
    confidence_level: float
    simultaneous_scope: str
    resampling_unit: str
    participant_column: str | None
    relative_gap_threshold: float | None
    minimum_relative_gaps: np.ndarray
    random_state: int | None
    provenance: Mapping[str, Any] = field(default_factory=dict)

    @property
    def n_bootstrap(self) -> int:
        return self.similarities.shape[0]

    @property
    def n_components(self) -> int:
        return self.lower.shape[0]

eyetrajectoriespy.bootstrap_fpca_component_bands

bootstrap_fpca_component_bands(trajectories: TrajectorySet, *, n_bootstrap: int = 500, n_components: int = 3, scaling: str = 'none', resample_unit: str = 'curve', participant_column: str | None = None, confidence_level: float = 0.95, simultaneous_scope: str = 'component', relative_gap_threshold: float | None = None, on_near_tie: str = 'error', random_state: int | None = 0) -> FPCAComponentBandResult

Estimate matched-bootstrap simultaneous bands for individual FPC shapes.

Bootstrap FPCs are matched to the full-sample reference by maximum absolute functional similarity and sign-aligned before uncertainty is calibrated. Pointwise bootstrap standard errors are combined with a studentized maximum absolute deviation over the observed time-by-dimension grid.

Component scope calibrates each FPC separately across its full observed grid. Family scope uses a single maximum across all requested FPCs and the grid, providing a more conservative familywise band.

Because individual eigenfunctions can be weakly identified when adjacent eigenvalues are close, relative_gap_threshold optionally performs an explicit descriptive identifiability screen. No universal threshold is imposed by default. If supplied, on_near_tie controls whether a retained FPC meeting the threshold raises, warns, or is recorded only.

These are bootstrap-calibrated simultaneous uncertainty bands over the observed grid. They do not assert exact finite-sample coverage or coverage between sampled time points.

Source code in src/eyetrajectoriespy/component_inference.py
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
def bootstrap_fpca_component_bands(
    trajectories: TrajectorySet,
    *,
    n_bootstrap: int = 500,
    n_components: int = 3,
    scaling: str = "none",
    resample_unit: str = "curve",
    participant_column: str | None = None,
    confidence_level: float = 0.95,
    simultaneous_scope: str = "component",
    relative_gap_threshold: float | None = None,
    on_near_tie: str = "error",
    random_state: int | None = 0,
) -> FPCAComponentBandResult:
    """Estimate matched-bootstrap simultaneous bands for individual FPC shapes.

    Bootstrap FPCs are matched to the full-sample reference by maximum absolute
    functional similarity and sign-aligned before uncertainty is calibrated.
    Pointwise bootstrap standard errors are combined with a studentized maximum
    absolute deviation over the observed time-by-dimension grid.

    Component scope calibrates each FPC separately across its full observed
    grid. Family scope uses a single maximum across all requested FPCs and the
    grid, providing a more conservative familywise band.

    Because individual eigenfunctions can be weakly identified when adjacent
    eigenvalues are close, relative_gap_threshold optionally performs an
    explicit descriptive identifiability screen. No universal threshold is
    imposed by default. If supplied, on_near_tie controls whether a retained
    FPC meeting the threshold raises, warns, or is recorded only.

    These are bootstrap-calibrated simultaneous uncertainty bands over the
    observed grid. They do not assert exact finite-sample coverage or coverage
    between sampled time points.
    """

    validate_trajectory_set(trajectories, require_complete=True)
    if isinstance(n_bootstrap, bool) or not isinstance(n_bootstrap, (int, np.integer)):
        raise TypeError("n_bootstrap must be an integer")
    n_bootstrap = int(n_bootstrap)
    if n_bootstrap < 20:
        raise ValueError("n_bootstrap must be at least 20")
    if isinstance(n_components, bool) or not isinstance(n_components, (int, np.integer)):
        raise TypeError("n_components must be an integer")
    n_components = int(n_components)
    max_nonzero_rank = min(
        trajectories.n_curves - 1,
        trajectories.n_time * trajectories.n_dimensions,
    )
    if n_components < 1 or n_components > max_nonzero_rank:
        raise ValueError(
            f"n_components must be in [1, {max_nonzero_rank}] for non-zero-rank FPCA"
        )
    if resample_unit not in {"curve", "participant"}:
        raise ValueError("resample_unit must be 'curve' or 'participant'")
    if resample_unit == "participant" and not participant_column:
        raise ValueError("participant_column is required for participant bootstrap")
    if not 0 < confidence_level < 1:
        raise ValueError("confidence_level must lie in (0, 1)")
    if simultaneous_scope not in {"component", "family"}:
        raise ValueError("simultaneous_scope must be 'component' or 'family'")
    if on_near_tie not in {"error", "warn", "ignore"}:
        raise ValueError("on_near_tie must be 'error', 'warn', or 'ignore'")

    if relative_gap_threshold is not None:
        if isinstance(relative_gap_threshold, bool):
            raise TypeError("relative_gap_threshold must be numeric or None")
        relative_gap_threshold = float(relative_gap_threshold)
        if not 0 <= relative_gap_threshold < 1:
            raise ValueError("relative_gap_threshold must be in [0, 1)")
        minimum_relative_gaps = _minimum_adjacent_relative_gaps(
            trajectories,
            n_components=n_components,
            scaling=scaling,
        )
        near_tie_components = np.flatnonzero(
            minimum_relative_gaps <= relative_gap_threshold
        )
        if len(near_tie_components):
            labels = [int(index + 1) for index in near_tie_components]
            message = (
                "individual FPC simultaneous bands are weakly identified under "
                f"the supplied relative-gap threshold for component(s) {labels}; "
                "inspect eigenspace/subspace stability before interpreting axes"
            )
            if on_near_tie == "error":
                raise ValueError(message)
            if on_near_tie == "warn":
                warnings.warn(message, RuntimeWarning, stacklevel=2)
    else:
        minimum_relative_gaps = np.full(n_components, np.nan, dtype=float)
        near_tie_components = np.array([], dtype=int)

    reference = _fit_for_trajectories(
        trajectories,
        n_components=n_components,
        scaling=scaling,
    )
    rng = np.random.default_rng(random_state)
    samples = np.empty(
        (
            n_bootstrap,
            n_components,
            trajectories.n_time,
            trajectories.n_dimensions,
        ),
        dtype=float,
    )
    similarities = np.empty((n_bootstrap, n_components), dtype=float)

    for bootstrap_index in range(n_bootstrap):
        if resample_unit == "curve":
            sample = _bootstrap_curves(trajectories, rng)
        else:
            sample = _bootstrap_participants(
                trajectories,
                rng,
                participant_column=str(participant_column),
            )

        candidate = _fit_for_trajectories(
            sample,
            n_components=n_components,
            scaling=scaling,
        )
        assignments, signed_similarity = match_fpca_components(
            reference,
            candidate,
            n_components=n_components,
        )
        similarities[bootstrap_index] = np.abs(signed_similarity)
        for component, matched in enumerate(assignments):
            sign = 1.0 if signed_similarity[component] >= 0 else -1.0
            samples[bootstrap_index, component] = sign * candidate.components[matched]

    pointwise_se = np.std(samples, axis=0, ddof=1)
    deviations = samples - reference.components[None, :, :, :]
    positive_variance = pointwise_se > np.finfo(float).eps
    reference_scale = max(1.0, float(np.max(np.abs(reference.components))))
    zero_tolerance = 100.0 * np.finfo(float).eps * reference_scale
    degenerate_discrepancy = (~positive_variance) & (
        np.max(np.abs(deviations), axis=0) > zero_tolerance
    )
    if np.any(degenerate_discrepancy):
        raise RuntimeError(
            "bootstrap component uncertainty is degenerate at a zero-variance "
            "grid cell with non-zero reference discrepancy"
        )

    standardized = np.zeros_like(deviations)
    np.divide(
        deviations,
        pointwise_se[None, :, :, :],
        out=standardized,
        where=positive_variance[None, :, :, :],
    )
    max_statistics = np.max(np.abs(standardized), axis=(2, 3))

    if simultaneous_scope == "component":
        critical_values = np.quantile(
            max_statistics,
            confidence_level,
            axis=0,
            method="higher",
        )
    else:
        critical = float(
            np.quantile(
                np.max(max_statistics, axis=1),
                confidence_level,
                method="higher",
            )
        )
        critical_values = np.full(n_components, critical, dtype=float)

    half_width = critical_values[:, None, None] * pointwise_se
    lower = reference.components - half_width
    upper = reference.components + half_width

    return FPCAComponentBandResult(
        reference=reference,
        lower=lower,
        upper=upper,
        pointwise_se=pointwise_se,
        critical_values=np.asarray(critical_values, dtype=float),
        max_statistics=max_statistics,
        similarities=similarities,
        confidence_level=float(confidence_level),
        simultaneous_scope=simultaneous_scope,
        resampling_unit=resample_unit,
        participant_column=(
            participant_column if resample_unit == "participant" else None
        ),
        relative_gap_threshold=relative_gap_threshold,
        minimum_relative_gaps=minimum_relative_gaps,
        random_state=random_state,
        provenance={
            **dict(trajectories.provenance),
            "fpca_component_band": {
                "method": "matched_sign_aligned_bootstrap_studentized_maximum",
                "n_bootstrap": n_bootstrap,
                "n_components": n_components,
                "scaling": scaling,
                "confidence_level": float(confidence_level),
                "simultaneous_scope": simultaneous_scope,
                "resample_unit": resample_unit,
                "participant_column": (
                    participant_column if resample_unit == "participant" else None
                ),
                "random_state": random_state,
                "simultaneous_domain": "observed_time_by_dimension_grid",
                "continuous_between_grid_points": False,
                "relative_gap_threshold": relative_gap_threshold,
                "on_near_tie": on_near_tie,
                "near_tie_components": [
                    int(index + 1) for index in near_tie_components
                ],
                "component_identity_screened": relative_gap_threshold is not None,
                "coverage_claim": "bootstrap_calibrated_observed_grid_approximation",
            },
        },
    )

eyetrajectoriespy.fpca_component_band_frame

fpca_component_band_frame(result: FPCAComponentBandResult) -> pd.DataFrame

Return long-form values for simultaneous FPC uncertainty bands.

Source code in src/eyetrajectoriespy/component_inference.py
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
def fpca_component_band_frame(
    result: FPCAComponentBandResult,
) -> pd.DataFrame:
    """Return long-form values for simultaneous FPC uncertainty bands."""

    rows: list[dict[str, float | int | str]] = []
    for component in range(result.n_components):
        for dimension, name in enumerate(result.reference.dimension_names):
            for index, time in enumerate(result.reference.time):
                rows.append(
                    {
                        "component": component + 1,
                        "time": float(time),
                        "dimension": name,
                        "reference": float(
                            result.reference.components[component, index, dimension]
                        ),
                        "pointwise_se": float(
                            result.pointwise_se[component, index, dimension]
                        ),
                        "lower": float(result.lower[component, index, dimension]),
                        "upper": float(result.upper[component, index, dimension]),
                    }
                )
    return pd.DataFrame(rows)

eyetrajectoriespy.plot_fpca_component_band

plot_fpca_component_band(result: FPCAComponentBandResult, *, component: int = 0, dimension: str | None = None, ax=None)

Plot an FPC with its bootstrap-calibrated simultaneous uncertainty band.

Source code in src/eyetrajectoriespy/plotting.py
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
def plot_fpca_component_band(
    result: FPCAComponentBandResult,
    *,
    component: int = 0,
    dimension: str | None = None,
    ax=None,
):
    """Plot an FPC with its bootstrap-calibrated simultaneous uncertainty band."""

    if component < 0 or component >= result.n_components:
        raise IndexError("component is outside the banded range")
    if dimension is None:
        dimension = result.reference.dimension_names[0]
    if dimension not in result.reference.dimension_names:
        raise KeyError(f"Unknown dimension {dimension!r}")
    if ax is None:
        _, ax = plt.subplots()

    dim = result.reference.dimension_names.index(dimension)
    time = result.reference.time
    scope = "familywise" if result.simultaneous_scope == "family" else "component-wise"
    ax.fill_between(
        time,
        result.lower[component, :, dim],
        result.upper[component, :, dim],
        alpha=0.2,
        label=f"{100 * result.confidence_level:.1f}% simultaneous band",
    )
    ax.plot(
        time,
        result.reference.components[component, :, dim],
        label="reference FPC",
    )
    ax.set_xlabel(f"Time ({result.reference.time_unit})")
    ax.set_ylabel(f"FPC loading: {dimension}")
    ax.set_title(f"FPC{component + 1} simultaneous band ({scope})")
    ax.legend()
    return ax

eyetrajectoriespy.fpca_component_band_reporting_text

fpca_component_band_reporting_text(result: FPCAComponentBandResult, *, digits: int = 2) -> str

Generate manuscript-oriented wording for simultaneous FPC uncertainty bands.

Source code in src/eyetrajectoriespy/reporting.py
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
def fpca_component_band_reporting_text(
    result: FPCAComponentBandResult,
    *,
    digits: int = 2,
) -> str:
    """Generate manuscript-oriented wording for simultaneous FPC uncertainty bands."""

    median_similarity = np.median(result.similarities, axis=0)
    similarity_text = ", ".join(
        f"FPC{k + 1}={value:.{digits}f}"
        for k, value in enumerate(median_similarity)
    )
    scope = (
        "familywise across the requested FPCs and observed time-by-dimension grid"
        if result.simultaneous_scope == "family"
        else "component-wise across each FPC's observed time-by-dimension grid"
    )
    text = (
        "Functional principal-component shape uncertainty was evaluated with "
        f"{result.n_bootstrap} {result.resampling_unit}-level bootstrap "
        "replicates. Replicate FPCs were matched and sign-aligned to the "
        "full-sample reference, then studentized maximum absolute deviations "
        f"were calibrated at {100 * result.confidence_level:.1f}% {scope}. "
        f"Median matched absolute similarities were {similarity_text}. "
    )
    if result.relative_gap_threshold is None:
        text += (
            "No numerical near-tie threshold was imposed; component "
            "identifiability should therefore be assessed separately with "
            "eigengap and subspace diagnostics. "
        )
    else:
        minimum = ", ".join(
            f"FPC{k + 1}={gap:.{digits + 1}f}"
            for k, gap in enumerate(result.minimum_relative_gaps)
        )
        text += (
            f"The pre-specified relative-gap screen used threshold "
            f"{result.relative_gap_threshold:.{digits + 1}f} "
            f"(minimum adjacent gaps: {minimum}). "
        )
    return (
        text
        + "The band is calibrated over the observed grid and does not establish "
        "continuous-domain coverage between sampled times or substantive "
        "identifiability of a near-tied individual FPC axis."
    )

eyetrajectoriespy.bootstrap_fpca_component_envelopes

bootstrap_fpca_component_envelopes(trajectories: TrajectorySet, *, n_bootstrap: int = 200, n_components: int = 3, scaling: str = 'none', resample_unit: str = 'curve', participant_column: str | None = None, level: float = 0.95, random_state: int | None = 0) -> FPCAComponentEnvelopeResult

Create pointwise descriptive envelopes from matched bootstrap FPCs.

Every bootstrap fit is matched to the full-sample reference components by maximum absolute functional similarity and sign-aligned before pointwise quantiles are calculated.

The returned envelopes summarize resampling variation. They are not simultaneous confidence bands and do not provide calibrated coverage guarantees.

Source code in src/eyetrajectoriespy/stability.py
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
def bootstrap_fpca_component_envelopes(
    trajectories: TrajectorySet,
    *,
    n_bootstrap: int = 200,
    n_components: int = 3,
    scaling: str = "none",
    resample_unit: str = "curve",
    participant_column: str | None = None,
    level: float = 0.95,
    random_state: int | None = 0,
) -> FPCAComponentEnvelopeResult:
    """Create pointwise descriptive envelopes from matched bootstrap FPCs.

    Every bootstrap fit is matched to the full-sample reference components by
    maximum absolute functional similarity and sign-aligned before pointwise
    quantiles are calculated.

    The returned envelopes summarize resampling variation. They are not
    simultaneous confidence bands and do not provide calibrated coverage
    guarantees.
    """

    validate_trajectory_set(trajectories, require_complete=True)
    if n_bootstrap < 2:
        raise ValueError("n_bootstrap must be at least 2")
    if n_components < 1 or n_components > trajectories.n_curves:
        raise ValueError("n_components must be between 1 and number of trajectories")
    if resample_unit not in {"curve", "participant"}:
        raise ValueError("resample_unit must be 'curve' or 'participant'")
    if resample_unit == "participant" and not participant_column:
        raise ValueError("participant_column is required for participant bootstrap")
    if not 0 < level < 1:
        raise ValueError("level must lie in (0, 1)")

    reference = _fit_for_trajectories(
        trajectories,
        n_components=n_components,
        scaling=scaling,
    )
    rng = np.random.default_rng(random_state)
    samples = np.empty(
        (
            n_bootstrap,
            n_components,
            trajectories.n_time,
            trajectories.n_dimensions,
        ),
        dtype=float,
    )
    similarities = np.empty((n_bootstrap, n_components), dtype=float)

    for bootstrap_index in range(n_bootstrap):
        if resample_unit == "curve":
            sample = _bootstrap_curves(trajectories, rng)
        else:
            sample = _bootstrap_participants(
                trajectories,
                rng,
                participant_column=str(participant_column),
            )

        candidate = _fit_for_trajectories(
            sample,
            n_components=n_components,
            scaling=scaling,
        )
        assignments, signed_similarity = match_fpca_components(
            reference,
            candidate,
            n_components=n_components,
        )
        similarities[bootstrap_index] = np.abs(signed_similarity)

        for component, matched in enumerate(assignments):
            sign = 1.0 if signed_similarity[component] >= 0 else -1.0
            samples[bootstrap_index, component] = sign * candidate.components[matched]

    alpha = (1.0 - level) / 2.0
    return FPCAComponentEnvelopeResult(
        reference=reference,
        lower=np.quantile(samples, alpha, axis=0),
        median=np.median(samples, axis=0),
        upper=np.quantile(samples, 1.0 - alpha, axis=0),
        similarities=similarities,
        level=level,
        resampling_unit=resample_unit,
        random_state=random_state,
        provenance={
            "method": "matched_sign_aligned_bootstrap_pointwise_envelope",
            "n_bootstrap": n_bootstrap,
            "n_components": n_components,
            "scaling": scaling,
            "participant_column": participant_column,
            "coverage_claim": "descriptive_pointwise_only",
        },
    )

eyetrajectoriespy.plot_fpca_component_envelope

plot_fpca_component_envelope(result: FPCAComponentEnvelopeResult, *, component: int = 0, dimension: str | None = None, ax=None)

Plot a reference FPC with its descriptive matched-bootstrap envelope.

Source code in src/eyetrajectoriespy/plotting.py
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
def plot_fpca_component_envelope(
    result: FPCAComponentEnvelopeResult,
    *,
    component: int = 0,
    dimension: str | None = None,
    ax=None,
):
    """Plot a reference FPC with its descriptive matched-bootstrap envelope."""

    if component < 0 or component >= result.reference.n_components:
        raise IndexError("component is outside the fitted range")
    if dimension is None:
        dimension = result.reference.dimension_names[0]
    if dimension not in result.reference.dimension_names:
        raise KeyError(f"Unknown dimension {dimension!r}")
    if ax is None:
        _, ax = plt.subplots()

    dim = result.reference.dimension_names.index(dimension)
    time = result.reference.time
    ax.fill_between(
        time,
        result.lower[component, :, dim],
        result.upper[component, :, dim],
        alpha=0.2,
        label=f"{100 * result.level:.0f}% pointwise envelope",
    )
    ax.plot(
        time,
        result.median[component, :, dim],
        linestyle="--",
        label="bootstrap median",
    )
    ax.plot(
        time,
        result.reference.components[component, :, dim],
        label="reference FPC",
    )
    ax.set_xlabel(f"Time ({result.reference.time_unit})")
    ax.set_ylabel(f"FPC loading: {dimension}")
    ax.set_title(f"FPC{component + 1} matched-bootstrap envelope")
    ax.legend()
    return ax

eyetrajectoriespy.fpca_component_envelope_reporting_text

fpca_component_envelope_reporting_text(result: FPCAComponentEnvelopeResult, *, digits: int = 2) -> str

Describe matched-bootstrap FPC envelopes without confidence-band claims.

Source code in src/eyetrajectoriespy/reporting.py
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
def fpca_component_envelope_reporting_text(
    result: FPCAComponentEnvelopeResult,
    *,
    digits: int = 2,
) -> str:
    """Describe matched-bootstrap FPC envelopes without confidence-band claims."""

    median_similarity = np.median(result.similarities, axis=0)
    similarity_text = ", ".join(
        f"FPC{k + 1}={value:.{digits}f}"
        for k, value in enumerate(median_similarity)
    )
    return (
        "Functional principal-component shape uncertainty was summarized with "
        f"{result.n_bootstrap} {result.resampling_unit}-level bootstrap "
        "replicates. Replicate components were matched and sign-aligned to the "
        f"full-sample reference before forming {100 * result.level:.1f}% "
        "pointwise descriptive envelopes. Median matched absolute similarities "
        f"were {similarity_text}. These envelopes are descriptive and are not "
        "simultaneous confidence bands."
    )

FPC score basis uncertainty

eyetrajectoriespy.FPCAScoreUncertaintyResult dataclass

Basis-resampling uncertainty for FPCA scores of fixed target curves.

Source code in src/eyetrajectoriespy/types.py
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
@dataclass(frozen=True)
class FPCAScoreUncertaintyResult:
    """Basis-resampling uncertainty for FPCA scores of fixed target curves."""

    reference: FPCAResult
    target_curve_ids: tuple[str, ...]
    reference_scores: np.ndarray
    bootstrap_scores: np.ndarray
    lower: np.ndarray
    median: np.ndarray
    upper: np.ndarray
    score_se: np.ndarray
    assignments: np.ndarray
    similarities: np.ndarray
    level: float
    resampling_unit: str
    participant_column: str | None
    target_source: str
    random_state: int | None
    provenance: Mapping[str, Any] = field(default_factory=dict)

    @property
    def n_bootstrap(self) -> int:
        return self.bootstrap_scores.shape[0]

    @property
    def n_targets(self) -> int:
        return self.bootstrap_scores.shape[1]

    @property
    def n_components(self) -> int:
        return self.bootstrap_scores.shape[2]

eyetrajectoriespy.bootstrap_fpca_score_uncertainty

bootstrap_fpca_score_uncertainty(trajectories: TrajectorySet, *, targets: TrajectorySet | None = None, n_bootstrap: int = 500, n_components: int = 3, scaling: str = 'none', resample_unit: str = 'curve', participant_column: str | None = None, level: float = 0.95, random_state: int | None = 0) -> FPCAScoreUncertaintyResult

Quantify FPC score sensitivity to re-estimation of the FPCA basis.

The training trajectories are resampled and FPCA is refitted in every bootstrap replicate. Bootstrap FPCs are matched to the full-sample reference by maximum absolute functional similarity and sign-aligned. Fixed target trajectories are then projected into each aligned bootstrap basis.

The returned percentile envelopes quantify basis-resampling uncertainty for fixed target curves. They do not include target measurement error, uncertainty about a latent target trajectory, conditional PACE score uncertainty, future-curve sampling variability, or uncertainty from preprocessing decisions. They are descriptive bootstrap uncertainty summaries rather than exact finite-sample confidence intervals.

Source code in src/eyetrajectoriespy/score_uncertainty.py
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
def bootstrap_fpca_score_uncertainty(
    trajectories: TrajectorySet,
    *,
    targets: TrajectorySet | None = None,
    n_bootstrap: int = 500,
    n_components: int = 3,
    scaling: str = "none",
    resample_unit: str = "curve",
    participant_column: str | None = None,
    level: float = 0.95,
    random_state: int | None = 0,
) -> FPCAScoreUncertaintyResult:
    """Quantify FPC score sensitivity to re-estimation of the FPCA basis.

    The training trajectories are resampled and FPCA is refitted in every
    bootstrap replicate. Bootstrap FPCs are matched to the full-sample
    reference by maximum absolute functional similarity and sign-aligned.
    Fixed target trajectories are then projected into each aligned bootstrap
    basis.

    The returned percentile envelopes quantify *basis-resampling uncertainty*
    for fixed target curves. They do not include target measurement error,
    uncertainty about a latent target trajectory, conditional PACE score
    uncertainty, future-curve sampling variability, or uncertainty from
    preprocessing decisions. They are descriptive bootstrap uncertainty
    summaries rather than exact finite-sample confidence intervals.
    """

    validate_trajectory_set(trajectories, require_complete=True)
    if not np.all(np.isfinite(trajectories.values)):
        raise ValueError("training trajectories must contain only finite values")
    if targets is None:
        target_set = trajectories
        target_source = "training"
    else:
        target_set = targets
        target_source = "external"
        _validate_score_targets(trajectories, target_set)

    if isinstance(n_bootstrap, bool) or not isinstance(
        n_bootstrap, (int, np.integer)
    ):
        raise TypeError("n_bootstrap must be an integer")
    n_bootstrap = int(n_bootstrap)
    if n_bootstrap < 20:
        raise ValueError("n_bootstrap must be at least 20")

    if isinstance(n_components, bool) or not isinstance(
        n_components, (int, np.integer)
    ):
        raise TypeError("n_components must be an integer")
    n_components = int(n_components)
    max_nonzero_rank = min(
        trajectories.n_curves - 1,
        trajectories.n_time * trajectories.n_dimensions,
    )
    if n_components < 1 or n_components > max_nonzero_rank:
        raise ValueError(
            f"n_components must be in [1, {max_nonzero_rank}] for non-zero-rank FPCA"
        )

    if resample_unit not in {"curve", "participant"}:
        raise ValueError("resample_unit must be 'curve' or 'participant'")
    if resample_unit == "curve" and participant_column is not None:
        raise ValueError(
            "participant_column must be None when resample_unit='curve'"
        )
    if resample_unit == "participant" and not participant_column:
        raise ValueError(
            "participant_column is required when resample_unit='participant'"
        )
    if not 0 < level < 1:
        raise ValueError("level must lie in (0, 1)")

    reference = _fit_for_trajectories(
        trajectories,
        n_components=n_components,
        scaling=scaling,
    )
    reference_scores = transform_fpca(reference, target_set)[:, :n_components]

    rng = np.random.default_rng(random_state)
    bootstrap_scores = np.empty(
        (n_bootstrap, target_set.n_curves, n_components),
        dtype=float,
    )
    assignments = np.empty((n_bootstrap, n_components), dtype=int)
    similarities = np.empty((n_bootstrap, n_components), dtype=float)

    for bootstrap_index in range(n_bootstrap):
        if resample_unit == "curve":
            sample = _bootstrap_curves(trajectories, rng)
        else:
            sample = _bootstrap_participants(
                trajectories,
                rng,
                participant_column=str(participant_column),
            )

        if sample.n_curves <= n_components:
            raise ValueError(
                f"bootstrap replicate {bootstrap_index} has too few curves for "
                f"{n_components} non-zero-rank components"
            )

        candidate = _fit_for_trajectories(
            sample,
            n_components=n_components,
            scaling=scaling,
        )
        matched, signed_similarity = match_fpca_components(
            reference,
            candidate,
            n_components=n_components,
        )
        assignments[bootstrap_index] = matched
        similarities[bootstrap_index] = np.abs(signed_similarity)

        candidate_scores = transform_fpca(candidate, target_set)
        orientation = np.where(signed_similarity >= 0.0, 1.0, -1.0)
        aligned_scores = candidate_scores[:, matched] * orientation[None, :]
        if not np.all(np.isfinite(aligned_scores)):
            raise RuntimeError(
                f"bootstrap replicate {bootstrap_index} produced non-finite target scores"
            )
        bootstrap_scores[bootstrap_index] = aligned_scores

    alpha = (1.0 - float(level)) / 2.0
    lower = np.quantile(bootstrap_scores, alpha, axis=0)
    median = np.quantile(bootstrap_scores, 0.5, axis=0)
    upper = np.quantile(bootstrap_scores, 1.0 - alpha, axis=0)
    score_se = np.std(bootstrap_scores, axis=0, ddof=1)

    return FPCAScoreUncertaintyResult(
        reference=reference,
        target_curve_ids=target_set.curve_ids,
        reference_scores=reference_scores,
        bootstrap_scores=bootstrap_scores,
        lower=lower,
        median=median,
        upper=upper,
        score_se=score_se,
        assignments=assignments,
        similarities=similarities,
        level=float(level),
        resampling_unit=resample_unit,
        participant_column=(
            participant_column if resample_unit == "participant" else None
        ),
        target_source=target_source,
        random_state=random_state,
        provenance={
            **dict(trajectories.provenance),
            "fpca_score_uncertainty": {
                "method": "matched_sign_aligned_basis_resampling",
                "interval": "pointwise_percentile_descriptive",
                "n_bootstrap": n_bootstrap,
                "n_components": n_components,
                "scaling": scaling,
                "level": float(level),
                "resample_unit": resample_unit,
                "participant_column": (
                    participant_column if resample_unit == "participant" else None
                ),
                "target_source": target_source,
                "target_curves_fixed": True,
                "component_matching": "maximum_absolute_functional_similarity",
                "sign_alignment": True,
                "includes_basis_estimation_uncertainty": True,
                "includes_target_measurement_error": False,
                "includes_latent_target_curve_uncertainty": False,
                "includes_future_curve_sampling_variability": False,
                "includes_preprocessing_uncertainty": False,
                "full_downstream_uncertainty_propagation": False,
                "random_state": random_state,
            },
        },
    )

eyetrajectoriespy.fpca_score_uncertainty_frame

fpca_score_uncertainty_frame(result: FPCAScoreUncertaintyResult) -> pd.DataFrame

Return tidy fixed-target score uncertainty summaries.

Source code in src/eyetrajectoriespy/score_uncertainty.py
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
def fpca_score_uncertainty_frame(
    result: FPCAScoreUncertaintyResult,
) -> pd.DataFrame:
    """Return tidy fixed-target score uncertainty summaries."""

    rows: list[dict[str, float | int | str]] = []
    median_similarity = np.median(result.similarities, axis=0)
    for target_index, curve_id in enumerate(result.target_curve_ids):
        for component in range(result.n_components):
            rows.append(
                {
                    "curve_id": curve_id,
                    "component": component + 1,
                    "reference_score": float(
                        result.reference_scores[target_index, component]
                    ),
                    "bootstrap_median": float(
                        result.median[target_index, component]
                    ),
                    "bootstrap_se": float(
                        result.score_se[target_index, component]
                    ),
                    "lower": float(result.lower[target_index, component]),
                    "upper": float(result.upper[target_index, component]),
                    "median_matched_abs_similarity": float(
                        median_similarity[component]
                    ),
                }
            )
    return pd.DataFrame(rows)

eyetrajectoriespy.plot_fpca_score_uncertainty

plot_fpca_score_uncertainty(result: FPCAScoreUncertaintyResult, *, component: int = 0, max_targets: int = 30, ax=None)

Plot fixed-target score uncertainty from bootstrap basis re-estimation.

Source code in src/eyetrajectoriespy/plotting.py
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
def plot_fpca_score_uncertainty(
    result: FPCAScoreUncertaintyResult,
    *,
    component: int = 0,
    max_targets: int = 30,
    ax=None,
):
    """Plot fixed-target score uncertainty from bootstrap basis re-estimation."""

    if component < 0 or component >= result.n_components:
        raise IndexError("component is outside the score-uncertainty range")
    if isinstance(max_targets, bool) or not isinstance(max_targets, (int, np.integer)):
        raise TypeError("max_targets must be an integer")
    if max_targets < 1:
        raise ValueError("max_targets must be positive")
    if ax is None:
        _, ax = plt.subplots()

    n = min(max_targets, result.n_targets)
    x = np.arange(n)
    median = result.median[:n, component]
    lower = result.lower[:n, component]
    upper = result.upper[:n, component]
    yerr = np.vstack((median - lower, upper - median))
    ax.errorbar(
        x,
        median,
        yerr=yerr,
        marker="o",
        linestyle="none",
        capsize=3,
        label="bootstrap median + percentile envelope",
    )
    ax.scatter(
        x,
        result.reference_scores[:n, component],
        marker="x",
        label="full-sample reference score",
    )
    ax.axhline(0.0, linestyle="--")
    ax.set_xticks(x)
    ax.set_xticklabels(result.target_curve_ids[:n], rotation=90)
    ax.set_xlabel("Target trajectory")
    ax.set_ylabel(f"FPC{component + 1} score")
    ax.set_title("Basis-resampling FPC score uncertainty")
    ax.legend()
    return ax

eyetrajectoriespy.fpca_score_uncertainty_reporting_text

fpca_score_uncertainty_reporting_text(result: FPCAScoreUncertaintyResult, *, digits: int = 3) -> str

Generate manuscript-oriented wording for basis-resampled FPC scores.

Source code in src/eyetrajectoriespy/reporting.py
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
def fpca_score_uncertainty_reporting_text(
    result: FPCAScoreUncertaintyResult,
    *,
    digits: int = 3,
) -> str:
    """Generate manuscript-oriented wording for basis-resampled FPC scores."""

    median_similarity = np.median(result.similarities, axis=0)
    similarity_text = ", ".join(
        f"FPC{k + 1}={value:.{digits}f}"
        for k, value in enumerate(median_similarity)
    )
    settings = result.provenance.get("fpca_score_uncertainty", {})
    scaling = settings.get("scaling", "unknown")
    return (
        f"FPC score sensitivity to basis estimation was evaluated with "
        f"{result.n_bootstrap} {result.resampling_unit}-level bootstrap "
        f"refits (scaling={scaling!r}) for {result.n_targets} fixed "
        f"{result.target_source} target trajectory/trajectories. Bootstrap "
        "components were matched and sign-aligned to the full-sample reference "
        f"(median absolute similarities: {similarity_text}). Pointwise "
        f"{100 * result.level:.1f}% percentile envelopes summarize variation "
        "in target scores induced by re-estimation of the FPCA basis. These "
        "envelopes do not include target measurement error, uncertainty in a "
        "latent target curve, future-curve sampling variability, preprocessing "
        "uncertainty, or full downstream-model uncertainty propagation."
    )

FPCA spectrum uncertainty

eyetrajectoriespy.FPCASpectrumUncertaintyResult dataclass

Bootstrap uncertainty for matched FPCA eigenvalues and variance spectra.

Source code in src/eyetrajectoriespy/types.py
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
@dataclass(frozen=True)
class FPCASpectrumUncertaintyResult:
    """Bootstrap uncertainty for matched FPCA eigenvalues and variance spectra."""

    reference: FPCAResult
    bootstrap_eigenvalues: np.ndarray
    bootstrap_explained_variance_ratio: np.ndarray
    bootstrap_cumulative_variance_ratio: np.ndarray
    eigenvalue_se: np.ndarray
    eigenvalue_critical_values: np.ndarray
    eigenvalue_lower: np.ndarray
    eigenvalue_upper: np.ndarray
    eigenvalue_max_statistics: np.ndarray
    explained_variance_ratio_se: np.ndarray
    explained_variance_ratio_critical_values: np.ndarray
    explained_variance_ratio_lower: np.ndarray
    explained_variance_ratio_upper: np.ndarray
    explained_variance_ratio_max_statistics: np.ndarray
    cumulative_variance_ratio_se: np.ndarray
    cumulative_variance_ratio_critical_values: np.ndarray
    cumulative_variance_ratio_lower: np.ndarray
    cumulative_variance_ratio_upper: np.ndarray
    cumulative_variance_ratio_max_statistics: np.ndarray
    assignments: np.ndarray
    similarities: np.ndarray
    confidence_level: float
    simultaneous_scope: str
    resampling_unit: str
    participant_column: str | None
    random_state: int | None
    provenance: Mapping[str, Any] = field(default_factory=dict)

    @property
    def n_bootstrap(self) -> int:
        return self.bootstrap_eigenvalues.shape[0]

    @property
    def n_components(self) -> int:
        return self.bootstrap_eigenvalues.shape[1]

eyetrajectoriespy.bootstrap_fpca_spectrum_uncertainty

bootstrap_fpca_spectrum_uncertainty(trajectories: TrajectorySet, *, n_bootstrap: int = 500, n_components: int = 3, scaling: str = 'none', resample_unit: str = 'curve', participant_column: str | None = None, confidence_level: float = 0.95, simultaneous_scope: str = 'component', random_state: int | None = 0) -> FPCASpectrumUncertaintyResult

Bootstrap uncertainty for matched FPCA eigenvalues and variance ratios.

Bootstrap FPCs are matched to the full-sample reference by maximum absolute functional similarity before eigenvalues and explained-variance ratios are attached to reference component identities.

Component scope calibrates each component separately. Family scope controls the maximum across all requested components within each spectrum metric: eigenvalue, explained-variance ratio, or cumulative explained variance. It is not a joint guarantee across the three different metrics.

Intervals are symmetric studentized bootstrap approximations around the full-sample estimate. They are not clipped to parameter support, so a finite sample interval may extend below zero or outside [0, 1].

Source code in src/eyetrajectoriespy/spectrum_inference.py
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
def bootstrap_fpca_spectrum_uncertainty(
    trajectories: TrajectorySet,
    *,
    n_bootstrap: int = 500,
    n_components: int = 3,
    scaling: str = "none",
    resample_unit: str = "curve",
    participant_column: str | None = None,
    confidence_level: float = 0.95,
    simultaneous_scope: str = "component",
    random_state: int | None = 0,
) -> FPCASpectrumUncertaintyResult:
    """Bootstrap uncertainty for matched FPCA eigenvalues and variance ratios.

    Bootstrap FPCs are matched to the full-sample reference by maximum absolute
    functional similarity before eigenvalues and explained-variance ratios are
    attached to reference component identities.

    Component scope calibrates each component separately. Family scope controls
    the maximum across all requested components within each spectrum metric:
    eigenvalue, explained-variance ratio, or cumulative explained variance.
    It is not a joint guarantee across the three different metrics.

    Intervals are symmetric studentized bootstrap approximations around the
    full-sample estimate. They are not clipped to parameter support, so a finite
    sample interval may extend below zero or outside [0, 1].
    """

    validate_trajectory_set(trajectories, require_complete=True)
    if isinstance(n_bootstrap, bool) or not isinstance(n_bootstrap, (int, np.integer)):
        raise TypeError("n_bootstrap must be an integer")
    n_bootstrap = int(n_bootstrap)
    if n_bootstrap < 20:
        raise ValueError("n_bootstrap must be at least 20")

    if isinstance(n_components, bool) or not isinstance(n_components, (int, np.integer)):
        raise TypeError("n_components must be an integer")
    n_components = int(n_components)
    max_nonzero_rank = min(
        trajectories.n_curves - 1,
        trajectories.n_time * trajectories.n_dimensions,
    )
    if n_components < 1 or n_components > max_nonzero_rank:
        raise ValueError(
            f"n_components must be in [1, {max_nonzero_rank}] for non-zero-rank FPCA"
        )

    if resample_unit not in {"curve", "participant"}:
        raise ValueError("resample_unit must be 'curve' or 'participant'")
    if resample_unit == "curve" and participant_column is not None:
        raise ValueError(
            "participant_column must be None when resample_unit='curve'"
        )
    if resample_unit == "participant" and not participant_column:
        raise ValueError(
            "participant_column is required when resample_unit='participant'"
        )
    if not 0 < confidence_level < 1:
        raise ValueError("confidence_level must lie in (0, 1)")
    if simultaneous_scope not in {"component", "family"}:
        raise ValueError("simultaneous_scope must be 'component' or 'family'")

    reference = _fit_for_trajectories(
        trajectories,
        n_components=n_components,
        scaling=scaling,
    )
    reference_eigenvalues = np.asarray(
        reference.explained_variance[:n_components],
        dtype=float,
    )
    reference_ratios = np.asarray(
        reference.explained_variance_ratio[:n_components],
        dtype=float,
    )
    reference_cumulative = np.cumsum(reference_ratios)

    rng = np.random.default_rng(random_state)
    eigenvalues = np.empty((n_bootstrap, n_components), dtype=float)
    ratios = np.empty_like(eigenvalues)
    assignments = np.empty((n_bootstrap, n_components), dtype=int)
    similarities = np.empty((n_bootstrap, n_components), dtype=float)

    for bootstrap_index in range(n_bootstrap):
        if resample_unit == "curve":
            sample = _bootstrap_curves(trajectories, rng)
        else:
            sample = _bootstrap_participants(
                trajectories,
                rng,
                participant_column=str(participant_column),
            )
        if sample.n_curves <= n_components:
            raise ValueError(
                f"bootstrap replicate {bootstrap_index} has too few curves for "
                f"{n_components} non-zero-rank components"
            )

        candidate = _fit_for_trajectories(
            sample,
            n_components=n_components,
            scaling=scaling,
        )
        matched, signed_similarity = match_fpca_components(
            reference,
            candidate,
            n_components=n_components,
        )
        assignments[bootstrap_index] = matched
        similarities[bootstrap_index] = np.abs(signed_similarity)
        eigenvalues[bootstrap_index] = candidate.explained_variance[matched]
        ratios[bootstrap_index] = candidate.explained_variance_ratio[matched]

        # Cumulative explained variance retains its conventional descending-rank
        # meaning. It is intentionally not reordered by component-shape matching.
        # This keeps "top k components explain ..." invariant to within-block
        # swaps of near-tied eigenfunctions.
        if bootstrap_index == 0:
            cumulative = np.empty((n_bootstrap, n_components), dtype=float)
        cumulative[bootstrap_index] = np.cumsum(
            candidate.explained_variance_ratio[:n_components]
        )

    eig_se, eig_crit, eig_lower, eig_upper, eig_stats = _calibrate_spectrum_metric(
        eigenvalues,
        reference_eigenvalues,
        confidence_level=confidence_level,
        simultaneous_scope=simultaneous_scope,
        metric_name="eigenvalue",
    )
    ratio_se, ratio_crit, ratio_lower, ratio_upper, ratio_stats = (
        _calibrate_spectrum_metric(
            ratios,
            reference_ratios,
            confidence_level=confidence_level,
            simultaneous_scope=simultaneous_scope,
            metric_name="explained_variance_ratio",
        )
    )
    cum_se, cum_crit, cum_lower, cum_upper, cum_stats = _calibrate_spectrum_metric(
        cumulative,
        reference_cumulative,
        confidence_level=confidence_level,
        simultaneous_scope=simultaneous_scope,
        metric_name="cumulative_explained_variance_ratio",
    )

    return FPCASpectrumUncertaintyResult(
        reference=reference,
        bootstrap_eigenvalues=eigenvalues,
        bootstrap_explained_variance_ratio=ratios,
        bootstrap_cumulative_variance_ratio=cumulative,
        eigenvalue_se=eig_se,
        eigenvalue_critical_values=eig_crit,
        eigenvalue_lower=eig_lower,
        eigenvalue_upper=eig_upper,
        eigenvalue_max_statistics=eig_stats,
        explained_variance_ratio_se=ratio_se,
        explained_variance_ratio_critical_values=ratio_crit,
        explained_variance_ratio_lower=ratio_lower,
        explained_variance_ratio_upper=ratio_upper,
        explained_variance_ratio_max_statistics=ratio_stats,
        cumulative_variance_ratio_se=cum_se,
        cumulative_variance_ratio_critical_values=cum_crit,
        cumulative_variance_ratio_lower=cum_lower,
        cumulative_variance_ratio_upper=cum_upper,
        cumulative_variance_ratio_max_statistics=cum_stats,
        assignments=assignments,
        similarities=similarities,
        confidence_level=float(confidence_level),
        simultaneous_scope=simultaneous_scope,
        resampling_unit=resample_unit,
        participant_column=(
            participant_column if resample_unit == "participant" else None
        ),
        random_state=random_state,
        provenance={
            **dict(trajectories.provenance),
            "fpca_spectrum_uncertainty": {
                "method": "matched_bootstrap_studentized_spectrum",
                "n_bootstrap": n_bootstrap,
                "n_components": n_components,
                "scaling": scaling,
                "confidence_level": float(confidence_level),
                "simultaneous_scope": simultaneous_scope,
                "familywise_domain": (
                    "requested_components_within_each_metric"
                    if simultaneous_scope == "family"
                    else "one_component_within_each_metric"
                ),
                "joint_across_metrics": False,
                "resample_unit": resample_unit,
                "participant_column": (
                    participant_column if resample_unit == "participant" else None
                ),
                "random_state": random_state,
                "component_matching": "maximum_absolute_functional_similarity",
                "individual_spectrum_order": "matched_reference_component_identity",
                "cumulative_spectrum_order": "descending_eigenvalue_rank",
                "support_clipping": False,
                "coverage_claim": "bootstrap_studentized_approximation",
            },
        },
    )

eyetrajectoriespy.fpca_spectrum_uncertainty_frame

fpca_spectrum_uncertainty_frame(result: FPCASpectrumUncertaintyResult) -> pd.DataFrame

Return component-level FPCA spectrum estimates and uncertainty intervals.

Source code in src/eyetrajectoriespy/spectrum_inference.py
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
def fpca_spectrum_uncertainty_frame(
    result: FPCASpectrumUncertaintyResult,
) -> pd.DataFrame:
    """Return component-level FPCA spectrum estimates and uncertainty intervals."""

    reference = result.reference
    n = result.n_components
    return pd.DataFrame(
        {
            "component": np.arange(1, n + 1),
            "eigenvalue": reference.explained_variance[:n],
            "eigenvalue_se": result.eigenvalue_se,
            "eigenvalue_lower": result.eigenvalue_lower,
            "eigenvalue_upper": result.eigenvalue_upper,
            "explained_variance_ratio": reference.explained_variance_ratio[:n],
            "explained_variance_ratio_se": result.explained_variance_ratio_se,
            "explained_variance_ratio_lower": result.explained_variance_ratio_lower,
            "explained_variance_ratio_upper": result.explained_variance_ratio_upper,
            "cumulative_variance_ratio": np.cumsum(
                reference.explained_variance_ratio[:n]
            ),
            "cumulative_variance_ratio_se": result.cumulative_variance_ratio_se,
            "cumulative_variance_ratio_lower": result.cumulative_variance_ratio_lower,
            "cumulative_variance_ratio_upper": result.cumulative_variance_ratio_upper,
            "median_matched_abs_similarity": np.median(
                result.similarities,
                axis=0,
            ),
        }
    )

eyetrajectoriespy.plot_fpca_spectrum_uncertainty

plot_fpca_spectrum_uncertainty(result: FPCASpectrumUncertaintyResult, *, metric: str = 'explained_variance_ratio', ax=None)

Plot FPCA spectrum estimates with bootstrap-calibrated uncertainty bars.

Source code in src/eyetrajectoriespy/plotting.py
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
def plot_fpca_spectrum_uncertainty(
    result: FPCASpectrumUncertaintyResult,
    *,
    metric: str = "explained_variance_ratio",
    ax=None,
):
    """Plot FPCA spectrum estimates with bootstrap-calibrated uncertainty bars."""

    allowed = {
        "eigenvalue",
        "explained_variance_ratio",
        "cumulative_variance_ratio",
    }
    if metric not in allowed:
        raise ValueError(
            "metric must be 'eigenvalue', 'explained_variance_ratio', "
            "or 'cumulative_variance_ratio'"
        )
    if ax is None:
        _, ax = plt.subplots()

    n = result.n_components
    x = np.arange(1, n + 1)
    if metric == "eigenvalue":
        estimate = result.reference.explained_variance[:n]
        lower = result.eigenvalue_lower
        upper = result.eigenvalue_upper
        ylabel = "Eigenvalue"
    elif metric == "explained_variance_ratio":
        estimate = result.reference.explained_variance_ratio[:n]
        lower = result.explained_variance_ratio_lower
        upper = result.explained_variance_ratio_upper
        ylabel = "Explained variance ratio"
    else:
        estimate = np.cumsum(result.reference.explained_variance_ratio[:n])
        lower = result.cumulative_variance_ratio_lower
        upper = result.cumulative_variance_ratio_upper
        ylabel = "Cumulative explained variance ratio"

    yerr = np.vstack((estimate - lower, upper - estimate))
    ax.errorbar(x, estimate, yerr=yerr, marker="o", capsize=3)
    ax.set_xticks(x)
    ax.set_xlabel("Functional principal component")
    ax.set_ylabel(ylabel)
    scope = "familywise" if result.simultaneous_scope == "family" else "component-wise"
    ax.set_title(f"FPCA spectrum uncertainty ({scope})")
    return ax

eyetrajectoriespy.fpca_spectrum_uncertainty_reporting_text

fpca_spectrum_uncertainty_reporting_text(result: FPCASpectrumUncertaintyResult, *, digits: int = 3) -> str

Generate manuscript-oriented wording for FPCA spectrum uncertainty.

Source code in src/eyetrajectoriespy/reporting.py
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
def fpca_spectrum_uncertainty_reporting_text(
    result: FPCASpectrumUncertaintyResult,
    *,
    digits: int = 3,
) -> str:
    """Generate manuscript-oriented wording for FPCA spectrum uncertainty."""

    median_similarity = np.median(result.similarities, axis=0)
    similarity_text = ", ".join(
        f"FPC{k + 1}={value:.{digits}f}"
        for k, value in enumerate(median_similarity)
    )
    scope = (
        "familywise across the requested components within each spectrum metric"
        if result.simultaneous_scope == "family"
        else "component-wise within each spectrum metric"
    )
    first_ratio = result.reference.explained_variance_ratio[0]
    return (
        "FPCA spectrum uncertainty was evaluated with "
        f"{result.n_bootstrap} {result.resampling_unit}-level bootstrap "
        "replicates. Bootstrap FPCs were matched to the full-sample reference "
        "before eigenvalues and variance ratios were attached to component "
        f"identities. Studentized {100 * result.confidence_level:.1f}% "
        f"uncertainty intervals were calibrated {scope}. "
        f"Median matched absolute similarities were {similarity_text}; the "
        f"reference FPC1 explained-variance ratio was {first_ratio:.{digits}f}. "
        "Familywise calibration, when requested, applies separately to "
        "eigenvalues, explained-variance ratios, and cumulative ratios rather "
        "than jointly across all three metrics. Intervals are not clipped to "
        "the mathematical support and are bootstrap approximations rather than "
        "exact finite-sample confidence guarantees."
    )

Near-tied eigenvalues and eigenspaces

eyetrajectoriespy.fpca_eigenvalue_gap_table

fpca_eigenvalue_gap_table(result: FPCAResult, *, relative_gap_threshold: float | None = None) -> pd.DataFrame

Return adjacent retained-eigenvalue gap diagnostics.

The table is descriptive. A relative_gap_threshold is optional and, when supplied, creates an explicit near_tie_flag. No threshold is imposed by default because the meaning of a practically small eigengap depends on the study, sample size, and downstream interpretation.

Only gaps between retained components can be calculated from the supplied fit. To inspect the gap at a proposed retention boundary, fit at least one component beyond that boundary.

Source code in src/eyetrajectoriespy/subspace.py
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
def fpca_eigenvalue_gap_table(
    result: FPCAResult,
    *,
    relative_gap_threshold: float | None = None,
) -> pd.DataFrame:
    """Return adjacent retained-eigenvalue gap diagnostics.

    The table is descriptive. A relative_gap_threshold is optional and, when
    supplied, creates an explicit near_tie_flag. No threshold is imposed by
    default because the meaning of a practically small eigengap depends on the
    study, sample size, and downstream interpretation.

    Only gaps between retained components can be calculated from the supplied
    fit. To inspect the gap at a proposed retention boundary, fit at least one
    component beyond that boundary.
    """

    values = np.asarray(result.explained_variance, dtype=float)
    if values.ndim != 1 or len(values) < 2:
        raise ValueError("At least two fitted components are required for eigengap diagnostics")
    if not np.all(np.isfinite(values)) or np.any(values < 0):
        raise ValueError("explained_variance must contain finite non-negative values")
    if np.any(np.diff(values) > np.finfo(float).eps * max(1.0, float(values[0]))):
        raise ValueError("explained_variance must be non-increasing")

    if relative_gap_threshold is not None:
        threshold = float(relative_gap_threshold)
        if not 0 <= threshold < 1:
            raise ValueError("relative_gap_threshold must be in [0, 1)")
    else:
        threshold = None

    current = values[:-1]
    following = values[1:]
    absolute_gap = current - following
    denominator = np.maximum(current, np.finfo(float).eps)
    relative_gap = absolute_gap / denominator
    ratio = np.divide(
        following,
        denominator,
        out=np.zeros_like(following),
        where=denominator > 0,
    )

    frame = pd.DataFrame(
        {
            "component": np.arange(1, len(values)),
            "next_component": np.arange(2, len(values) + 1),
            "eigenvalue": current,
            "next_eigenvalue": following,
            "absolute_gap": absolute_gap,
            "relative_gap": relative_gap,
            "next_to_current_ratio": ratio,
        }
    )
    if threshold is not None:
        frame["near_tie_flag"] = frame["relative_gap"] <= threshold
        frame["relative_gap_threshold"] = threshold
    return frame

eyetrajectoriespy.compare_fpca_subspaces

compare_fpca_subspaces(reference: FPCAResult, candidate: FPCAResult, *, start_component: int = 0, n_components: int = 2) -> FPCASubspaceComparisonResult

Compare corresponding FPC subspaces using principal angles.

This diagnostic is invariant to sign changes, permutations, and rotations within the selected subspace. It is useful when adjacent eigenvalues are close and individual FPC labels can swap or rotate.

normalized_projector_distance lies in [0, 1], where zero indicates identical subspaces. It is the Frobenius distance between the two projection operators divided by sqrt(2 * n_components).

Source code in src/eyetrajectoriespy/subspace.py
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
def compare_fpca_subspaces(
    reference: FPCAResult,
    candidate: FPCAResult,
    *,
    start_component: int = 0,
    n_components: int = 2,
) -> FPCASubspaceComparisonResult:
    """Compare corresponding FPC subspaces using principal angles.

    This diagnostic is invariant to sign changes, permutations, and rotations
    within the selected subspace. It is useful when adjacent eigenvalues are
    close and individual FPC labels can swap or rotate.

    normalized_projector_distance lies in [0, 1], where zero indicates
    identical subspaces. It is the Frobenius distance between the two
    projection operators divided by sqrt(2 * n_components).
    """

    start, stop = _validate_subspace_inputs(
        reference,
        candidate,
        start_component=start_component,
        n_components=n_components,
    )
    ref = _standardized_block(reference, start=start, stop=stop)
    cand = _standardized_block(candidate, start=start, stop=stop)

    singular_values = np.linalg.svd(ref @ cand.T, compute_uv=False)
    principal_cosines = np.clip(singular_values, 0.0, 1.0)
    principal_angles = np.degrees(np.arccos(principal_cosines))

    squared_sines = np.maximum(0.0, 1.0 - principal_cosines**2)
    projector_distance = float(np.sqrt(2.0 * np.sum(squared_sines)))
    normalized_distance = float(projector_distance / np.sqrt(2.0 * n_components))

    indices = tuple(range(start, stop))
    return FPCASubspaceComparisonResult(
        reference=reference,
        candidate=candidate,
        component_indices=indices,
        principal_cosines=principal_cosines,
        principal_angles_degrees=principal_angles,
        projector_distance_frobenius=projector_distance,
        normalized_projector_distance=normalized_distance,
        provenance={
            "method": "principal_angles_weighted_functional_subspace",
            "start_component_zero_based": start_component,
            "n_components": n_components,
            "standardization": "fit_specific_dimension_scale",
            "rotation_invariant_within_selected_subspace": True,
        },
    )

eyetrajectoriespy.bootstrap_fpca_subspace_stability

bootstrap_fpca_subspace_stability(trajectories: TrajectorySet, *, start_component: int = 0, n_components: int = 2, n_bootstrap: int = 200, scaling: str = 'none', resample_unit: str = 'curve', participant_column: str | None = None, random_state: int | None = 0) -> FPCASubspaceStabilityResult

Bootstrap stability of a contiguous FPCA eigenspace.

The same ranked component block is compared between the full-sample fit and each bootstrap fit using principal angles. Individual FPC matching is not required, so rotations or swaps within the selected block do not create false instability.

The result is descriptive and does not test equality of population eigenspaces.

Source code in src/eyetrajectoriespy/subspace.py
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
def bootstrap_fpca_subspace_stability(
    trajectories: TrajectorySet,
    *,
    start_component: int = 0,
    n_components: int = 2,
    n_bootstrap: int = 200,
    scaling: str = "none",
    resample_unit: str = "curve",
    participant_column: str | None = None,
    random_state: int | None = 0,
) -> FPCASubspaceStabilityResult:
    """Bootstrap stability of a contiguous FPCA eigenspace.

    The same ranked component block is compared between the full-sample fit and
    each bootstrap fit using principal angles. Individual FPC matching is not
    required, so rotations or swaps within the selected block do not create
    false instability.

    The result is descriptive and does not test equality of population
    eigenspaces.
    """

    validate_trajectory_set(trajectories, require_complete=True)
    if isinstance(start_component, bool) or not isinstance(start_component, int):
        raise TypeError("start_component must be an integer")
    if isinstance(n_components, bool) or not isinstance(n_components, int):
        raise TypeError("n_components must be an integer")
    if start_component < 0:
        raise ValueError("start_component must be non-negative")
    if n_components < 1:
        raise ValueError("n_components must be positive")
    if isinstance(n_bootstrap, bool) or not isinstance(n_bootstrap, int) or n_bootstrap < 2:
        raise ValueError("n_bootstrap must be an integer >= 2")
    if resample_unit not in {"curve", "participant"}:
        raise ValueError("resample_unit must be 'curve' or 'participant'")
    if resample_unit == "participant" and not participant_column:
        raise ValueError("participant_column is required for participant bootstrap")

    stop = start_component + n_components
    if stop > trajectories.n_curves - 1:
        raise ValueError(
            "Requested subspace exceeds the maximum non-zero sample FPCA rank; "
            "reduce start_component or n_components"
        )

    reference = _fit_for_trajectories(
        trajectories,
        n_components=stop,
        scaling=scaling,
    )
    rng = np.random.default_rng(random_state)
    principal_cosines = np.empty((n_bootstrap, n_components), dtype=float)
    principal_angles = np.empty_like(principal_cosines)
    projector_distance = np.empty(n_bootstrap, dtype=float)
    normalized_distance = np.empty(n_bootstrap, dtype=float)

    for bootstrap_index in range(n_bootstrap):
        if resample_unit == "curve":
            sample = _bootstrap_curves(trajectories, rng)
        else:
            sample = _bootstrap_participants(
                trajectories,
                rng,
                participant_column=str(participant_column),
            )
        if stop > sample.n_curves - 1:
            raise ValueError(
                f"Bootstrap replicate {bootstrap_index} has insufficient non-zero "
                "sample rank for the requested subspace"
            )
        candidate = _fit_for_trajectories(
            sample,
            n_components=stop,
            scaling=scaling,
        )
        comparison = compare_fpca_subspaces(
            reference,
            candidate,
            start_component=start_component,
            n_components=n_components,
        )
        principal_cosines[bootstrap_index] = comparison.principal_cosines
        principal_angles[bootstrap_index] = comparison.principal_angles_degrees
        projector_distance[bootstrap_index] = comparison.projector_distance_frobenius
        normalized_distance[bootstrap_index] = comparison.normalized_projector_distance

    return FPCASubspaceStabilityResult(
        reference=reference,
        component_indices=tuple(range(start_component, stop)),
        principal_cosines=principal_cosines,
        principal_angles_degrees=principal_angles,
        projector_distance_frobenius=projector_distance,
        normalized_projector_distance=normalized_distance,
        resampling_unit=resample_unit,
        random_state=random_state,
        provenance={
            "method": "bootstrap_principal_angle_subspace_stability",
            "n_bootstrap": n_bootstrap,
            "start_component_zero_based": start_component,
            "n_components": n_components,
            "scaling": scaling,
            "participant_column": participant_column,
            "scientific_warning": (
                "Subspace stability is descriptive and does not establish that "
                "individual FPC labels within a near-tied block are identifiable."
            ),
        },
    )

eyetrajectoriespy.summarise_fpca_subspace_stability

summarise_fpca_subspace_stability(result: FPCASubspaceStabilityResult, *, interval: tuple[float, float] = (0.025, 0.975)) -> pd.DataFrame

Summarize bootstrap principal-angle and projector-distance stability.

Source code in src/eyetrajectoriespy/subspace.py
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
def summarise_fpca_subspace_stability(
    result: FPCASubspaceStabilityResult,
    *,
    interval: tuple[float, float] = (0.025, 0.975),
) -> pd.DataFrame:
    """Summarize bootstrap principal-angle and projector-distance stability."""

    low, high = interval
    if not 0 <= low < high <= 1:
        raise ValueError("interval must satisfy 0 <= low < high <= 1")

    minimum_cosine = np.min(result.principal_cosines, axis=1)
    maximum_angle = np.max(result.principal_angles_degrees, axis=1)
    distance = result.normalized_projector_distance
    indices = result.component_indices
    return pd.DataFrame(
        [
            {
                "component_start": indices[0] + 1,
                "component_end": indices[-1] + 1,
                "n_components": len(indices),
                "median_min_principal_cosine": float(np.median(minimum_cosine)),
                "min_principal_cosine_interval_low": float(np.quantile(minimum_cosine, low)),
                "min_principal_cosine_interval_high": float(np.quantile(minimum_cosine, high)),
                "median_max_principal_angle_degrees": float(np.median(maximum_angle)),
                "max_principal_angle_interval_low": float(np.quantile(maximum_angle, low)),
                "max_principal_angle_interval_high": float(np.quantile(maximum_angle, high)),
                "median_normalized_projector_distance": float(np.median(distance)),
                "normalized_projector_distance_interval_low": float(np.quantile(distance, low)),
                "normalized_projector_distance_interval_high": float(np.quantile(distance, high)),
            }
        ]
    )

eyetrajectoriespy.plot_fpca_subspace_stability

plot_fpca_subspace_stability(result: FPCASubspaceStabilityResult, *, metric: str = 'normalized_projector_distance', ax=None)

Plot bootstrap eigenspace stability across resamples.

Source code in src/eyetrajectoriespy/plotting.py
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
def plot_fpca_subspace_stability(
    result: FPCASubspaceStabilityResult,
    *,
    metric: str = "normalized_projector_distance",
    ax=None,
):
    """Plot bootstrap eigenspace stability across resamples."""

    allowed = {
        "normalized_projector_distance",
        "max_principal_angle_degrees",
        "min_principal_cosine",
    }
    if metric not in allowed:
        raise ValueError(
            "metric must be 'normalized_projector_distance', "
            "'max_principal_angle_degrees', or 'min_principal_cosine'"
        )
    if ax is None:
        _, ax = plt.subplots()

    if metric == "normalized_projector_distance":
        values = result.normalized_projector_distance
        ylabel = "Normalized projector distance"
    elif metric == "max_principal_angle_degrees":
        values = np.max(result.principal_angles_degrees, axis=1)
        ylabel = "Maximum principal angle (degrees)"
    else:
        values = np.min(result.principal_cosines, axis=1)
        ylabel = "Minimum principal cosine"

    x = np.arange(1, result.n_bootstrap + 1)
    ax.plot(x, values, marker="o", linestyle="none")
    ax.set_xlabel("Bootstrap replicate")
    ax.set_ylabel(ylabel)
    start = result.component_indices[0] + 1
    end = result.component_indices[-1] + 1
    ax.set_title(f"FPCA subspace stability: FPC{start}–FPC{end}")
    return ax

eyetrajectoriespy.fpca_eigengap_reporting_text

fpca_eigengap_reporting_text(result: FPCAResult, *, relative_gap_threshold: float | None = None, digits: int = 3) -> str

Generate descriptive text for adjacent retained FPCA eigengaps.

Source code in src/eyetrajectoriespy/reporting.py
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
def fpca_eigengap_reporting_text(
    result: FPCAResult,
    *,
    relative_gap_threshold: float | None = None,
    digits: int = 3,
) -> str:
    """Generate descriptive text for adjacent retained FPCA eigengaps."""

    table = fpca_eigenvalue_gap_table(
        result,
        relative_gap_threshold=relative_gap_threshold,
    )
    row = table.loc[table["relative_gap"].idxmin()]
    text = (
        f"The smallest adjacent retained FPCA eigengap was between FPC"
        f"{int(row['component'])} and FPC{int(row['next_component'])} "
        f"(relative gap={row['relative_gap']:.{digits}f}; "
        f"next/current eigenvalue ratio={row['next_to_current_ratio']:.{digits}f})."
    )
    if relative_gap_threshold is not None:
        count = int(table["near_tie_flag"].sum())
        text += (
            f" Using the pre-specified relative-gap threshold "
            f"{relative_gap_threshold:.{digits}f}, {count} adjacent retained "
            "pair(s) met the descriptive near-tie criterion."
        )
    return text

eyetrajectoriespy.fpca_subspace_stability_reporting_text

fpca_subspace_stability_reporting_text(result: FPCASubspaceStabilityResult, *, digits: int = 3) -> str

Generate descriptive text for bootstrap FPCA eigenspace stability.

Source code in src/eyetrajectoriespy/reporting.py
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
def fpca_subspace_stability_reporting_text(
    result: FPCASubspaceStabilityResult,
    *,
    digits: int = 3,
) -> str:
    """Generate descriptive text for bootstrap FPCA eigenspace stability."""

    summary = summarise_fpca_subspace_stability(result)
    row = summary.iloc[0]
    start = int(row["component_start"])
    end = int(row["component_end"])
    return (
        f"Bootstrap FPCA subspace stability evaluated FPC{start}–FPC{end} using "
        f"{result.n_bootstrap} {result.resampling_unit}-level resamples. The "
        f"median minimum principal cosine was "
        f"{row['median_min_principal_cosine']:.{digits}f}, the median maximum "
        f"principal angle was {row['median_max_principal_angle_degrees']:.{digits}f} "
        f"degrees, and the median normalized projector distance was "
        f"{row['median_normalized_projector_distance']:.{digits}f}. These are "
        "descriptive eigenspace-stability summaries; they do not establish "
        "identifiability of individual FPC labels within the selected block."
    )

Split-conformal FPCA anomaly review

eyetrajectoriespy.ConformalFunctionalAnomalyResult dataclass

Split-conformal anomaly p-values for new functional trajectories.

Source code in src/eyetrajectoriespy/types.py
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
@dataclass(frozen=True)
class ConformalFunctionalAnomalyResult:
    """Split-conformal anomaly p-values for new functional trajectories."""

    reference: FPCAResult
    calibration_curve_ids: tuple[str, ...]
    target_curve_ids: tuple[str, ...]
    calibration_scores: np.ndarray
    target_scores: np.ndarray
    p_values: np.ndarray
    review_flags: np.ndarray
    alpha: float
    nonconformity: str
    mahalanobis_covariance: str | None
    n_components: int
    scaling: str
    provenance: Mapping[str, Any] = field(default_factory=dict)

    @property
    def n_calibration(self) -> int:
        return self.calibration_scores.shape[0]

    @property
    def n_targets(self) -> int:
        return self.target_scores.shape[0]

    @property
    def minimum_attainable_p(self) -> float:
        return 1.0 / (self.n_calibration + 1.0)

eyetrajectoriespy.split_conformal_fpca_anomaly

split_conformal_fpca_anomaly(proper_training: TrajectorySet, calibration: TrajectorySet, targets: TrajectorySet, *, n_components: int = 3, scaling: str = 'none', nonconformity: str = 'reconstruction_rmse', mahalanobis_covariance: str | None = None, alpha: float = 0.05, random_state: int | None = 0) -> ConformalFunctionalAnomalyResult

Compute marginal split-conformal anomaly p-values for new trajectories.

The FPCA/MFPCA reference and any score-space covariance estimator are fitted on the proper-training set only. Calibration and target trajectories are scored without refitting the reference.

P-values use the conservative split-conformal rule

(1 + number of calibration scores >= target score) / (n_calibration + 1).

Review flags are p <= alpha and are never automatic exclusions.

The finite-sample marginal conformal interpretation requires exchangeable inlier trajectories at the curve level and a proper-training/calibration reference population appropriate for the targets. This function does not implement calibration-conditional adjustments or multiple-testing/FDR control.

Source code in src/eyetrajectoriespy/conformal.py
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
def split_conformal_fpca_anomaly(
    proper_training: TrajectorySet,
    calibration: TrajectorySet,
    targets: TrajectorySet,
    *,
    n_components: int = 3,
    scaling: str = "none",
    nonconformity: str = "reconstruction_rmse",
    mahalanobis_covariance: str | None = None,
    alpha: float = 0.05,
    random_state: int | None = 0,
) -> ConformalFunctionalAnomalyResult:
    """Compute marginal split-conformal anomaly p-values for new trajectories.

    The FPCA/MFPCA reference and any score-space covariance estimator are fitted
    on the proper-training set only. Calibration and target trajectories are
    scored without refitting the reference.

    P-values use the conservative split-conformal rule

        (1 + number of calibration scores >= target score) / (n_calibration + 1).

    Review flags are p <= alpha and are never automatic exclusions.

    The finite-sample marginal conformal interpretation requires exchangeable
    inlier trajectories at the curve level and a proper-training/calibration
    reference population appropriate for the targets. This function does not
    implement calibration-conditional adjustments or multiple-testing/FDR
    control.
    """

    validate_trajectory_set(proper_training, require_complete=True)
    if proper_training.n_curves < 2:
        raise ValueError("proper_training must contain at least two trajectories")
    if not np.all(np.isfinite(proper_training.values)):
        raise ValueError("proper_training must contain only finite trajectory values")
    _validate_partition(proper_training, calibration, name="calibration")
    _validate_partition(proper_training, targets, name="targets")
    _validate_disjoint_ids(proper_training, calibration, targets)

    if isinstance(n_components, bool) or not isinstance(
        n_components, (int, np.integer)
    ):
        raise TypeError("n_components must be an integer")
    n_components = int(n_components)
    max_nonzero_rank = min(
        proper_training.n_curves - 1,
        proper_training.n_time * proper_training.n_dimensions,
    )
    if n_components < 1 or n_components > max_nonzero_rank:
        raise ValueError(
            f"n_components must be in [1, {max_nonzero_rank}] for the "
            "proper-training non-zero FPCA rank"
        )
    if scaling not in {"none", "dimension_sd"}:
        raise ValueError("scaling must be 'none' or 'dimension_sd'")
    if nonconformity not in {"reconstruction_rmse", "score_mahalanobis"}:
        raise ValueError(
            "nonconformity must be 'reconstruction_rmse' or 'score_mahalanobis'"
        )
    if not 0 < alpha < 1:
        raise ValueError("alpha must lie in (0, 1)")
    if random_state is not None and (
        isinstance(random_state, bool) or not isinstance(random_state, (int, np.integer))
    ):
        raise TypeError("random_state must be an integer or None")

    if nonconformity == "reconstruction_rmse":
        if mahalanobis_covariance is not None:
            raise ValueError(
                "mahalanobis_covariance must be None when "
                "nonconformity='reconstruction_rmse'"
            )
    elif mahalanobis_covariance not in {"empirical", "robust"}:
        raise ValueError(
            "score_mahalanobis requires explicit mahalanobis_covariance="
            "'empirical' or 'robust'"
        )

    reference = _fit_reference(
        proper_training,
        n_components=n_components,
        scaling=scaling,
    )

    if nonconformity == "reconstruction_rmse":
        calibration_scores = _reconstruction_scores(
            reference,
            calibration,
            n_components=n_components,
        )
        target_scores = _reconstruction_scores(
            reference,
            targets,
            n_components=n_components,
        )
    else:
        covariance = _fit_score_covariance(
            reference,
            n_components=n_components,
            method=str(mahalanobis_covariance),
            random_state=None if random_state is None else int(random_state),
        )
        calibration_scores = _mahalanobis_scores(
            reference,
            calibration,
            n_components=n_components,
            covariance=covariance,
        )
        target_scores = _mahalanobis_scores(
            reference,
            targets,
            n_components=n_components,
            covariance=covariance,
        )

    counts = np.sum(
        calibration_scores[None, :] >= target_scores[:, None],
        axis=1,
    )
    p_values = (1.0 + counts.astype(float)) / (calibration.n_curves + 1.0)
    review_flags = p_values <= float(alpha)
    minimum_p = 1.0 / (calibration.n_curves + 1.0)

    return ConformalFunctionalAnomalyResult(
        reference=reference,
        calibration_curve_ids=calibration.curve_ids,
        target_curve_ids=targets.curve_ids,
        calibration_scores=np.asarray(calibration_scores, dtype=float),
        target_scores=np.asarray(target_scores, dtype=float),
        p_values=np.asarray(p_values, dtype=float),
        review_flags=np.asarray(review_flags, dtype=bool),
        alpha=float(alpha),
        nonconformity=nonconformity,
        mahalanobis_covariance=(
            str(mahalanobis_covariance)
            if nonconformity == "score_mahalanobis"
            else None
        ),
        n_components=n_components,
        scaling=scaling,
        provenance={
            **dict(proper_training.provenance),
            "split_conformal_fpca_anomaly": {
                "method": "split_conformal_marginal_p_value",
                "reference_model": "proper_training_fpca",
                "proper_training_n": proper_training.n_curves,
                "calibration_n": calibration.n_curves,
                "target_n": targets.n_curves,
                "n_components": n_components,
                "scaling": scaling,
                "nonconformity": nonconformity,
                "mahalanobis_covariance": (
                    mahalanobis_covariance
                    if nonconformity == "score_mahalanobis"
                    else None
                ),
                "random_state": (
                    None if random_state is None else int(random_state)
                ),
                "alpha": float(alpha),
                "minimum_attainable_p": float(minimum_p),
                "tie_rule": "greater_equal_conservative",
                "exchangeability_unit": "curve",
                "marginal_validity_only": True,
                "calibration_conditional_adjustment": False,
                "multiple_testing_correction": False,
                "fdr_control_claimed": False,
                "automatic_exclusion": False,
                "repeated_trial_warning": (
                    "Curve-level conformal validity does not make repeated trials "
                    "from the same participant independent. Use genuinely "
                    "exchangeable independent units for inferential claims."
                ),
            },
        },
    )

eyetrajectoriespy.conformal_fpca_anomaly_frame

conformal_fpca_anomaly_frame(result: ConformalFunctionalAnomalyResult) -> pd.DataFrame

Return target split-conformal anomaly results as a tidy table.

Source code in src/eyetrajectoriespy/conformal.py
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
def conformal_fpca_anomaly_frame(
    result: ConformalFunctionalAnomalyResult,
) -> pd.DataFrame:
    """Return target split-conformal anomaly results as a tidy table."""

    return pd.DataFrame(
        {
            "curve_id": result.target_curve_ids,
            "nonconformity_score": result.target_scores,
            "conformal_p_value": result.p_values,
            "alpha": np.full(result.n_targets, result.alpha, dtype=float),
            "review_flag": result.review_flags,
            "minimum_attainable_p": np.full(
                result.n_targets,
                result.minimum_attainable_p,
                dtype=float,
            ),
        }
    )

eyetrajectoriespy.plot_conformal_fpca_anomaly

plot_conformal_fpca_anomaly(result: ConformalFunctionalAnomalyResult, *, max_targets: int = 50, ax=None)

Plot marginal split-conformal anomaly p-values for target trajectories.

Source code in src/eyetrajectoriespy/plotting.py
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
def plot_conformal_fpca_anomaly(
    result: ConformalFunctionalAnomalyResult,
    *,
    max_targets: int = 50,
    ax=None,
):
    """Plot marginal split-conformal anomaly p-values for target trajectories."""

    if isinstance(max_targets, bool) or not isinstance(max_targets, (int, np.integer)):
        raise TypeError("max_targets must be an integer")
    if max_targets < 1:
        raise ValueError("max_targets must be positive")
    if ax is None:
        _, ax = plt.subplots()

    n = min(max_targets, result.n_targets)
    x = np.arange(n)
    ax.scatter(x, result.p_values[:n])
    ax.axhline(result.alpha, linestyle="--", label=f"alpha={result.alpha:g}")
    ax.set_xticks(x)
    ax.set_xticklabels(result.target_curve_ids[:n], rotation=90)
    ax.set_xlabel("Target trajectory")
    ax.set_ylabel("Marginal conformal p-value")
    ax.set_ylim(0.0, 1.0)
    ax.set_title("Split-conformal FPCA anomaly review")
    ax.legend()
    return ax

eyetrajectoriespy.conformal_fpca_anomaly_reporting_text

conformal_fpca_anomaly_reporting_text(result: ConformalFunctionalAnomalyResult, *, digits: int = 3) -> str

Generate reporting text for split-conformal FPCA anomaly review.

Source code in src/eyetrajectoriespy/reporting.py
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
def conformal_fpca_anomaly_reporting_text(
    result: ConformalFunctionalAnomalyResult,
    *,
    digits: int = 3,
) -> str:
    """Generate reporting text for split-conformal FPCA anomaly review."""

    flagged = int(np.count_nonzero(result.review_flags))
    covariance = (
        ""
        if result.mahalanobis_covariance is None
        else f" using {result.mahalanobis_covariance} score covariance"
    )
    return (
        f"Split-conformal functional anomaly review fitted an FPCA reference with "
        f"{result.n_components} component(s) on the proper-training set and used "
        f"{result.n_calibration} calibration trajectory(ies). Nonconformity was "
        f"{result.nonconformity}{covariance}. Marginal conformal p-values used the "
        "conservative greater-than-or-equal tie rule; the minimum attainable "
        f"p-value was {result.minimum_attainable_p:.{digits}f}. At alpha="
        f"{result.alpha:.{digits}f}, {flagged} of {result.n_targets} target "
        "trajectory(ies) were flagged for review. Flags are not automatic "
        "exclusions. The marginal conformal interpretation requires curve-level "
        "exchangeability of inlier trajectories. No calibration-conditional "
        "adjustment, multiple-testing correction, or FDR guarantee was applied."
    )

Functional outliers and influence

eyetrajectoriespy.diagnose_fpca_outliers

diagnose_fpca_outliers(result: FPCAResult, trajectories: TrajectorySet, *, n_components: int | None = None, reconstruction_z_threshold: float = 3.5, score_alpha: float = 0.99, score_covariance: str = 'robust', random_state: int | None = 0) -> FunctionalOutlierResult

Screen fitted trajectories using reconstruction and FPCA-score diagnostics.

Review flags are diagnostics only. The package never removes flagged trajectories automatically.

Source code in src/eyetrajectoriespy/outliers.py
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
def diagnose_fpca_outliers(
    result: FPCAResult,
    trajectories: TrajectorySet,
    *,
    n_components: int | None = None,
    reconstruction_z_threshold: float = 3.5,
    score_alpha: float = 0.99,
    score_covariance: str = "robust",
    random_state: int | None = 0,
) -> FunctionalOutlierResult:
    """Screen fitted trajectories using reconstruction and FPCA-score diagnostics.

    Review flags are diagnostics only. The package never removes flagged
    trajectories automatically.
    """

    _validate_fitted_sample(result, trajectories)
    k = _resolve_component_count(result, n_components)
    if reconstruction_z_threshold <= 0:
        raise ValueError("reconstruction_z_threshold must be positive")
    if not 0 < score_alpha < 1:
        raise ValueError("score_alpha must lie in (0, 1)")
    if score_covariance not in {"robust", "empirical"}:
        raise ValueError("score_covariance must be 'robust' or 'empirical'")

    reconstruction = reconstruction_error_by_curve(
        result,
        trajectories,
        n_components=k,
    )
    reconstruction_error = reconstruction["integrated_rmse"].to_numpy(dtype=float)
    reconstruction_z = _robust_upper_z(reconstruction_error)

    scores = np.asarray(result.scores[:, :k], dtype=float)
    if score_covariance == "robust":
        if trajectories.n_curves <= 2 * k:
            raise ValueError(
                "Robust score covariance requires more than 2 * n_components trajectories"
            )
        try:
            covariance = MinCovDet(random_state=random_state).fit(scores)
        except Exception as exc:
            raise ValueError(
                "Robust covariance estimation failed; reduce n_components, inspect "
                "score degeneracy, or request score_covariance='empirical' explicitly"
            ) from exc
    else:
        if trajectories.n_curves <= k:
            raise ValueError(
                "Empirical score covariance requires more trajectories than components"
            )
        covariance = EmpiricalCovariance().fit(scores)

    mahalanobis_sq = np.asarray(covariance.mahalanobis(scores), dtype=float)
    score_cutoff = float(chi2.ppf(score_alpha, df=k))
    reconstruction_flag = reconstruction_z > reconstruction_z_threshold
    score_flag = mahalanobis_sq > score_cutoff
    review_flag = reconstruction_flag | score_flag

    diagnostics = pd.DataFrame(
        {
            "curve_id": trajectories.curve_ids,
            "reconstruction_rmse": reconstruction_error,
            "reconstruction_robust_z": reconstruction_z,
            "score_mahalanobis_sq": mahalanobis_sq,
            "score_cutoff": score_cutoff,
            "reconstruction_flag": reconstruction_flag,
            "score_flag": score_flag,
            "review_flag": review_flag,
        }
    )
    return FunctionalOutlierResult(
        diagnostics=diagnostics,
        method="fpca_reconstruction_and_score_space",
        reference=result,
        provenance={
            "n_components": k,
            "reconstruction_z_threshold": reconstruction_z_threshold,
            "score_alpha": score_alpha,
            "score_covariance": score_covariance,
            "random_state": random_state,
            "scientific_warning": (
                "Review flags are diagnostics only. Do not exclude flagged trajectories "
                "without a pre-specified substantive or quality rule."
            ),
        },
    )

eyetrajectoriespy.leave_one_group_out_fpca_influence

leave_one_group_out_fpca_influence(trajectories: TrajectorySet, *, group_column: str | None = None, n_components: int = 3, scaling: str = 'none') -> FPCAInfluenceResult

Quantify how much each curve or group influences fitted FPC structure.

When group_column is supplied, all curves belonging to one group are removed together. For repeated-trial eye-tracking designs this is commonly a participant identifier.

Source code in src/eyetrajectoriespy/outliers.py
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
def leave_one_group_out_fpca_influence(
    trajectories: TrajectorySet,
    *,
    group_column: str | None = None,
    n_components: int = 3,
    scaling: str = "none",
) -> FPCAInfluenceResult:
    """Quantify how much each curve or group influences fitted FPC structure.

    When group_column is supplied, all curves belonging to one group are
    removed together. For repeated-trial eye-tracking designs this is commonly
    a participant identifier.
    """

    validate_trajectory_set(trajectories, require_complete=True)
    if isinstance(n_components, bool) or not isinstance(n_components, int):
        raise TypeError("n_components must be an integer")
    if n_components < 1 or n_components > trajectories.n_curves:
        raise ValueError("n_components must be between 1 and number of trajectories")

    if group_column is None:
        group_values = np.asarray(trajectories.curve_ids, dtype=object)
        groups = list(trajectories.curve_ids)
    else:
        if group_column not in trajectories.metadata.columns:
            raise ValueError(f"metadata does not contain group column {group_column!r}")
        if trajectories.metadata[group_column].isna().any():
            raise ValueError("group_column contains missing values")
        group_values = trajectories.metadata[group_column].astype(str).to_numpy()
        groups = list(pd.unique(group_values))

    reference = _fit_reference(
        trajectories,
        n_components=n_components,
        scaling=scaling,
    )
    summary_rows: list[dict[str, object]] = []
    component_rows: list[dict[str, object]] = []

    for group in groups:
        omitted = np.flatnonzero(group_values == group)
        keep = np.flatnonzero(group_values != group)
        if len(keep) < max(2, n_components):
            raise ValueError(
                f"Removing group {group!r} leaves too few trajectories for "
                f"{n_components} components"
            )
        candidate_data = trajectories.subset(keep)
        candidate = _fit_reference(
            candidate_data,
            n_components=n_components,
            scaling=scaling,
        )
        assignments, signed = match_fpca_components(
            reference,
            candidate,
            n_components=n_components,
        )
        abs_similarity = np.abs(signed)
        matched_evr = candidate.explained_variance_ratio[assignments]
        evr_change = matched_evr - reference.explained_variance_ratio[:n_components]

        summary_rows.append(
            {
                "group": str(group),
                "omitted_n_curves": int(len(omitted)),
                "remaining_n_curves": int(len(keep)),
                "min_abs_component_similarity": float(np.min(abs_similarity)),
                "mean_abs_component_similarity": float(np.mean(abs_similarity)),
                "max_abs_explained_variance_change": float(np.max(np.abs(evr_change))),
                "mean_abs_explained_variance_change": float(np.mean(np.abs(evr_change))),
                "influence_score": float(1.0 - np.min(abs_similarity)),
            }
        )
        for component in range(n_components):
            component_rows.append(
                {
                    "group": str(group),
                    "reference_component": component + 1,
                    "matched_component": int(assignments[component]) + 1,
                    "signed_similarity": float(signed[component]),
                    "absolute_similarity": float(abs_similarity[component]),
                    "explained_variance_change": float(evr_change[component]),
                }
            )

    return FPCAInfluenceResult(
        reference=reference,
        summary=pd.DataFrame(summary_rows),
        components=pd.DataFrame(component_rows),
        group_column=group_column,
        n_components=n_components,
        provenance={
            "method": "leave_one_group_out_fpca",
            "group_column": group_column,
            "scaling": scaling,
            "scientific_warning": (
                "Influence diagnostics quantify sensitivity to omission. They are not "
                "automatic exclusion criteria."
            ),
        },
    )

eyetrajectoriespy.detect_functional_outliers_skfda

detect_functional_outliers_skfda(trajectories: TrajectorySet, *, dimension: str, method: str = 'boxplot', factor: float = 1.5, random_state: int | None = 0) -> FunctionalOutlierResult

Run optional scikit-fda outlier screening on one functional dimension.

Returned review flags are diagnostic only and never modify trajectories.

Source code in src/eyetrajectoriespy/backends.py
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
def detect_functional_outliers_skfda(
    trajectories: TrajectorySet,
    *,
    dimension: str,
    method: str = "boxplot",
    factor: float = 1.5,
    random_state: int | None = 0,
) -> FunctionalOutlierResult:
    """Run optional scikit-fda outlier screening on one functional dimension.

    Returned review flags are diagnostic only and never modify trajectories.
    """

    validate_trajectory_set(trajectories, require_complete=True)
    if dimension not in trajectories.dimension_names:
        raise KeyError(f"Unknown dimension {dimension!r}")
    if factor <= 0:
        raise ValueError("factor must be positive")
    if method not in {"boxplot", "msplot"}:
        raise ValueError("method must be 'boxplot' or 'msplot'")
    try:
        from skfda import FDataGrid
        from skfda.exploratory.outliers import (
            BoxplotOutlierDetector,
            MSPlotOutlierDetector,
        )
    except ImportError as exc:  # pragma: no cover - optional dependency
        raise ImportError(
            "scikit-fda is optional. Install eyetrajectoriespy with the 'fda' extra."
        ) from exc

    index = trajectories.dimension_names.index(dimension)
    fd = FDataGrid(
        data_matrix=trajectories.values[:, :, index][:, :, None],
        grid_points=trajectories.time,
        dataset_name=f"eyetrajectoriespy {dimension}",
        coordinate_names=(dimension,),
    )
    if method == "boxplot":
        detector = BoxplotOutlierDetector(factor=factor)
    else:
        detector = MSPlotOutlierDetector(
            cutoff_factor=factor,
            random_state=random_state,
        )
    labels = np.asarray(detector.fit_predict(fd), dtype=int)
    diagnostics = pd.DataFrame(
        {
            "curve_id": trajectories.curve_ids,
            "backend_label": labels,
            "review_flag": labels == -1,
        }
    )
    return FunctionalOutlierResult(
        diagnostics=diagnostics,
        method=f"skfda_{method}",
        reference=None,
        provenance={
            **dict(trajectories.provenance),
            "dimension": dimension,
            "factor": factor,
            "random_state": random_state,
            "scientific_warning": (
                "Functional outlier flags are review diagnostics and are not "
                "automatic exclusion criteria."
            ),
        },
        backend_object=detector,
    )

eyetrajectoriespy.plot_fpca_outlier_diagnostics

plot_fpca_outlier_diagnostics(result: FunctionalOutlierResult, *, ax=None)

Plot reconstruction robust-z against score-space Mahalanobis distance.

Source code in src/eyetrajectoriespy/plotting.py
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
def plot_fpca_outlier_diagnostics(
    result: FunctionalOutlierResult,
    *,
    ax=None,
):
    """Plot reconstruction robust-z against score-space Mahalanobis distance."""

    required = {
        "reconstruction_robust_z",
        "score_mahalanobis_sq",
        "score_cutoff",
        "review_flag",
    }
    if not required <= set(result.diagnostics.columns):
        raise ValueError("result does not contain FPCA reconstruction/score diagnostics")
    if ax is None:
        _, ax = plt.subplots()
    frame = result.diagnostics
    ax.scatter(
        frame["reconstruction_robust_z"],
        frame["score_mahalanobis_sq"],
        marker="o",
    )
    threshold = result.provenance.get("reconstruction_z_threshold")
    if threshold is not None:
        ax.axvline(float(threshold), linestyle="--")
    ax.axhline(float(frame["score_cutoff"].iloc[0]), linestyle="--")
    ax.set_xlabel("Reconstruction robust z")
    ax.set_ylabel("Squared Mahalanobis distance in FPC score space")
    ax.set_title("FPCA trajectory review diagnostics")
    return ax

eyetrajectoriespy.plot_fpca_influence

plot_fpca_influence(result: FPCAInfluenceResult, *, metric: str = 'min_abs_component_similarity', ax=None)

Plot leave-one-group-out FPCA influence summaries.

Source code in src/eyetrajectoriespy/plotting.py
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
def plot_fpca_influence(
    result: FPCAInfluenceResult,
    *,
    metric: str = "min_abs_component_similarity",
    ax=None,
):
    """Plot leave-one-group-out FPCA influence summaries."""

    if metric not in result.summary.columns:
        raise KeyError(f"Unknown influence metric {metric!r}")
    if ax is None:
        _, ax = plt.subplots()
    x = np.arange(len(result.summary))
    ax.plot(x, result.summary[metric].to_numpy(dtype=float), marker="o")
    ax.set_xticks(x)
    ax.set_xticklabels(result.summary["group"].astype(str), rotation=90)
    ax.set_ylabel(metric.replace("_", " "))
    ax.set_xlabel("Omitted group")
    ax.set_title("Leave-one-group-out FPCA influence")
    return ax

eyetrajectoriespy.fpca_outlier_reporting_text

fpca_outlier_reporting_text(result: FunctionalOutlierResult) -> str

Generate descriptive text for functional review diagnostics.

Source code in src/eyetrajectoriespy/reporting.py
596
597
598
599
600
601
602
603
604
605
606
607
def fpca_outlier_reporting_text(result: FunctionalOutlierResult) -> str:
    """Generate descriptive text for functional review diagnostics."""

    frame = result.diagnostics
    if "review_flag" not in frame.columns:
        raise ValueError("result diagnostics do not contain review_flag")
    n_review = int(frame["review_flag"].sum())
    return (
        f"Functional anomaly screening ({result.method}) flagged {n_review} of "
        f"{len(frame)} trajectories for review. Flags were treated as diagnostic "
        "signals only and were not used as automatic exclusion criteria."
    )

eyetrajectoriespy.fpca_influence_reporting_text

fpca_influence_reporting_text(result: FPCAInfluenceResult, *, digits: int = 2) -> str

Generate descriptive text for leave-one-group-out FPCA influence.

Source code in src/eyetrajectoriespy/reporting.py
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
def fpca_influence_reporting_text(
    result: FPCAInfluenceResult,
    *,
    digits: int = 2,
) -> str:
    """Generate descriptive text for leave-one-group-out FPCA influence."""

    if result.summary.empty:
        raise ValueError("influence summary is empty")
    row = result.summary.loc[result.summary["influence_score"].idxmax()]
    unit = result.group_column or "curve_id"
    return (
        f"Leave-one-{unit}-out FPCA influence analysis evaluated "
        f"{len(result.summary)} omission fits. The largest observed influence "
        f"was for {row['group']!r}, with minimum matched component similarity "
        f"{row['min_abs_component_similarity']:.{digits}f}. Influence diagnostics "
        "were used for sensitivity assessment rather than automatic exclusion."
    )

FPCA stability and reconstruction

eyetrajectoriespy.component_similarity_matrix

component_similarity_matrix(reference: FPCAResult, candidate: FPCAResult, *, n_components: int | None = None) -> np.ndarray

Signed integrated cosine similarity between two FPCA component sets.

Components are compared in the standardized functional geometry used by each fit so explicit channel scaling does not distort matching.

Source code in src/eyetrajectoriespy/stability.py
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
def component_similarity_matrix(
    reference: FPCAResult,
    candidate: FPCAResult,
    *,
    n_components: int | None = None,
) -> np.ndarray:
    """Signed integrated cosine similarity between two FPCA component sets.

    Components are compared in the standardized functional geometry used by
    each fit so explicit channel scaling does not distort matching.
    """

    if not np.array_equal(reference.time, candidate.time):
        raise ValueError("FPCA results must use the same time grid")
    if reference.dimension_names != candidate.dimension_names:
        raise ValueError("FPCA results must use the same functional dimensions")
    if n_components is None:
        n_components = min(reference.n_components, candidate.n_components)
    if n_components < 1 or n_components > min(reference.n_components, candidate.n_components):
        raise ValueError("n_components is outside the common fitted range")

    weights = reference.weights
    if not np.allclose(weights, candidate.weights):
        raise ValueError("FPCA quadrature weights differ between results")

    ref = reference.components[:n_components] / reference.scale[None, None, :]
    cand = candidate.components[:n_components] / candidate.scale[None, None, :]
    matrix = np.empty((n_components, n_components), dtype=float)
    for i in range(n_components):
        a = ref[i]
        norm_a = np.sqrt(np.sum((a**2) * weights[:, None]))
        for j in range(n_components):
            b = cand[j]
            norm_b = np.sqrt(np.sum((b**2) * weights[:, None]))
            if norm_a <= np.finfo(float).eps or norm_b <= np.finfo(float).eps:
                matrix[i, j] = np.nan
            else:
                inner = np.sum(a * b * weights[:, None])
                matrix[i, j] = float(inner / (norm_a * norm_b))
    return matrix

eyetrajectoriespy.match_fpca_components

match_fpca_components(reference: FPCAResult, candidate: FPCAResult, *, n_components: int | None = None) -> tuple[np.ndarray, np.ndarray]

Match candidate FPCs to reference FPCs by maximum absolute similarity.

Returns:

Name Type Description
assignments ndarray

Candidate component index matched to each reference component.

signed_similarity ndarray

Signed similarity after matching. Sign is retained because FPC orientation is arbitrary and should be inspected explicitly.

Source code in src/eyetrajectoriespy/stability.py
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
def match_fpca_components(
    reference: FPCAResult,
    candidate: FPCAResult,
    *,
    n_components: int | None = None,
) -> tuple[np.ndarray, np.ndarray]:
    """Match candidate FPCs to reference FPCs by maximum absolute similarity.

    Returns
    -------
    assignments:
        Candidate component index matched to each reference component.
    signed_similarity:
        Signed similarity after matching. Sign is retained because FPC
        orientation is arbitrary and should be inspected explicitly.
    """

    similarity = component_similarity_matrix(reference, candidate, n_components=n_components)
    if np.isnan(similarity).any():
        raise ValueError("Component similarity is undefined for a zero-norm component")
    rows, cols = linear_sum_assignment(-np.abs(similarity))
    order = np.argsort(rows)
    assignments = cols[order]
    signed = similarity[rows[order], cols[order]]
    return assignments.astype(int), signed.astype(float)

eyetrajectoriespy.bootstrap_fpca_stability

bootstrap_fpca_stability(trajectories: TrajectorySet, *, n_bootstrap: int = 200, n_components: int = 3, scaling: str = 'none', resample_unit: str = 'curve', participant_column: str | None = None, random_state: int | None = 0) -> FPCAStabilityResult

Estimate descriptive FPC stability under nonparametric bootstrap.

Component labels are matched to the full-sample reference by maximum absolute functional similarity. Returned bootstrap fractions are descriptive stability summaries, not probabilities that a component is scientifically true.

Source code in src/eyetrajectoriespy/stability.py
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
def bootstrap_fpca_stability(
    trajectories: TrajectorySet,
    *,
    n_bootstrap: int = 200,
    n_components: int = 3,
    scaling: str = "none",
    resample_unit: str = "curve",
    participant_column: str | None = None,
    random_state: int | None = 0,
) -> FPCAStabilityResult:
    """Estimate descriptive FPC stability under nonparametric bootstrap.

    Component labels are matched to the full-sample reference by maximum
    absolute functional similarity. Returned bootstrap fractions are
    descriptive stability summaries, not probabilities that a component is
    scientifically true.
    """

    validate_trajectory_set(trajectories, require_complete=True)
    if n_bootstrap < 2:
        raise ValueError("n_bootstrap must be at least 2")
    if n_components < 1 or n_components > trajectories.n_curves:
        raise ValueError("n_components must be between 1 and number of trajectories")
    if resample_unit not in {"curve", "participant"}:
        raise ValueError("resample_unit must be 'curve' or 'participant'")
    if resample_unit == "participant" and not participant_column:
        raise ValueError("participant_column is required for participant bootstrap")

    reference = _fit_for_trajectories(
        trajectories,
        n_components=n_components,
        scaling=scaling,
    )
    rng = np.random.default_rng(random_state)
    similarities = np.empty((n_bootstrap, n_components), dtype=float)
    signed = np.empty_like(similarities)
    assignments = np.empty((n_bootstrap, n_components), dtype=int)
    explained = np.empty_like(similarities)
    curve_counts = np.empty(n_bootstrap, dtype=int)

    for b in range(n_bootstrap):
        if resample_unit == "curve":
            sample = _bootstrap_curves(trajectories, rng)
        else:
            sample = _bootstrap_participants(
                trajectories,
                rng,
                participant_column=str(participant_column),
            )
        if sample.n_curves < n_components:
            raise ValueError(
                f"Bootstrap replicate {b} has fewer curves than n_components; reduce n_components"
            )
        candidate = _fit_for_trajectories(
            sample,
            n_components=n_components,
            scaling=scaling,
        )
        matched, signed_similarity = match_fpca_components(
            reference,
            candidate,
            n_components=n_components,
        )
        assignments[b] = matched
        signed[b] = signed_similarity
        similarities[b] = np.abs(signed_similarity)
        explained[b] = candidate.explained_variance_ratio[matched]
        curve_counts[b] = sample.n_curves

    return FPCAStabilityResult(
        reference=reference,
        similarities=similarities,
        signed_similarities=signed,
        assignments=assignments,
        explained_variance_ratio=explained,
        bootstrap_curve_counts=curve_counts,
        resampling_unit=resample_unit,
        random_state=random_state,
        provenance={
            "method": "nonparametric_bootstrap_component_matching",
            "n_bootstrap": n_bootstrap,
            "n_components": n_components,
            "scaling": scaling,
            "participant_column": participant_column,
        },
    )

eyetrajectoriespy.summarise_fpca_stability

summarise_fpca_stability(result: FPCAStabilityResult, *, similarity_threshold: float = 0.8, interval: tuple[float, float] = (0.025, 0.975)) -> pd.DataFrame

Summarize matched FPC stability across bootstrap replicates.

Source code in src/eyetrajectoriespy/stability.py
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
def summarise_fpca_stability(
    result: FPCAStabilityResult,
    *,
    similarity_threshold: float = 0.80,
    interval: tuple[float, float] = (0.025, 0.975),
) -> pd.DataFrame:
    """Summarize matched FPC stability across bootstrap replicates."""

    if not 0 <= similarity_threshold <= 1:
        raise ValueError("similarity_threshold must be in [0, 1]")
    low, high = interval
    if not 0 <= low < high <= 1:
        raise ValueError("interval must satisfy 0 <= low < high <= 1")
    rows = []
    for k in range(result.reference.n_components):
        values = result.similarities[:, k]
        ev = result.explained_variance_ratio[:, k]
        rows.append(
            {
                "component": k + 1,
                "median_abs_similarity": float(np.median(values)),
                "similarity_interval_low": float(np.quantile(values, low)),
                "similarity_interval_high": float(np.quantile(values, high)),
                "fraction_at_or_above_threshold": float(np.mean(values >= similarity_threshold)),
                "similarity_threshold": similarity_threshold,
                "median_explained_variance_ratio": float(np.median(ev)),
            }
        )
    return pd.DataFrame(rows)

eyetrajectoriespy.reconstruction_error_by_curve

reconstruction_error_by_curve(result: FPCAResult, trajectories: TrajectorySet, *, n_components: int | None = None) -> pd.DataFrame

Integrated root-mean-square reconstruction error for each trajectory.

Source code in src/eyetrajectoriespy/stability.py
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
def reconstruction_error_by_curve(
    result: FPCAResult,
    trajectories: TrajectorySet,
    *,
    n_components: int | None = None,
) -> pd.DataFrame:
    """Integrated root-mean-square reconstruction error for each trajectory."""

    validate_trajectory_set(trajectories, require_complete=True)
    if not np.array_equal(trajectories.time, result.time):
        raise ValueError("Trajectory grid must match the fitted FPCA grid")
    if trajectories.dimension_names != result.dimension_names:
        raise ValueError("Functional dimensions must match the fitted FPCA model")
    if trajectories.curve_ids != result.curve_ids:
        raise ValueError("Trajectory IDs/order must match the fitted FPCA result")
    reconstructed = reconstruct_fpca(result, scores=None, n_components=n_components)
    if reconstructed.shape[0] != trajectories.n_curves:
        raise ValueError(
            "reconstruction_error_by_curve requires the trajectories used to fit the supplied result"
        )
    error = reconstructed - trajectories.values
    weighted_mse = np.sum(
        error**2 * result.weights[None, :, None],
        axis=(1, 2),
    ) / (result.weights.sum() * trajectories.n_dimensions)
    return pd.DataFrame(
        {
            "curve_id": trajectories.curve_ids,
            "n_components": result.n_components if n_components is None else n_components,
            "integrated_rmse": np.sqrt(weighted_mse),
        }
    )

eyetrajectoriespy.fpca_reconstruction_curve

fpca_reconstruction_curve(result: FPCAResult, trajectories: TrajectorySet) -> pd.DataFrame

Overall reconstruction error as the retained component count increases.

Source code in src/eyetrajectoriespy/stability.py
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
def fpca_reconstruction_curve(
    result: FPCAResult,
    trajectories: TrajectorySet,
) -> pd.DataFrame:
    """Overall reconstruction error as the retained component count increases."""

    rows = []
    for n_components in range(1, result.n_components + 1):
        frame = reconstruction_error_by_curve(
            result,
            trajectories,
            n_components=n_components,
        )
        rows.append(
            {
                "n_components": n_components,
                "mean_integrated_rmse": float(frame["integrated_rmse"].mean()),
                "median_integrated_rmse": float(frame["integrated_rmse"].median()),
                "cumulative_variance_ratio": float(
                    result.cumulative_explained_variance()[n_components - 1]
                ),
            }
        )
    return pd.DataFrame(rows)

Registration and phase

eyetrajectoriespy.register_to_landmarks

register_to_landmarks(trajectories: TrajectorySet, observed_landmarks: ndarray, *, reference_landmarks: ndarray | None = None, interpolation: str = 'linear', max_gap: float | None = None) -> RegistrationResult

Register trajectories using monotone piecewise-linear landmark warping.

The returned warping function h_i(t) maps reference time to the corresponding time in each original trajectory. Registered curves are therefore evaluated as G_i(h_i(t)).

Notes

The function preserves both the original trajectories and the warping functions so phase information is not lost from the analysis record.

Source code in src/eyetrajectoriespy/registration.py
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
def register_to_landmarks(
    trajectories: TrajectorySet,
    observed_landmarks: np.ndarray,
    *,
    reference_landmarks: np.ndarray | None = None,
    interpolation: str = "linear",
    max_gap: float | None = None,
) -> RegistrationResult:
    """Register trajectories using monotone piecewise-linear landmark warping.

    The returned warping function ``h_i(t)`` maps *reference time* to the
    corresponding time in each original trajectory. Registered curves are
    therefore evaluated as ``G_i(h_i(t))``.

    Notes
    -----
    The function preserves both the original trajectories and the warping
    functions so phase information is not lost from the analysis record.
    """

    validate_trajectory_set(trajectories)
    observed = np.asarray(observed_landmarks, dtype=float)
    if reference_landmarks is None:
        reference = np.median(observed, axis=0)
    else:
        reference = np.asarray(reference_landmarks, dtype=float)
    observed, reference = _validate_landmarks(observed, reference, trajectories)

    target_time = trajectories.time
    anchor_ref = np.r_[target_time[0], reference, target_time[-1]]
    registered_values = np.full_like(trajectories.values, np.nan, dtype=float)
    warpings = np.empty((trajectories.n_curves, trajectories.n_time), dtype=float)
    for i in range(trajectories.n_curves):
        anchor_obs = np.r_[target_time[0], observed[i], target_time[-1]]
        warped_source_time = np.interp(target_time, anchor_ref, anchor_obs)
        warpings[i] = warped_source_time
        registered_values[i] = _resample_single_curve(
            target_time,
            trajectories.values[i],
            warped_source_time,
            method=interpolation,
            max_gap=max_gap,
        )

    registered = trajectories.with_values(
        registered_values,
        provenance_update={
            "registration": {
                "method": "landmark_piecewise_linear",
                "reference_landmarks": reference.tolist(),
                "interpolation": interpolation,
                "max_gap": max_gap,
            }
        },
    )
    return RegistrationResult(
        registered=registered,
        original=trajectories,
        warping_functions=warpings,
        reference_landmarks=reference,
        observed_landmarks=observed,
        method="landmark_piecewise_linear",
        provenance={
            "scientific_warning": (
                "Registration removes some timing variation. Analyze warping functions or unregistered trajectories "
                "when latency/phase is scientifically meaningful."
            )
        },
    )

eyetrajectoriespy.warping_displacement

warping_displacement(result: RegistrationResult) -> np.ndarray

Return h_i(t) - t for each curve and grid point.

Source code in src/eyetrajectoriespy/registration.py
114
115
116
117
def warping_displacement(result: RegistrationResult) -> np.ndarray:
    """Return ``h_i(t) - t`` for each curve and grid point."""

    return result.warping_functions - result.original.time[None, :]

eyetrajectoriespy.phase_summary

phase_summary(result: RegistrationResult) -> dict[str, np.ndarray]

Summarize phase/warping magnitude without discarding the full functions.

Source code in src/eyetrajectoriespy/registration.py
120
121
122
123
124
125
126
127
128
def phase_summary(result: RegistrationResult) -> dict[str, np.ndarray]:
    """Summarize phase/warping magnitude without discarding the full functions."""

    displacement = warping_displacement(result)
    return {
        "mean_absolute_displacement": np.mean(np.abs(displacement), axis=1),
        "max_absolute_displacement": np.max(np.abs(displacement), axis=1),
        "signed_mean_displacement": np.mean(displacement, axis=1),
    }

eyetrajectoriespy.phase_trajectory_set

phase_trajectory_set(registration: RegistrationResult, *, representation: str = 'displacement') -> TrajectorySet

Convert registration warpings into a one-dimensional functional object.

Parameters:

Name Type Description Default
representation str

"displacement" returns h_i(t) - t. "warping" returns h_i(t).

'displacement'
Source code in src/eyetrajectoriespy/phase.py
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
def phase_trajectory_set(
    registration: RegistrationResult,
    *,
    representation: str = "displacement",
) -> TrajectorySet:
    """Convert registration warpings into a one-dimensional functional object.

    Parameters
    ----------
    representation:
        "displacement" returns h_i(t) - t. "warping" returns h_i(t).
    """

    if representation == "displacement":
        values = (
            registration.warping_functions
            - registration.original.time[None, :]
        )[:, :, None]
        dimension_name = "phase_displacement"
    elif representation == "warping":
        values = registration.warping_functions[:, :, None]
        dimension_name = "warping_time"
    else:
        raise ValueError("representation must be 'displacement' or 'warping'")

    return TrajectorySet(
        time=registration.original.time,
        values=values,
        curve_ids=registration.original.curve_ids,
        dimension_names=(dimension_name,),
        metadata=registration.original.metadata.reset_index(drop=True),
        coordinate_system="phase_time",
        time_unit=registration.original.time_unit,
        provenance={
            **dict(registration.provenance),
            "phase_representation": representation,
            "registration_method": registration.method,
        },
    )

eyetrajectoriespy.fit_phase_fpca

fit_phase_fpca(registration: RegistrationResult, *, representation: str = 'displacement', n_components: int | float = 0.95) -> FPCAResult

Fit univariate FPCA to registration-derived phase functions.

Source code in src/eyetrajectoriespy/phase.py
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
def fit_phase_fpca(
    registration: RegistrationResult,
    *,
    representation: str = "displacement",
    n_components: int | float = 0.95,
) -> FPCAResult:
    """Fit univariate FPCA to registration-derived phase functions."""

    phase = phase_trajectory_set(
        registration,
        representation=representation,
    )
    return fit_fpca(
        phase,
        n_components=n_components,
        scaling="none",
    )

eyetrajectoriespy.phase_landmark_frame

phase_landmark_frame(registration: RegistrationResult) -> pd.DataFrame

Return observed-minus-reference landmark timing deviations by curve.

Source code in src/eyetrajectoriespy/phase.py
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
def phase_landmark_frame(
    registration: RegistrationResult,
) -> pd.DataFrame:
    """Return observed-minus-reference landmark timing deviations by curve."""

    observed = np.asarray(registration.observed_landmarks, dtype=float)
    reference = np.asarray(registration.reference_landmarks, dtype=float)
    if observed.ndim != 2:
        raise ValueError("observed_landmarks must be two-dimensional")
    rows: list[dict[str, float | int | str]] = []
    for i, curve_id in enumerate(registration.original.curve_ids):
        for landmark in range(observed.shape[1]):
            rows.append(
                {
                    "curve_id": curve_id,
                    "landmark": landmark + 1,
                    "observed_time": float(observed[i, landmark]),
                    "reference_time": float(reference[landmark]),
                    "timing_deviation": float(
                        observed[i, landmark] - reference[landmark]
                    ),
                }
            )
    return pd.DataFrame(rows)

eyetrajectoriespy.compare_registered_unregistered_fpca

compare_registered_unregistered_fpca(registration: RegistrationResult, *, n_components: int = 3, scaling: str = 'none') -> RegistrationSensitivityResult

Compare dominant FPCs before and after explicit registration.

Component functions are matched by maximum absolute functional similarity. Score correlations use sign-aligned registered scores and quantify whether participant/trial ordering is preserved after timing alignment.

Source code in src/eyetrajectoriespy/phase.py
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
def compare_registered_unregistered_fpca(
    registration: RegistrationResult,
    *,
    n_components: int = 3,
    scaling: str = "none",
) -> RegistrationSensitivityResult:
    """Compare dominant FPCs before and after explicit registration.

    Component functions are matched by maximum absolute functional similarity.
    Score correlations use sign-aligned registered scores and quantify whether
    participant/trial ordering is preserved after timing alignment.
    """

    if n_components < 1:
        raise ValueError("n_components must be positive")
    original = registration.original
    registered = registration.registered
    if original.curve_ids != registered.curve_ids:
        raise ValueError("Original and registered curve ordering must match")

    unregistered_fit = _fit_spatial(
        original,
        n_components=n_components,
        scaling=scaling,
    )
    registered_fit = _fit_spatial(
        registered,
        n_components=n_components,
        scaling=scaling,
    )

    assignments, signed_similarity = match_fpca_components(
        unregistered_fit,
        registered_fit,
        n_components=n_components,
    )

    score_correlations = np.empty(n_components, dtype=float)
    for k, matched in enumerate(assignments):
        sign = 1.0 if signed_similarity[k] >= 0 else -1.0
        a = unregistered_fit.scores[:, k]
        b = sign * registered_fit.scores[:, matched]
        if np.std(a) <= np.finfo(float).eps or np.std(b) <= np.finfo(float).eps:
            score_correlations[k] = np.nan
        else:
            score_correlations[k] = float(np.corrcoef(a, b)[0, 1])

    return RegistrationSensitivityResult(
        unregistered_fpca=unregistered_fit,
        registered_fpca=registered_fit,
        component_assignments=assignments,
        signed_component_similarity=signed_similarity,
        score_correlations=score_correlations,
        provenance={
            "method": "registered_vs_unregistered_fpca",
            "registration_method": registration.method,
            "n_components": n_components,
            "scaling": scaling,
        },
    )

eyetrajectoriespy.registration_sensitivity_frame

registration_sensitivity_frame(result: RegistrationSensitivityResult) -> pd.DataFrame

Return a tidy component-level registration sensitivity table.

Source code in src/eyetrajectoriespy/phase.py
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
def registration_sensitivity_frame(
    result: RegistrationSensitivityResult,
) -> pd.DataFrame:
    """Return a tidy component-level registration sensitivity table."""

    return pd.DataFrame(
        {
            "unregistered_component": np.arange(
                1,
                result.unregistered_fpca.n_components + 1,
            ),
            "registered_component": result.component_assignments + 1,
            "signed_component_similarity": result.signed_component_similarity,
            "absolute_component_similarity": np.abs(
                result.signed_component_similarity
            ),
            "score_correlation": result.score_correlations,
        }
    )

Multilevel and compositional

eyetrajectoriespy.fit_multilevel_fpca

fit_multilevel_fpca(trajectories: TrajectorySet, *, participant_column: str, participant_components: int | float = 0.95, trial_components: int | float = 0.95, scaling: str = 'none') -> MultilevelFPCAResult

Separate between-participant and within-participant functional variation.

This implements a transparent two-level functional ANOVA decomposition:

G_ij(t) = mu(t) + U_i(t) + V_ij(t)

FPCA is then fitted separately to participant mean deviations U_i and trial residuals V_ij. It is appropriate for repeated trial designs when the goal is to avoid mixing stable participant differences with trial-level functional variability.

Source code in src/eyetrajectoriespy/multilevel.py
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
def fit_multilevel_fpca(
    trajectories: TrajectorySet,
    *,
    participant_column: str,
    participant_components: int | float = 0.95,
    trial_components: int | float = 0.95,
    scaling: str = "none",
) -> MultilevelFPCAResult:
    """Separate between-participant and within-participant functional variation.

    This implements a transparent two-level functional ANOVA decomposition:

    ``G_ij(t) = mu(t) + U_i(t) + V_ij(t)``

    FPCA is then fitted separately to participant mean deviations ``U_i`` and
    trial residuals ``V_ij``. It is appropriate for repeated trial designs when
    the goal is to avoid mixing stable participant differences with trial-level
    functional variability.
    """

    validate_trajectory_set(trajectories, require_complete=True)
    if participant_column not in trajectories.metadata.columns:
        raise ValueError(f"metadata does not contain {participant_column!r}")
    participants = trajectories.metadata[participant_column].astype(str).to_numpy()
    unique = pd.unique(participants)
    if len(unique) < 2:
        raise ValueError("At least two participants are required")

    participant_means = []
    participant_ids = []
    trial_residuals = np.empty_like(trajectories.values)
    for participant in unique:
        idx = np.flatnonzero(participants == participant)
        mean_curve = trajectories.values[idx].mean(axis=0)
        participant_means.append(mean_curve)
        participant_ids.append(str(participant))
        trial_residuals[idx] = trajectories.values[idx] - mean_curve[None, :, :]

    grand_mean = trajectories.values.mean(axis=0)
    participant_deviations = np.stack(participant_means) - grand_mean[None, :, :]
    participant_set = TrajectorySet(
        time=trajectories.time,
        values=participant_deviations,
        curve_ids=tuple(participant_ids),
        dimension_names=trajectories.dimension_names,
        metadata=pd.DataFrame({participant_column: participant_ids}),
        coordinate_system=trajectories.coordinate_system,
        time_unit=trajectories.time_unit,
        provenance={**dict(trajectories.provenance), "level": "participant_deviation"},
    )
    trial_set = trajectories.with_values(
        trial_residuals,
        provenance_update={"level": "within_participant_trial_residual"},
    )

    participant_fpca = fit_fpca(
        participant_set,
        n_components=participant_components,
        scaling=scaling,
    )
    trial_fpca = fit_fpca(
        trial_set,
        n_components=trial_components,
        scaling=scaling,
    )
    participant_scores = fpca_score_frame(participant_fpca, prefix="participant_FPC")
    participant_scores[participant_column] = participant_ids
    trial_scores = fpca_score_frame(trial_fpca, prefix="trial_FPC")
    trial_scores[participant_column] = participants

    return MultilevelFPCAResult(
        grand_mean=grand_mean,
        participant_fpca=participant_fpca,
        trial_fpca=trial_fpca,
        participant_scores=participant_scores,
        trial_scores=trial_scores,
        participant_column=participant_column,
        provenance={
            "method": "two_level_functional_ANOVA_FPCA",
            "n_participants": int(len(unique)),
            "n_trials": int(trajectories.n_curves),
            "scaling": scaling,
        },
    )

eyetrajectoriespy.fit_compositional_fpca

fit_compositional_fpca(trajectories: TrajectorySet, *, reference_dimension: int = -1, epsilon: float = 1e-08, n_components: int | float = 0.95, scaling: str = 'none') -> CompositionalFPCAResult

Fit FPCA to AOI probability functions while respecting the simplex.

The implementation applies an additive log-ratio transform before MFPCA. Reconstructed trajectories can be mapped back exactly to the simplex with :func:reconstruct_compositional_fpca.

Source code in src/eyetrajectoriespy/compositional.py
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
def fit_compositional_fpca(
    trajectories: TrajectorySet,
    *,
    reference_dimension: int = -1,
    epsilon: float = 1e-8,
    n_components: int | float = 0.95,
    scaling: str = "none",
) -> CompositionalFPCAResult:
    """Fit FPCA to AOI probability functions while respecting the simplex.

    The implementation applies an additive log-ratio transform before MFPCA.
    Reconstructed trajectories can be mapped back exactly to the simplex with
    :func:`reconstruct_compositional_fpca`.
    """

    validate_simplex(trajectories.values)
    k = trajectories.n_dimensions
    ref = reference_dimension % k
    z = alr_transform(
        trajectories.values,
        reference_dimension=ref,
        epsilon=epsilon,
    )
    names = tuple(f"log({name}/{trajectories.dimension_names[ref]})" for i, name in enumerate(trajectories.dimension_names) if i != ref)
    transformed = TrajectorySet(
        time=trajectories.time,
        values=z,
        curve_ids=trajectories.curve_ids,
        dimension_names=names,
        metadata=trajectories.metadata.reset_index(drop=True),
        coordinate_system="simplex_logratio",
        time_unit=trajectories.time_unit,
        provenance={
            **dict(trajectories.provenance),
            "compositional_transform": {
                "method": "additive_log_ratio",
                "reference_dimension": ref,
                "epsilon": epsilon,
            },
        },
    )
    fpca = fit_mfpca(transformed, n_components=n_components, scaling=scaling)
    return CompositionalFPCAResult(
        fpca=fpca,
        reference_dimension=ref,
        original_dimension_names=trajectories.dimension_names,
        epsilon=epsilon,
        provenance={
            "method": "ALR + quadrature-weighted MFPCA",
            "simplex_preserved_on_inverse": True,
        },
    )

eyetrajectoriespy.reconstruct_compositional_fpca

reconstruct_compositional_fpca(result: CompositionalFPCAResult, *, scores: ndarray | None = None, n_components: int | None = None) -> np.ndarray

Reconstruct AOI probability trajectories that sum to one.

Source code in src/eyetrajectoriespy/compositional.py
110
111
112
113
114
115
116
117
118
119
120
121
122
123
def reconstruct_compositional_fpca(
    result: CompositionalFPCAResult,
    *,
    scores: np.ndarray | None = None,
    n_components: int | None = None,
) -> np.ndarray:
    """Reconstruct AOI probability trajectories that sum to one."""

    z = reconstruct_fpca(result.fpca, scores=scores, n_components=n_components)
    return inverse_alr(
        z,
        reference_dimension=result.reference_dimension,
        n_dimensions=len(result.original_dimension_names),
    )

Derived functions

eyetrajectoriespy.speed_function

speed_function(trajectories: TrajectorySet) -> TrajectorySet

Compute the Euclidean speed function of a planar gaze path.

Source code in src/eyetrajectoriespy/kinematics.py
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
def speed_function(trajectories: TrajectorySet) -> TrajectorySet:
    """Compute the Euclidean speed function of a planar gaze path."""

    if trajectories.n_dimensions < 2:
        raise ValueError("Planar x/y dimensions are required")
    velocity = differentiate_trajectories(trajectories, order=1)
    speed = np.linalg.norm(velocity.values[:, :, :2], axis=2, keepdims=True)
    return TrajectorySet(
        time=trajectories.time,
        values=speed,
        curve_ids=trajectories.curve_ids,
        dimension_names=("speed",),
        metadata=trajectories.metadata.reset_index(drop=True),
        coordinate_system=trajectories.coordinate_system,
        time_unit=trajectories.time_unit,
        provenance={**dict(velocity.provenance), "derived_function": "speed"},
    )

eyetrajectoriespy.acceleration_magnitude_function

acceleration_magnitude_function(trajectories: TrajectorySet) -> TrajectorySet

Compute Euclidean acceleration magnitude for planar trajectories.

Source code in src/eyetrajectoriespy/kinematics.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
def acceleration_magnitude_function(trajectories: TrajectorySet) -> TrajectorySet:
    """Compute Euclidean acceleration magnitude for planar trajectories."""

    acceleration = differentiate_trajectories(trajectories, order=2)
    magnitude = np.linalg.norm(acceleration.values[:, :, :2], axis=2, keepdims=True)
    return TrajectorySet(
        time=trajectories.time,
        values=magnitude,
        curve_ids=trajectories.curve_ids,
        dimension_names=("acceleration_magnitude",),
        metadata=trajectories.metadata.reset_index(drop=True),
        coordinate_system=trajectories.coordinate_system,
        time_unit=trajectories.time_unit,
        provenance={**dict(acceleration.provenance), "derived_function": "acceleration_magnitude"},
    )

eyetrajectoriespy.distance_to_landmark_function

distance_to_landmark_function(trajectories: TrajectorySet, *, landmark_x: float | ndarray, landmark_y: float | ndarray) -> TrajectorySet

Compute continuous Euclidean distance from gaze to a spatial landmark.

Source code in src/eyetrajectoriespy/kinematics.py
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
def distance_to_landmark_function(
    trajectories: TrajectorySet,
    *,
    landmark_x: float | np.ndarray,
    landmark_y: float | np.ndarray,
) -> TrajectorySet:
    """Compute continuous Euclidean distance from gaze to a spatial landmark."""

    validate_trajectory_set(trajectories)
    if trajectories.n_dimensions < 2:
        raise ValueError("Planar x/y dimensions are required")
    lx = np.asarray(landmark_x, dtype=float)
    ly = np.asarray(landmark_y, dtype=float)
    if lx.ndim == 0:
        lx = np.repeat(lx, trajectories.n_curves)
    if ly.ndim == 0:
        ly = np.repeat(ly, trajectories.n_curves)
    if lx.shape != (trajectories.n_curves,) or ly.shape != (trajectories.n_curves,):
        raise ValueError("landmark coordinates must be scalars or one value per trajectory")
    dx = trajectories.values[:, :, 0] - lx[:, None]
    dy = trajectories.values[:, :, 1] - ly[:, None]
    distance = np.sqrt(dx**2 + dy**2)[:, :, None]
    return TrajectorySet(
        time=trajectories.time,
        values=distance,
        curve_ids=trajectories.curve_ids,
        dimension_names=("distance_to_landmark",),
        metadata=trajectories.metadata.reset_index(drop=True),
        coordinate_system=trajectories.coordinate_system,
        time_unit=trajectories.time_unit,
        provenance={
            **dict(trajectories.provenance),
            "derived_function": "distance_to_landmark",
        },
    )

eyetrajectoriespy.cumulative_path_length

cumulative_path_length(trajectories: TrajectorySet) -> TrajectorySet

Compute cumulative 2-D path length as a function of trial time.

Source code in src/eyetrajectoriespy/kinematics.py
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
def cumulative_path_length(trajectories: TrajectorySet) -> TrajectorySet:
    """Compute cumulative 2-D path length as a function of trial time."""

    validate_trajectory_set(trajectories, require_complete=True)
    if trajectories.n_dimensions < 2:
        raise ValueError("Planar x/y dimensions are required")
    delta = np.diff(trajectories.values[:, :, :2], axis=1)
    steps = np.linalg.norm(delta, axis=2)
    cumulative = np.concatenate(
        [np.zeros((trajectories.n_curves, 1)), np.cumsum(steps, axis=1)], axis=1
    )[:, :, None]
    return TrajectorySet(
        time=trajectories.time,
        values=cumulative,
        curve_ids=trajectories.curve_ids,
        dimension_names=("cumulative_path_length",),
        metadata=trajectories.metadata.reset_index(drop=True),
        coordinate_system=trajectories.coordinate_system,
        time_unit=trajectories.time_unit,
        provenance={**dict(trajectories.provenance), "derived_function": "cumulative_path_length"},
    )

eyetrajectoriespy.heading_function

heading_function(trajectories: TrajectorySet, *, dimensions: Sequence[str] | None = None, min_speed: float = 0.0, undefined_policy: str = 'nan') -> TrajectorySet

Compute wrapped planar heading in radians without smoothing or unwrapping.

Source code in src/eyetrajectoriespy/kinematics.py
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
def heading_function(
    trajectories: TrajectorySet,
    *,
    dimensions: Sequence[str] | None = None,
    min_speed: float = 0.0,
    undefined_policy: str = "nan",
) -> TrajectorySet:
    """Compute wrapped planar heading in radians without smoothing or unwrapping."""

    names, velocity, _, speed = _planar_derivatives(
        trajectories,
        dimensions=dimensions,
    )
    heading = np.arctan2(velocity[:, :, 1], velocity[:, :, 0])
    heading, undefined = _apply_low_speed_contract(
        heading,
        speed=speed,
        min_speed=min_speed,
        undefined_policy=undefined_policy,
        curve_ids=trajectories.curve_ids,
        quantity="heading",
    )
    return _geometry_result(
        trajectories,
        values=heading,
        dimension_name="heading",
        planar_dimensions=names,
        min_speed=min_speed,
        undefined_policy=undefined_policy,
        undefined_mask=undefined,
        value_unit="radian",
        extra_provenance={
            "angle_range": "[-pi, pi]",
            "angle_unwrapped": False,
        },
    )

eyetrajectoriespy.signed_curvature_function

signed_curvature_function(trajectories: TrajectorySet, *, dimensions: Sequence[str] | None = None, min_speed: float = 0.0, undefined_policy: str = 'nan') -> TrajectorySet

Compute signed planar curvature without hidden denominator stabilization.

Source code in src/eyetrajectoriespy/kinematics.py
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
def signed_curvature_function(
    trajectories: TrajectorySet,
    *,
    dimensions: Sequence[str] | None = None,
    min_speed: float = 0.0,
    undefined_policy: str = "nan",
) -> TrajectorySet:
    """Compute signed planar curvature without hidden denominator stabilization."""

    names, velocity, acceleration, speed = _planar_derivatives(
        trajectories,
        dimensions=dimensions,
    )
    vx = velocity[:, :, 0]
    vy = velocity[:, :, 1]
    ax = acceleration[:, :, 0]
    ay = acceleration[:, :, 1]
    numerator = vx * ay - vy * ax
    speed_squared = vx**2 + vy**2
    denominator = np.power(speed_squared, 1.5)
    with np.errstate(divide="ignore", invalid="ignore"):
        curvature = numerator / denominator
    curvature, undefined = _apply_low_speed_contract(
        curvature,
        speed=speed,
        min_speed=min_speed,
        undefined_policy=undefined_policy,
        curve_ids=trajectories.curve_ids,
        quantity="signed curvature",
    )
    coordinate_unit = {
        "degrees": "1/degree",
        "pixels": "1/pixel",
        "normalized": "1/normalized_coordinate",
    }.get(trajectories.coordinate_system, "inverse_source_coordinate_unit")
    return _geometry_result(
        trajectories,
        values=curvature,
        dimension_name="signed_curvature",
        planar_dimensions=names,
        min_speed=min_speed,
        undefined_policy=undefined_policy,
        undefined_mask=undefined,
        value_unit=coordinate_unit,
        extra_provenance={
            "orientation_sign": (
                "positive_under_the_recorded_x_y_axis_orientation"
            ),
            "denominator_epsilon": None,
        },
    )

eyetrajectoriespy.turning_rate_function

turning_rate_function(trajectories: TrajectorySet, *, dimensions: Sequence[str] | None = None, min_speed: float = 0.0, undefined_policy: str = 'nan') -> TrajectorySet

Compute signed heading-change rate directly from planar derivatives.

Source code in src/eyetrajectoriespy/kinematics.py
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
def turning_rate_function(
    trajectories: TrajectorySet,
    *,
    dimensions: Sequence[str] | None = None,
    min_speed: float = 0.0,
    undefined_policy: str = "nan",
) -> TrajectorySet:
    """Compute signed heading-change rate directly from planar derivatives."""

    names, velocity, acceleration, speed = _planar_derivatives(
        trajectories,
        dimensions=dimensions,
    )
    vx = velocity[:, :, 0]
    vy = velocity[:, :, 1]
    ax = acceleration[:, :, 0]
    ay = acceleration[:, :, 1]
    numerator = vx * ay - vy * ax
    speed_squared = vx**2 + vy**2
    with np.errstate(divide="ignore", invalid="ignore"):
        rate = numerator / speed_squared
    rate, undefined = _apply_low_speed_contract(
        rate,
        speed=speed,
        min_speed=min_speed,
        undefined_policy=undefined_policy,
        curve_ids=trajectories.curve_ids,
        quantity="turning rate",
    )
    return _geometry_result(
        trajectories,
        values=rate,
        dimension_name="turning_rate",
        planar_dimensions=names,
        min_speed=min_speed,
        undefined_policy=undefined_policy,
        undefined_mask=undefined,
        value_unit=f"radian/{trajectories.time_unit}",
        extra_provenance={
            "computed_from_wrapped_heading": False,
            "denominator_epsilon": None,
        },
    )

eyetrajectoriespy.trajectory_tortuosity

trajectory_tortuosity(trajectories: TrajectorySet, *, dimensions: Sequence[str] | None = None, min_displacement: float = 0.0, undefined_policy: str = 'nan') -> pd.DataFrame

Return path-length / endpoint-displacement tortuosity per curve.

Source code in src/eyetrajectoriespy/kinematics.py
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
def trajectory_tortuosity(
    trajectories: TrajectorySet,
    *,
    dimensions: Sequence[str] | None = None,
    min_displacement: float = 0.0,
    undefined_policy: str = "nan",
) -> pd.DataFrame:
    """Return path-length / endpoint-displacement tortuosity per curve."""

    names, indices = _resolve_planar_dimensions(trajectories, dimensions)
    threshold = _nonnegative_finite_threshold(
        min_displacement,
        name="min_displacement",
    )
    policy = _validate_undefined_policy(undefined_policy)
    planar = trajectories.values[:, :, indices]
    steps = np.linalg.norm(np.diff(planar, axis=1), axis=2)
    path_length = np.sum(steps, axis=1)
    displacement = np.linalg.norm(planar[:, -1, :] - planar[:, 0, :], axis=1)
    undefined = displacement <= threshold

    if np.any(undefined) and policy == "raise":
        affected = [
            trajectories.curve_ids[index]
            for index in np.flatnonzero(undefined)
        ]
        raise ValueError(
            "trajectory tortuosity is undefined where endpoint displacement "
            f"<= min_displacement={threshold}; affected curves: {affected[:8]}"
        )

    tortuosity = np.full(trajectories.n_curves, np.nan, dtype=float)
    valid = ~undefined
    tortuosity[valid] = path_length[valid] / displacement[valid]

    table = pd.DataFrame(
        {
            "curve_id": trajectories.curve_ids,
            "path_length": path_length,
            "endpoint_displacement": displacement,
            "tortuosity": tortuosity,
            "undefined": undefined,
        }
    )
    for column in trajectories.metadata.columns:
        if column not in table.columns:
            table[column] = trajectories.metadata[column].to_numpy()

    table.attrs["provenance"] = {
        **dict(trajectories.provenance),
        "operation": "trajectory_tortuosity",
        "source_coordinate_system": trajectories.coordinate_system,
        "planar_dimensions": names,
        "definition": "path_length / endpoint_displacement",
        "min_displacement": threshold,
        "undefined_rule": "endpoint_displacement <= min_displacement",
        "undefined_policy": policy,
        "denominator_epsilon": None,
        "smoothing": False,
    }
    return table

Functional mixed-effects regression

eyetrajectoriespy.FunctionalMixedEffectsResult dataclass

Joint Gaussian functional mixed-effects regression fit.

Source code in src/eyetrajectoriespy/types.py
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
@dataclass(frozen=True)
class FunctionalMixedEffectsResult:
    """Joint Gaussian functional mixed-effects regression fit."""

    coefficient_functions: np.ndarray
    coefficient_standard_errors: np.ndarray
    fixed_basis_coefficients: np.ndarray
    fixed_parameter_covariance: np.ndarray
    fixed_basis: np.ndarray
    fixed_basis_knots: np.ndarray
    random_basis: np.ndarray
    random_basis_knots: np.ndarray
    random_intercept_basis: np.ndarray
    random_slope_basis: np.ndarray | None
    random_effect_design_matrix: np.ndarray
    random_effect_coefficients: np.ndarray
    random_effect_functions: np.ndarray
    random_intercept_coefficients: np.ndarray
    random_slope_coefficients: np.ndarray | None
    random_intercept_functions: np.ndarray
    random_slope_functions: np.ndarray | None
    random_effect_covariance: np.ndarray
    random_intercept_covariance: np.ndarray
    random_slope_covariance: np.ndarray | None
    random_intercept_slope_covariance: np.ndarray | None
    random_effect_covariance_eigenvalues: np.ndarray
    random_effect_covariance_condition_number: float
    random_effect_dimension: int
    random_effect_covariance_parameter_count: int
    random_effect_complexity_warning: bool
    random_effect_singular: bool
    random_slope_boundary_fit: bool
    random_slope_predictor: str | None
    residual_variance: float
    fitted_functions: np.ndarray
    residual_functions: np.ndarray
    observed_functions: np.ndarray
    scalar_design_matrix: np.ndarray
    coefficient_names: tuple[str, ...]
    predictor_names: tuple[str, ...]
    scalar_design_rank: int
    expanded_design_rank: int
    participant_column: str
    participant_ids: tuple[str, ...]
    curve_participant_ids: tuple[str, ...]
    curves_per_participant: tuple[int, ...]
    source_curve_ids: tuple[str, ...]
    time: np.ndarray
    dimension_name: str
    coordinate_system: str
    time_unit: str
    fixed_basis_size: int
    random_basis_size: int
    spline_degree: int
    reml: bool
    method: str
    maxiter: int
    converged: bool
    boundary_fit: bool
    backend_warnings: tuple[str, ...]
    log_likelihood: float
    provenance: Mapping[str, Any] = field(default_factory=dict)
    model: Any | None = None
    trial_column: str | None = None
    curve_trial_ids: tuple[str, ...] = ()
    trial_ids: tuple[str, ...] = ()
    trial_random_effect: str | None = None
    trial_random_basis_size: int = 0
    trial_random_basis: np.ndarray | None = None
    trial_random_basis_knots: np.ndarray | None = None
    trial_random_effect_coefficients: np.ndarray | None = None
    trial_random_effect_functions: np.ndarray | None = None
    trial_random_effect_covariance: np.ndarray | None = None
    trial_random_effect_covariance_eigenvalues: np.ndarray | None = None
    trial_random_effect_covariance_condition_number: float | None = None
    trial_random_effect_covariance_parameter_count: int = 0
    trial_random_effect_complexity_warning: bool = False
    trial_random_effect_boundary_fit: bool = False
    trial_random_effect_singular: bool = False
    residual_correlation: str = "iid"
    residual_correlation_parameter: float | None = None
    residual_correlation_parameter_name: str | None = None
    residual_correlation_parameter_unit: str | None = None
    residual_correlation_matrix: np.ndarray | None = None
    residual_correlation_eigenvalues: np.ndarray | None = None
    residual_correlation_condition_number: float | None = None
    residual_correlation_boundary_fit: bool = False
    residual_correlation_independence_limit_fit: bool = False
    residual_correlation_optimizer_bounds: tuple[float, float] | None = None
    residual_correlation_grid_regular: bool = False
    residual_correlation_grid_interval: float | None = None
    whitened_residual_functions: np.ndarray | None = None

    @property
    def n_coefficients(self) -> int:
        return len(self.coefficient_names)

    @property
    def n_participants(self) -> int:
        return len(self.participant_ids)

    @property
    def n_curves(self) -> int:
        return len(self.source_curve_ids)

    @property
    def n_trials(self) -> int:
        return len(self.trial_ids)

eyetrajectoriespy.fit_functional_mixed_effects_regression

fit_functional_mixed_effects_regression(trajectories: TrajectorySet, design: DataFrame, predictors: Sequence[str], *, participant_column: str, dimension: str, fixed_basis_size: int = 6, random_basis_size: int = 4, random_slope_predictor: str | None = None, trial_column: str | None = None, trial_random_effect: str | None = None, trial_random_basis_size: int = 3, residual_correlation: str = 'iid', spline_degree: int = 3, reml: bool = True, method: str = 'lbfgs', maxiter: int = 500) -> FunctionalMixedEffectsResult

Fit one joint Gaussian functional mixed-effects model.

The base model contains a participant functional random intercept. When random_slope_predictor explicitly names one declared fixed predictor, the model adds exactly one participant random functional slope for that predictor.

Fixed coefficient functions, the participant functional random intercept, and the optional random functional slope use explicitly sized clamped B-spline bases. Version 0.45 uses one common random basis size for the intercept and the single slope, with one unstructured covariance over the stacked random-basis coefficient vector.

Version 0.48 optionally adds one nested trial-level functional random intercept through an explicit profiled Gaussian marginal-likelihood backend. Version 0.49 extends that backend with explicit within-trial residual correlation: physical-time exponential correlation on arbitrary strictly increasing common grids, or index-step AR(1) on equally spaced grids. Residual correlation is block diagonal by source curve/trial and is never allowed to cross trial boundaries.

The historical statsmodels MixedLM path is preserved exactly for the backward-compatible participant-only residual_correlation="iid" model.

No random-slope predictor, trial random effect, residual-correlation family, basis size, interaction, or optimizer fallback is selected automatically.

Source code in src/eyetrajectoriespy/functional_mixed_effects.py
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
def fit_functional_mixed_effects_regression(
    trajectories: TrajectorySet,
    design: pd.DataFrame,
    predictors: Sequence[str],
    *,
    participant_column: str,
    dimension: str,
    fixed_basis_size: int = 6,
    random_basis_size: int = 4,
    random_slope_predictor: str | None = None,
    trial_column: str | None = None,
    trial_random_effect: str | None = None,
    trial_random_basis_size: int = 3,
    residual_correlation: str = "iid",
    spline_degree: int = 3,
    reml: bool = True,
    method: str = "lbfgs",
    maxiter: int = 500,
) -> FunctionalMixedEffectsResult:
    """Fit one joint Gaussian functional mixed-effects model.

    The base model contains a participant functional random intercept. When
    random_slope_predictor explicitly names one declared fixed predictor, the
    model adds exactly one participant random functional slope for that
    predictor.

    Fixed coefficient functions, the participant functional random intercept,
    and the optional random functional slope use explicitly sized clamped
    B-spline bases. Version 0.45 uses one common random basis size for the
    intercept and the single slope, with one unstructured covariance over the
    stacked random-basis coefficient vector.

    Version 0.48 optionally adds one nested trial-level functional random
    intercept through an explicit profiled Gaussian marginal-likelihood backend.
    Version 0.49 extends that backend with explicit within-trial residual
    correlation: physical-time exponential correlation on arbitrary strictly
    increasing common grids, or index-step AR(1) on equally spaced grids.
    Residual correlation is block diagonal by source curve/trial and is never
    allowed to cross trial boundaries.

    The historical statsmodels MixedLM path is preserved exactly for the
    backward-compatible participant-only `residual_correlation="iid"` model.

    No random-slope predictor, trial random effect, residual-correlation family,
    basis size, interaction, or optimizer fallback is selected automatically.
    """

    if not isinstance(residual_correlation, str) or not residual_correlation:
        raise TypeError("residual_correlation must be a non-empty string")
    normalized_residual_correlation = residual_correlation.lower().strip()

    if (
        trial_random_effect is not None
        or normalized_residual_correlation != "iid"
    ):
        from .functional_mixed_effects_nested import (
            fit_nested_functional_mixed_effects_regression,
        )

        return fit_nested_functional_mixed_effects_regression(
            trajectories,
            design,
            predictors,
            participant_column=participant_column,
            trial_column=trial_column,
            dimension=dimension,
            fixed_basis_size=fixed_basis_size,
            random_basis_size=random_basis_size,
            random_slope_predictor=random_slope_predictor,
            trial_random_effect=trial_random_effect,
            trial_random_basis_size=trial_random_basis_size,
            residual_correlation=normalized_residual_correlation,
            spline_degree=spline_degree,
            reml=reml,
            method=method,
            maxiter=maxiter,
        )
    if trial_column is not None:
        raise ValueError(
            "trial_column is only used when trial_random_effect is explicitly "
            "requested"
        )

    validate_trajectory_set(trajectories, require_complete=True)
    if not np.all(np.isfinite(trajectories.values)):
        raise ValueError(
            "functional mixed-effects regression requires finite complete "
            "trajectories"
        )
    if trajectories.coordinate_system == "probability_simplex":
        raise ValueError(
            "Direct Gaussian functional mixed-effects regression is not "
            "supported for probability_simplex trajectories"
        )
    if not isinstance(reml, bool):
        raise TypeError("reml must be boolean")
    if not isinstance(method, str) or not method:
        raise TypeError("method must be a non-empty string")
    if isinstance(maxiter, bool) or not isinstance(maxiter, int):
        raise TypeError("maxiter must be an integer")
    if maxiter < 1:
        raise ValueError("maxiter must be positive")
    if random_slope_predictor is not None and (
        not isinstance(random_slope_predictor, str)
        or not random_slope_predictor
    ):
        raise TypeError(
            "random_slope_predictor must be a non-empty string or None"
        )

    aligned_design, predictor_names = _validate_design_alignment(
        trajectories,
        design,
        predictors,
    )
    dimension_index, dimension_name = _validate_dimension(
        trajectories,
        dimension,
    )

    fixed_basis, fixed_knots = _bspline_basis(
        trajectories.time,
        n_basis=fixed_basis_size,
        degree=spline_degree,
    )
    random_basis, random_knots = _bspline_basis(
        trajectories.time,
        n_basis=random_basis_size,
        degree=spline_degree,
    )

    has_random_slope = random_slope_predictor is not None
    random_effect_dimension = random_basis_size * (2 if has_random_slope else 1)
    (
        curve_participants,
        participant_ids,
        curves_per_participant,
    ) = _validate_participants(
        trajectories,
        participant_column,
        random_effect_dimension=random_effect_dimension,
    )

    slope_values = _validate_random_slope(
        random_slope_predictor=random_slope_predictor,
        predictor_names=predictor_names,
        aligned_design=aligned_design,
        curve_participants=curve_participants,
        participant_ids=participant_ids,
    )

    predictor_matrix = aligned_design.loc[
        :, list(predictor_names)
    ].to_numpy(dtype=float)
    scalar_design = np.column_stack(
        [np.ones(trajectories.n_curves, dtype=float), predictor_matrix]
    )
    coefficient_names = ("Intercept",) + predictor_names
    scalar_rank = int(np.linalg.matrix_rank(scalar_design))
    if scalar_rank != scalar_design.shape[1]:
        raise ValueError(
            "scalar design matrix is rank deficient; remove redundant "
            "predictors or encode the model explicitly"
        )

    fixed_exog = _fixed_effect_design(scalar_design, fixed_basis)
    fixed_rank = int(np.linalg.matrix_rank(fixed_exog))
    if fixed_rank != fixed_exog.shape[1]:
        raise ValueError(
            "expanded functional fixed-effect design is rank deficient"
        )

    n_time = trajectories.n_time
    y = trajectories.values[:, :, dimension_index].reshape(-1)
    groups = np.repeat(curve_participants, n_time)
    random_exog = _random_effect_design(
        random_basis,
        n_curves=trajectories.n_curves,
        slope_values=slope_values,
    )
    random_design_rank = int(np.linalg.matrix_rank(random_exog))
    if random_design_rank != random_exog.shape[1]:
        raise ValueError(
            "expanded functional random-effect design is rank deficient"
        )

    covariance_parameter_count = (
        random_effect_dimension * (random_effect_dimension + 1) // 2
    )
    if has_random_slope and len(participant_ids) <= covariance_parameter_count:
        raise ValueError(
            "guarded random-functional-slope model requires the participant "
            "count to exceed the number of free unstructured random-effect "
            "covariance parameters; "
            f"got {len(participant_ids)} participants and "
            f"{covariance_parameter_count} covariance parameters"
        )
    covariance_complexity_warning = bool(
        len(participant_ids) <= covariance_parameter_count
    )

    model = MixedLM(
        endog=y,
        exog=fixed_exog,
        groups=groups,
        exog_re=random_exog,
        use_sqrt=True,
        missing="raise",
    )

    with warnings.catch_warnings(record=True) as captured:
        warnings.simplefilter("always")
        try:
            fitted_model = model.fit(
                reml=reml,
                method=method,
                maxiter=maxiter,
                disp=False,
            )
        except Exception as exc:
            raise RuntimeError(
                "statsmodels MixedLM failed before producing a valid fit"
            ) from exc

    warning_messages = tuple(dict.fromkeys(str(item.message) for item in captured))
    if not bool(getattr(fitted_model, "converged", False)):
        raise RuntimeError(
            "functional mixed-effects optimization did not converge"
        )

    n_coefficients = len(coefficient_names)
    expected_fixed = n_coefficients * fixed_basis_size
    fixed_parameters = np.asarray(fitted_model.fe_params, dtype=float)
    if fixed_parameters.size != expected_fixed:
        raise RuntimeError(
            "backend returned an unexpected number of fixed-effect parameters"
        )
    fixed_basis_coefficients = fixed_parameters.reshape(
        n_coefficients,
        fixed_basis_size,
    )
    coefficient_functions = fixed_basis_coefficients @ fixed_basis.T

    covariance_all = np.asarray(fitted_model.cov_params(), dtype=float)
    fixed_parameter_covariance = covariance_all[
        :expected_fixed,
        :expected_fixed,
    ]
    coefficient_standard_errors = np.empty(
        (n_coefficients, n_time),
        dtype=float,
    )
    for coefficient_index in range(n_coefficients):
        block_start = coefficient_index * fixed_basis_size
        block_stop = block_start + fixed_basis_size
        block = fixed_parameter_covariance[
            block_start:block_stop,
            block_start:block_stop,
        ]
        variance = np.einsum(
            "ti,ij,tj->t",
            fixed_basis,
            block,
            fixed_basis,
            optimize=True,
        )
        coefficient_standard_errors[coefficient_index] = np.sqrt(
            np.maximum(variance, 0.0)
        )

    random_effect_covariance = np.asarray(
        fitted_model.cov_re,
        dtype=float,
    )
    expected_covariance_shape = (
        random_effect_dimension,
        random_effect_dimension,
    )
    if random_effect_covariance.shape != expected_covariance_shape:
        raise RuntimeError(
            "backend returned an unexpected random-effect covariance shape"
        )
    if not np.all(np.isfinite(random_effect_covariance)):
        raise RuntimeError(
            "backend returned non-finite random-effect covariance values"
        )
    if not np.allclose(
        random_effect_covariance,
        random_effect_covariance.T,
        rtol=1e-10,
        atol=1e-12,
    ):
        raise RuntimeError(
            "backend returned a non-symmetric random-effect covariance"
        )

    covariance_eigenvalues = np.linalg.eigvalsh(random_effect_covariance)
    covariance_scale = max(
        1.0,
        float(np.max(np.abs(random_effect_covariance))),
    )
    negative_tolerance = 1e-10 * covariance_scale
    if float(np.min(covariance_eigenvalues)) < -negative_tolerance:
        raise RuntimeError(
            "fitted random-effect covariance is not positive semidefinite"
        )

    boundary_fit = bool(
        float(np.min(covariance_eigenvalues))
        <= 1e-8 * covariance_scale
    )
    largest_covariance_eigenvalue = float(
        np.max(np.abs(covariance_eigenvalues))
    )
    singular_tolerance = max(
        np.finfo(float).eps * max(1.0, largest_covariance_eigenvalue),
        1e-14,
    )
    random_effect_singular = bool(
        float(np.min(covariance_eigenvalues)) <= singular_tolerance
    )
    if float(np.min(covariance_eigenvalues)) <= 0.0:
        covariance_condition_number = float("inf")
    else:
        covariance_condition_number = float(
            np.max(covariance_eigenvalues)
            / np.min(covariance_eigenvalues)
        )

    random_intercept_covariance = random_effect_covariance[
        :random_basis_size,
        :random_basis_size,
    ].copy()
    if has_random_slope:
        random_slope_covariance = random_effect_covariance[
            random_basis_size:,
            random_basis_size:,
        ].copy()
        random_intercept_slope_covariance = random_effect_covariance[
            :random_basis_size,
            random_basis_size:,
        ].copy()
        slope_eigenvalues = np.linalg.eigvalsh(random_slope_covariance)
        slope_boundary_reference = max(
            largest_covariance_eigenvalue,
            np.finfo(float).eps,
        )
        random_slope_boundary_fit = bool(
            float(np.max(slope_eigenvalues))
            <= 0.10 * slope_boundary_reference
            or float(np.min(slope_eigenvalues))
            <= 1e-8 * covariance_scale
        )
    else:
        random_slope_covariance = None
        random_intercept_slope_covariance = None
        random_slope_boundary_fit = False

    random_effect_coefficients = np.empty(
        (len(participant_ids), random_effect_dimension),
        dtype=float,
    )
    for participant_index, participant_id in enumerate(participant_ids):
        try:
            random_values = np.asarray(
                fitted_model.random_effects[participant_id],
                dtype=float,
            )
        except Exception as exc:
            raise RuntimeError(
                "backend could not recover participant random effects"
            ) from exc
        if random_values.size != random_effect_dimension:
            raise RuntimeError(
                "backend returned an unexpected participant random-effect "
                "dimension"
            )
        random_effect_coefficients[participant_index] = random_values

    random_intercept_coefficients = random_effect_coefficients[
        :, :random_basis_size
    ].copy()
    random_intercept_functions = (
        random_intercept_coefficients @ random_basis.T
    )
    if has_random_slope:
        random_slope_coefficients = random_effect_coefficients[
            :, random_basis_size:
        ].copy()
        random_slope_functions = random_slope_coefficients @ random_basis.T
    else:
        random_slope_coefficients = None
        random_slope_functions = None

    random_effect_functions = random_intercept_functions.copy()

    participant_lookup = {
        participant_id: index
        for index, participant_id in enumerate(participant_ids)
    }

    fixed_fitted = scalar_design @ coefficient_functions
    fitted_functions = np.empty(
        (trajectories.n_curves, n_time),
        dtype=float,
    )
    for curve_index, participant_id in enumerate(curve_participants):
        participant_index = participant_lookup[participant_id]
        random_contribution = random_intercept_functions[participant_index]
        if random_slope_functions is not None and slope_values is not None:
            random_contribution = (
                random_contribution
                + slope_values[curve_index]
                * random_slope_functions[participant_index]
            )
        fitted_functions[curve_index] = (
            fixed_fitted[curve_index] + random_contribution
        )
    observed_functions = trajectories.values[:, :, dimension_index].copy()
    residual_functions = observed_functions - fitted_functions
    residual_variance = float(fitted_model.scale)
    whitened_residual_functions = (
        residual_functions / np.sqrt(residual_variance)
    )
    residual_correlation_matrix = np.eye(n_time, dtype=float)

    return FunctionalMixedEffectsResult(
        coefficient_functions=coefficient_functions,
        coefficient_standard_errors=coefficient_standard_errors,
        fixed_basis_coefficients=fixed_basis_coefficients,
        fixed_parameter_covariance=fixed_parameter_covariance,
        fixed_basis=fixed_basis,
        fixed_basis_knots=fixed_knots,
        random_basis=random_basis,
        random_basis_knots=random_knots,
        random_intercept_basis=random_basis.copy(),
        random_slope_basis=(
            None if not has_random_slope else random_basis.copy()
        ),
        random_effect_design_matrix=random_exog.copy(),
        random_effect_coefficients=random_effect_coefficients,
        random_effect_functions=random_effect_functions,
        random_intercept_coefficients=random_intercept_coefficients,
        random_slope_coefficients=random_slope_coefficients,
        random_intercept_functions=random_intercept_functions,
        random_slope_functions=random_slope_functions,
        random_effect_covariance=random_effect_covariance,
        random_intercept_covariance=random_intercept_covariance,
        random_slope_covariance=random_slope_covariance,
        random_intercept_slope_covariance=random_intercept_slope_covariance,
        random_effect_covariance_eigenvalues=covariance_eigenvalues.copy(),
        random_effect_covariance_condition_number=covariance_condition_number,
        random_effect_dimension=random_effect_dimension,
        random_effect_covariance_parameter_count=covariance_parameter_count,
        random_effect_complexity_warning=covariance_complexity_warning,
        random_effect_singular=random_effect_singular,
        random_slope_boundary_fit=random_slope_boundary_fit,
        random_slope_predictor=random_slope_predictor,
        residual_variance=residual_variance,
        fitted_functions=fitted_functions,
        residual_functions=residual_functions,
        observed_functions=observed_functions,
        scalar_design_matrix=scalar_design,
        coefficient_names=coefficient_names,
        predictor_names=predictor_names,
        scalar_design_rank=scalar_rank,
        expanded_design_rank=fixed_rank,
        participant_column=participant_column,
        participant_ids=participant_ids,
        curve_participant_ids=tuple(curve_participants),
        curves_per_participant=curves_per_participant,
        source_curve_ids=trajectories.curve_ids,
        time=trajectories.time.copy(),
        dimension_name=dimension_name,
        coordinate_system=trajectories.coordinate_system,
        time_unit=trajectories.time_unit,
        fixed_basis_size=fixed_basis_size,
        random_basis_size=random_basis_size,
        spline_degree=spline_degree,
        reml=reml,
        method=method,
        maxiter=maxiter,
        converged=True,
        boundary_fit=boundary_fit,
        backend_warnings=warning_messages,
        log_likelihood=float(fitted_model.llf),
        provenance={
            **dict(trajectories.provenance),
            "functional_mixed_effects_regression": {
                "method": "joint_stacked_gaussian_linear_mixed_model",
                "backend": "statsmodels.MixedLM",
                "response_dimension": dimension_name,
                "participant_column": participant_column,
                "n_source_curves": trajectories.n_curves,
                "n_participants": len(participant_ids),
                "n_grid_observations": int(
                    trajectories.n_curves * n_time
                ),
                "coefficient_names": list(coefficient_names),
                "predictors": list(predictor_names),
                "design_alignment": aligned_design.attrs[
                    "eyetrajectoriespy_alignment"
                ],
                "fixed_basis": "clamped_bspline",
                "fixed_basis_size": fixed_basis_size,
                "random_intercept_basis": "clamped_bspline",
                "random_slope_basis": (
                    None if not has_random_slope else "clamped_bspline"
                ),
                "random_basis_size": random_basis_size,
                "spline_degree": spline_degree,
                "basis_size_selected_automatically": False,
                "smoothing_penalty": False,
                "categorical_encoding": False,
                "predictor_centering": False,
                "predictor_scaling": False,
                "interaction_construction": False,
                "automatic_model_selection": False,
                "automatic_random_slope_selection": False,
                "random_effect": (
                    "participant_functional_intercept"
                    if not has_random_slope
                    else "participant_functional_intercept_plus_one_slope"
                ),
                "random_slope_predictor": random_slope_predictor,
                "random_effect_dimension": random_effect_dimension,
                "random_effect_design_rank": random_design_rank,
                "random_basis_covariance": "unstructured",
                "random_effect_covariance_parameter_count": (
                    covariance_parameter_count
                ),
                "participants_per_covariance_parameter": float(
                    len(participant_ids) / covariance_parameter_count
                ),
                "covariance_complexity_warning": (
                    covariance_complexity_warning
                ),
                "covariance_complexity_warning_rule": (
                    "n_participants <= random_effect_covariance_parameter_count"
                ),
                "random_slope_covariance_complexity_guard": (
                    "require n_participants > covariance_parameter_count"
                    if has_random_slope
                    else None
                ),
                "random_slope_boundary_rule": (
                    "max_slope_covariance_eigenvalue <= "
                    "0.10 * max_abs_full_covariance_eigenvalue OR "
                    "min_slope_covariance_eigenvalue <= 1e-8 * covariance_scale"
                    if has_random_slope
                    else None
                ),
                "random_effect_covariance_eigenvalues": (
                    covariance_eigenvalues.tolist()
                ),
                "random_effect_covariance_condition_number": (
                    covariance_condition_number
                ),
                "random_effect_singular": random_effect_singular,
                "random_slope_boundary_fit": random_slope_boundary_fit,
                "random_effect_functions_legacy_alias": (
                    "random_intercept_functions"
                ),
                "curve_level_functional_random_effect": False,
                "residual_structure": (
                    "conditionally_iid_gaussian_grid_errors"
                ),
                "residual_correlation": "iid",
                "automatic_residual_correlation_selection": False,
                "residual_correlation_crosses_trial_boundaries": False,
                "trial_varying_predictors_supported": True,
                "random_slope_requires_within_participant_variation": (
                    has_random_slope
                ),
                "joint_fit_over_all_time_points": True,
                "pointwise_mixed_models": False,
                "multivariate_cross_dimension_covariance": False,
                "reml": reml,
                "optimizer": method,
                "maxiter": maxiter,
                "converged": True,
                "boundary_fit": boundary_fit,
                "backend_warnings": list(warning_messages),
            },
        },
        model=fitted_model,
        residual_correlation="iid",
        residual_correlation_parameter=None,
        residual_correlation_parameter_name=None,
        residual_correlation_parameter_unit=None,
        residual_correlation_matrix=residual_correlation_matrix,
        residual_correlation_eigenvalues=np.ones(n_time, dtype=float),
        residual_correlation_condition_number=1.0,
        residual_correlation_boundary_fit=False,
        residual_correlation_independence_limit_fit=True,
        residual_correlation_optimizer_bounds=None,
        residual_correlation_grid_regular=bool(
            np.allclose(
                np.diff(trajectories.time),
                np.diff(trajectories.time)[0],
                rtol=1e-8,
                atol=max(
                    1e-12,
                    abs(float(np.diff(trajectories.time)[0])) * 1e-10,
                ),
            )
        ),
        residual_correlation_grid_interval=(
            float(np.diff(trajectories.time)[0])
            if np.allclose(
                np.diff(trajectories.time),
                np.diff(trajectories.time)[0],
                rtol=1e-8,
                atol=max(
                    1e-12,
                    abs(float(np.diff(trajectories.time)[0])) * 1e-10,
                ),
            )
            else None
        ),
        whitened_residual_functions=whitened_residual_functions,
    )

eyetrajectoriespy.functional_mixed_effects_coefficient_frame

functional_mixed_effects_coefficient_frame(result: FunctionalMixedEffectsResult, *, band: FunctionalMixedEffectsBandResult | None = None) -> pd.DataFrame

Return fixed-effect coefficient functions and optional simultaneous bands.

Source code in src/eyetrajectoriespy/functional_mixed_effects.py
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
def functional_mixed_effects_coefficient_frame(
    result: FunctionalMixedEffectsResult,
    *,
    band: FunctionalMixedEffectsBandResult | None = None,
) -> pd.DataFrame:
    """Return fixed-effect coefficient functions and optional simultaneous bands."""

    if not isinstance(result, FunctionalMixedEffectsResult):
        raise TypeError("result must be a FunctionalMixedEffectsResult")
    if band is not None:
        if not isinstance(band, FunctionalMixedEffectsBandResult):
            raise TypeError(
                "band must be a FunctionalMixedEffectsBandResult or None"
            )
        if band.reference is not result:
            raise ValueError("band.reference must be the supplied result object")

    rows: list[dict[str, float | str]] = []
    z_value = 1.959963984540054
    for coefficient_index, coefficient_name in enumerate(
        result.coefficient_names
    ):
        for time_index, time_value in enumerate(result.time):
            estimate = float(
                result.coefficient_functions[
                    coefficient_index,
                    time_index,
                ]
            )
            standard_error = float(
                result.coefficient_standard_errors[
                    coefficient_index,
                    time_index,
                ]
            )
            row: dict[str, float | str] = {
                "coefficient": coefficient_name,
                "time": float(time_value),
                "dimension": result.dimension_name,
                "estimate": estimate,
                "standard_error": standard_error,
                "lower_95_wald": estimate - z_value * standard_error,
                "upper_95_wald": estimate + z_value * standard_error,
            }
            if band is not None:
                row.update(
                    {
                        "bootstrap_standard_error": float(
                            band.pointwise_standard_errors[
                                coefficient_index,
                                time_index,
                            ]
                        ),
                        "lower_simultaneous": float(
                            band.lower[
                                coefficient_index,
                                time_index,
                            ]
                        ),
                        "upper_simultaneous": float(
                            band.upper[
                                coefficient_index,
                                time_index,
                            ]
                        ),
                        "simultaneous_critical_value": float(
                            band.critical_values[coefficient_index]
                        ),
                        "simultaneous_scope": band.simultaneous_scope,
                    }
                )
            rows.append(row)
    return pd.DataFrame(rows)

eyetrajectoriespy.plot_functional_mixed_effects_coefficient

plot_functional_mixed_effects_coefficient(result: FunctionalMixedEffectsResult | FunctionalMixedEffectsBandResult, *, coefficient: str | int, ax=None)

Plot one mixed-effects coefficient with pointwise or simultaneous uncertainty.

Source code in src/eyetrajectoriespy/plotting.py
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
def plot_functional_mixed_effects_coefficient(
    result: FunctionalMixedEffectsResult | FunctionalMixedEffectsBandResult,
    *,
    coefficient: str | int,
    ax=None,
):
    """Plot one mixed-effects coefficient with pointwise or simultaneous uncertainty."""

    if isinstance(result, FunctionalMixedEffectsBandResult):
        fit = result.reference
        band = result
    elif isinstance(result, FunctionalMixedEffectsResult):
        fit = result
        band = None
    else:
        raise TypeError(
            "result must be a FunctionalMixedEffectsResult or "
            "FunctionalMixedEffectsBandResult"
        )

    if isinstance(coefficient, str):
        if coefficient not in fit.coefficient_names:
            raise KeyError(f"Unknown coefficient {coefficient!r}")
        coefficient_index = fit.coefficient_names.index(coefficient)
    elif isinstance(coefficient, bool) or not isinstance(
        coefficient,
        (int, np.integer),
    ):
        raise TypeError("coefficient must be a name or integer index")
    else:
        coefficient_index = int(coefficient)
        if coefficient_index < 0 or coefficient_index >= fit.n_coefficients:
            raise IndexError("coefficient index is out of range")

    if ax is None:
        _, ax = plt.subplots()

    estimate = fit.coefficient_functions[coefficient_index]
    if band is None:
        standard_error = fit.coefficient_standard_errors[coefficient_index]
        z_value = 1.959963984540054
        lower = estimate - z_value * standard_error
        upper = estimate + z_value * standard_error
        label = "95% pointwise Wald interval"
    else:
        lower = band.lower[coefficient_index]
        upper = band.upper[coefficient_index]
        label = (
            f"{100 * band.confidence_level:.1f}% simultaneous band "
            f"({band.simultaneous_scope})"
        )

    ax.fill_between(
        fit.time,
        lower,
        upper,
        alpha=0.2,
        label=label,
    )
    ax.plot(
        fit.time,
        estimate,
        label=fit.coefficient_names[coefficient_index],
    )
    ax.axhline(0.0, linestyle="--")
    ax.set_xlabel(f"Time ({fit.time_unit})")
    ax.set_ylabel(f"Coefficient: {fit.dimension_name}")
    ax.set_title(
        "Functional mixed-effects coefficient: "
        f"{fit.coefficient_names[coefficient_index]}"
    )
    ax.legend()
    return ax

eyetrajectoriespy.functional_mixed_effects_reporting_text

functional_mixed_effects_reporting_text(result: FunctionalMixedEffectsResult, *, band: FunctionalMixedEffectsBandResult | None = None) -> str

Generate manuscript-oriented wording for a functional mixed-effects fit.

Source code in src/eyetrajectoriespy/reporting.py
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
def functional_mixed_effects_reporting_text(
    result: FunctionalMixedEffectsResult,
    *,
    band: FunctionalMixedEffectsBandResult | None = None,
) -> str:
    """Generate manuscript-oriented wording for a functional mixed-effects fit."""

    if not isinstance(result, FunctionalMixedEffectsResult):
        raise TypeError("result must be a FunctionalMixedEffectsResult")
    if band is not None:
        if not isinstance(band, FunctionalMixedEffectsBandResult):
            raise TypeError(
                "band must be a FunctionalMixedEffectsBandResult or None"
            )
        if band.reference is not result:
            raise ValueError("band.reference must be the supplied result object")

    predictor_text = ", ".join(result.predictor_names)
    warning_text = ""
    if result.boundary_fit:
        if result.trial_random_effect is None:
            warning_text += (
                " The participant random-effect covariance was estimated on or "
                "near the numerical boundary and should be interpreted cautiously."
            )
        else:
            warning_text += (
                " At least one fitted participant/trial random-effect covariance "
                "component was estimated on or near the numerical boundary and "
                "should be interpreted cautiously."
            )
    if result.random_effect_singular:
        warning_text += (
            " The fitted participant random-effect covariance was numerically "
            "singular under the retained diagnostic tolerance."
        )
    if result.trial_random_effect_singular:
        warning_text += (
            " The fitted trial random-effect covariance was numerically "
            "singular under the retained diagnostic tolerance."
        )
    if result.random_effect_complexity_warning:
        warning_text += (
            " The number of participants did not exceed the number of free "
            "unstructured random-effect covariance parameters; this "
            "covariance-complexity warning should be reported."
        )
    if result.residual_correlation_boundary_fit:
        warning_text += (
            " The fitted residual-correlation parameter was at or near its "
            "recorded numerical optimizer bound and should be interpreted as "
            "a boundary diagnostic rather than an ordinary interior estimate."
        )
    if result.residual_correlation_independence_limit_fit:
        warning_text += (
            " The fitted exponential residual correlation is at the recorded "
            "practical independence limit on the observed grid (maximum "
            "off-diagonal correlation no greater than 0.05); phi is therefore "
            "weakly identified toward the iid boundary and should not be "
            "interpreted as a precise positive range estimate."
        )
    if result.backend_warnings:
        warning_text += (
            " Backend warnings were retained in the result provenance rather "
            "than suppressed."
        )

    if result.random_slope_predictor is None:
        random_text = (
            "the participant-specific functional random intercept used "
            f"{result.random_basis_size} B-spline basis functions with an "
            "unstructured basis-coefficient covariance"
        )
    else:
        random_text = (
            "the participant random-effect structure contained a functional "
            "random intercept and exactly one functional random slope for "
            f"{result.random_slope_predictor!r}, each using "
            f"{result.random_basis_size} B-spline basis functions. Their "
            f"stacked {result.random_effect_dimension}-dimensional random "
            "coefficient vector used one unstructured covariance with "
            f"{result.random_effect_covariance_parameter_count} free "
            "covariance parameters"
        )

    if result.trial_random_effect == "functional_intercept":
        trial_text = (
            " A nested trial-specific functional random intercept was also "
            f"estimated for {result.n_trials} trials using "
            f"{result.trial_random_basis_size} B-spline basis functions and "
            "one shared unstructured trial-basis covariance with "
            f"{result.trial_random_effect_covariance_parameter_count} free "
            "covariance parameters. Trial identifiers were required to be "
            "unique within participant, and every participant contributed at "
            "least two trials"
        )
    else:
        trial_text = ""

    if result.residual_correlation == "iid":
        residual_text = (
            "Grid-level residuals were conditionally iid Gaussian within each "
            "source curve/trial."
        )
    elif result.residual_correlation == "exponential":
        residual_text = (
            "Grid-level residuals used a within-trial continuous-time "
            "exponential correlation with jointly estimated range "
            f"phi={result.residual_correlation_parameter:.4g} "
            f"{result.residual_correlation_parameter_unit}; residual "
            "correlation was block diagonal across trials."
        )
    elif result.residual_correlation == "ar1":
        residual_text = (
            "Grid-level residuals used a within-trial index-step AR(1) "
            "correlation on the verified regular grid with jointly estimated "
            f"rho={result.residual_correlation_parameter:.4g}; residual "
            "correlation was block diagonal across trials."
        )
    else:
        raise ValueError(
            f"unsupported residual_correlation {result.residual_correlation!r}"
        )

    inference_text = (
        " Reported 95% coefficient intervals are pointwise Wald intervals; "
        "simultaneous functional coverage and variance-component uncertainty "
        "are not claimed."
    )
    if band is not None:
        if isinstance(
            band.bootstrap,
            FunctionalMixedEffectsFullRefitBootstrapResult,
        ):
            inference_text = (
                f" Full-refit participant bootstrap "
                f"{100 * band.confidence_level:.1f}% simultaneous coefficient "
                f"bands used {band.bootstrap.n_bootstrap} whole-participant "
                f"resamples with {band.simultaneous_scope}-scope maxima over "
                "the observed time grid. Every sampled participant occurrence "
                "received a distinct bootstrap group identity, and each "
                "replicate refitted fixed coefficients, the participant "
                "random-effect covariance"
                + (
                    ", the shared trial random-effect covariance"
                    if result.trial_random_effect is not None
                    else ""
                )
                + ", residual variance"
                + (
                    f", and the {result.residual_correlation} residual-"
                    "correlation parameter"
                    if result.residual_correlation != "iid"
                    else ""
                )
                + " under the unchanged declared model specification. "
                "Basis sizes, preprocessing, "
                "predictors, random-slope structure, REML/ML choice, and "
                "optimizer were not reselected. The bands therefore include "
                "variance-component re-estimation across participant bootstrap "
                "samples but not model-selection, preprocessing, or basis-"
                "selection uncertainty, and they do not claim simultaneous "
                "coverage between unsampled grid points."
            )
        else:
            inference_text = (
                f" Participant-cluster bootstrap "
                f"{100 * band.confidence_level:.1f}% simultaneous coefficient "
                f"bands used {band.bootstrap.n_bootstrap} whole-participant "
                f"resamples with {band.simultaneous_scope}-scope maxima over "
                "the observed time grid. Fixed coefficient functions were "
                "re-estimated by GLS in every resample while the fitted "
                "participant random-effect covariance"
                + (
                    ", shared trial random-effect covariance"
                    if result.trial_random_effect is not None
                    else ""
                )
                + ", residual variance"
                + (
                    f", and the fitted {result.residual_correlation} residual "
                    "correlation"
                    if result.residual_correlation != "iid"
                    else ""
                )
                + " were held fixed. Thus "
                "the bands target participant-sampling variability conditional "
                "on the declared basis and fitted covariance model; they do not "
                "include variance-component or basis-selection uncertainty and "
                "do not claim simultaneous coverage between unsampled grid "
                "points."
            )

    return (
        "A Gaussian functional mixed-effects regression was fitted jointly "
        "over all curve-by-time observations for dimension "
        f"{result.dimension_name!r}. Fixed coefficient functions for "
        f"{predictor_text} and the intercept used a clamped B-spline basis "
        f"with {result.fixed_basis_size} functions (degree "
        f"{result.spline_degree}); "
        + random_text
        + "."
        + trial_text
        + " "
        f"The model included {result.n_curves} curves from "
        f"{result.n_participants} participants and was estimated by "
        f"{'REML' if result.reml else 'ML'} using optimizer "
        f"{result.method!r}. The fitted random-effect covariance condition "
        f"number was {result.random_effect_covariance_condition_number:.3g}. "
        "Trial-varying predictors were retained at the curve level while "
        "participant clustering was represented directly. "
        + residual_text
        + " No automatic categorical "
        "encoding, interaction construction, predictor scaling, basis-size "
        "selection, random-slope selection, trial-random-effect selection, "
        "residual-correlation-family selection, smoothing-penalty selection, "
        "or optimizer fallback was performed."
        + inference_text
        + warning_text
    )

eyetrajectoriespy.functional_mixed_effects_whitened_residuals

functional_mixed_effects_whitened_residuals(result: FunctionalMixedEffectsResult) -> np.ndarray

Return conditional residual functions whitened within each trial.

Whitening uses the fitted residual covariance only. Random effects remain conditioned on their fitted BLUPs, so these are model-scale diagnostic residuals rather than independent observations with parameter uncertainty removed.

Source code in src/eyetrajectoriespy/functional_mixed_effects_nested.py
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
def functional_mixed_effects_whitened_residuals(
    result: FunctionalMixedEffectsResult,
) -> np.ndarray:
    """Return conditional residual functions whitened within each trial.

    Whitening uses the fitted residual covariance only. Random effects remain
    conditioned on their fitted BLUPs, so these are model-scale diagnostic
    residuals rather than independent observations with parameter uncertainty
    removed.
    """

    if not isinstance(result, FunctionalMixedEffectsResult):
        raise TypeError("result must be a FunctionalMixedEffectsResult")
    residuals = np.asarray(result.residual_functions, dtype=float)
    if residuals.shape != (result.n_curves, result.time.size):
        raise ValueError(
            "residual_functions must have shape (n_curves, n_time)"
        )
    if not np.all(np.isfinite(residuals)):
        raise ValueError("residual_functions must be finite")
    if result.residual_variance <= 0 or not np.isfinite(
        result.residual_variance
    ):
        raise ValueError("residual_variance must be finite and positive")

    if result.residual_correlation_matrix is None:
        correlation = np.eye(result.time.size, dtype=float)
    else:
        correlation = np.asarray(
            result.residual_correlation_matrix,
            dtype=float,
        )
    if correlation.shape != (result.time.size, result.time.size):
        raise ValueError(
            "residual_correlation_matrix has an unexpected shape"
        )
    covariance = result.residual_variance * correlation
    try:
        chol = np.linalg.cholesky(covariance)
    except np.linalg.LinAlgError as exc:
        raise RuntimeError(
            "fitted residual covariance is not positive definite"
        ) from exc

    whitened = np.empty_like(residuals, dtype=float)
    for curve_index, residual in enumerate(residuals):
        whitened[curve_index] = solve_triangular(
            chol,
            residual,
            lower=True,
            check_finite=False,
        )
    return whitened

eyetrajectoriespy.FunctionalMixedEffectsResidualDiagnosticsResult dataclass

Descriptive within-trial residual-dependence diagnostics.

Source code in src/eyetrajectoriespy/types.py
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
@dataclass(frozen=True)
class FunctionalMixedEffectsResidualDiagnosticsResult:
    """Descriptive within-trial residual-dependence diagnostics."""

    reference: FunctionalMixedEffectsResult
    trial_diagnostics: pd.DataFrame
    participant_diagnostics: pd.DataFrame
    overall_diagnostics: pd.DataFrame
    max_lag: int
    provenance: Mapping[str, Any] = field(default_factory=dict)
    residual_scale: str = "raw"

    @property
    def n_curves(self) -> int:
        return self.reference.n_curves

    @property
    def n_participants(self) -> int:
        return self.reference.n_participants

eyetrajectoriespy.functional_mixed_effects_residual_diagnostics

functional_mixed_effects_residual_diagnostics(result: FunctionalMixedEffectsResult, *, max_lag: int, residual_scale: Literal['raw', 'whitened'] = 'raw') -> FunctionalMixedEffectsResidualDiagnosticsResult

Compute descriptive residual-dependence diagnostics.

Parameters:

Name Type Description Default
result FunctionalMixedEffectsResult

Converged likelihood-based functional mixed-effects fit.

required
max_lag int

Largest within-trial index lag to inspect. The value is always explicit; no automatic lag selection is performed.

required

Returns:

Type Description
FunctionalMixedEffectsResidualDiagnosticsResult

Trial-level diagnostics plus pair-count-weighted participant and overall summaries.

Notes

Diagnostics use the fitted model's conditional residual functions. For trial j with residuals r_j(t_m), the within-trial centered residual is

e_j(t_m) = r_j(t_m) - mean_m r_j(t_m).

At index lag h, autocovariance is the mean of e_j(t_m)e_j(t_{m+h}), autocorrelation divides by the lag-zero autocovariance, and semivariance is half the mean squared residual difference. Trials with exactly zero residual variance are retained; autocorrelation is undefined (NaN) and explicitly counted in summaries.

The common time grid is not assumed equally spaced. Each index lag retains its mean, minimum, and maximum physical time separation. Use :func:functional_mixed_effects_residual_pair_frame when the exact physical lag for every residual pair is needed.

Source code in src/eyetrajectoriespy/functional_mixed_effects_diagnostics.py
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
def functional_mixed_effects_residual_diagnostics(
    result: FunctionalMixedEffectsResult,
    *,
    max_lag: int,
    residual_scale: Literal["raw", "whitened"] = "raw",
) -> FunctionalMixedEffectsResidualDiagnosticsResult:
    """Compute descriptive residual-dependence diagnostics.

    Parameters
    ----------
    result:
        Converged likelihood-based functional mixed-effects fit.
    max_lag:
        Largest within-trial index lag to inspect. The value is always explicit;
        no automatic lag selection is performed.

    Returns
    -------
    FunctionalMixedEffectsResidualDiagnosticsResult
        Trial-level diagnostics plus pair-count-weighted participant and overall
        summaries.

    Notes
    -----
    Diagnostics use the fitted model's conditional residual functions. For
    trial j with residuals r_j(t_m), the within-trial centered residual is

        e_j(t_m) = r_j(t_m) - mean_m r_j(t_m).

    At index lag h, autocovariance is the mean of
    e_j(t_m)e_j(t_{m+h}), autocorrelation divides by the lag-zero
    autocovariance, and semivariance is half the mean squared residual
    difference. Trials with exactly zero residual variance are retained;
    autocorrelation is undefined (NaN) and explicitly counted in summaries.

    The common time grid is not assumed equally spaced. Each index lag retains
    its mean, minimum, and maximum physical time separation. Use
    :func:`functional_mixed_effects_residual_pair_frame` when the exact
    physical lag for every residual pair is needed.
    """

    _validate_reference(result)
    residuals = _residual_array(result, residual_scale)
    residual_scale_value = str(residual_scale).lower().strip()
    time = np.asarray(result.time, dtype=float)
    max_lag_value = _validate_max_lag(max_lag, time.size)

    lag_summaries = {
        lag: _lag_time_summary(time, lag)
        for lag in range(max_lag_value + 1)
    }

    rows: list[dict[str, object]] = []
    for curve_index, residual in enumerate(residuals):
        residual_mean = float(np.mean(residual))
        centered = residual - residual_mean
        variance = float(np.mean(centered * centered))
        zero_variance = bool(variance == 0.0)
        residual_rms = float(np.sqrt(np.mean(residual * residual)))
        residual_sd = float(np.sqrt(variance))

        for lag in range(max_lag_value + 1):
            left = centered[: time.size - lag] if lag else centered
            right = centered[lag:] if lag else centered
            raw_left = residual[: time.size - lag] if lag else residual
            raw_right = residual[lag:] if lag else residual
            n_pairs = int(left.size)

            autocovariance = float(np.mean(left * right))
            autocorrelation = (
                float(autocovariance / variance)
                if not zero_variance
                else float("nan")
            )
            semivariance = float(
                0.5 * np.mean((raw_right - raw_left) ** 2)
            )

            lag_mean, lag_min, lag_max = lag_summaries[lag]
            rows.append(
                {
                    "curve_index": int(curve_index),
                    "curve_id": result.source_curve_ids[curve_index],
                    "participant_id": result.curve_participant_ids[curve_index],
                    "lag_index": int(lag),
                    "lag_time_mean": lag_mean,
                    "lag_time_min": lag_min,
                    "lag_time_max": lag_max,
                    "n_pairs": n_pairs,
                    "residual_mean": residual_mean,
                    "residual_sd": residual_sd,
                    "residual_rms": residual_rms,
                    "zero_residual_variance": zero_variance,
                    "autocovariance": autocovariance,
                    "autocorrelation": autocorrelation,
                    "semivariance": semivariance,
                }
            )

    trial = pd.DataFrame(rows)
    participant = _aggregate_diagnostics(
        trial,
        ["participant_id", "lag_index"],
    )
    overall = _aggregate_diagnostics(trial, ["lag_index"])

    provenance = {
        "functional_mixed_effects_residual_diagnostics": {
            "residual_type": (
                "conditional_residual_function"
                if residual_scale_value == "raw"
                else "within_trial_whitened_conditional_residual_function"
            ),
            "residual_scale": residual_scale_value,
            "source_residual_correlation": result.residual_correlation,
            "whitening_uses_fitted_residual_covariance_only": (
                residual_scale_value == "whitened"
            ),
            "within_trial_centering": True,
            "max_lag_index": max_lag_value,
            "automatic_lag_selection": False,
            "physical_lag_binning": False,
            "participant_summary": "pair_count_weighted_mean_of_trial_diagnostics",
            "overall_summary": "pair_count_weighted_mean_of_trial_diagnostics",
            "zero_variance_trial_policy": "retain_with_undefined_autocorrelation",
            "automatic_covariance_structure_selection": False,
            "automatic_ar1_selection": False,
            "automatic_trial_random_effect_selection": False,
            "time_unit": result.time_unit,
            "dimension": result.dimension_name,
            "source_model_random_slope_predictor": result.random_slope_predictor,
        }
    }

    return FunctionalMixedEffectsResidualDiagnosticsResult(
        reference=result,
        trial_diagnostics=trial,
        participant_diagnostics=participant,
        overall_diagnostics=overall,
        max_lag=max_lag_value,
        provenance=provenance,
        residual_scale=residual_scale_value,
    )

eyetrajectoriespy.functional_mixed_effects_residual_diagnostic_frame

functional_mixed_effects_residual_diagnostic_frame(result: FunctionalMixedEffectsResidualDiagnosticsResult, *, level: Literal['trial', 'participant', 'overall'] = 'trial', curve_id: str | None = None, participant_id: str | None = None) -> pd.DataFrame

Return one auditable residual-diagnostic summary level.

Source code in src/eyetrajectoriespy/functional_mixed_effects_diagnostics.py
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
def functional_mixed_effects_residual_diagnostic_frame(
    result: FunctionalMixedEffectsResidualDiagnosticsResult,
    *,
    level: Literal["trial", "participant", "overall"] = "trial",
    curve_id: str | None = None,
    participant_id: str | None = None,
) -> pd.DataFrame:
    """Return one auditable residual-diagnostic summary level."""

    if not isinstance(result, FunctionalMixedEffectsResidualDiagnosticsResult):
        raise TypeError(
            "result must be a FunctionalMixedEffectsResidualDiagnosticsResult"
        )
    if level not in {"trial", "participant", "overall"}:
        raise ValueError("level must be 'trial', 'participant', or 'overall'")

    if level == "trial":
        if participant_id is not None:
            raise ValueError(
                "participant_id is not accepted for level='trial'; use curve_id "
                "or request the complete trial frame"
            )
        frame = result.trial_diagnostics
        if curve_id is not None:
            curve_id = str(curve_id)
            frame = frame.loc[frame["curve_id"].astype(str) == curve_id]
            if frame.empty:
                raise KeyError(f"Unknown curve_id {curve_id!r}")
        return frame.reset_index(drop=True).copy()

    if level == "participant":
        if curve_id is not None:
            raise ValueError(
                "curve_id is not accepted for level='participant'"
            )
        frame = result.participant_diagnostics
        if participant_id is not None:
            participant_id = str(participant_id)
            frame = frame.loc[
                frame["participant_id"].astype(str) == participant_id
            ]
            if frame.empty:
                raise KeyError(f"Unknown participant_id {participant_id!r}")
        return frame.reset_index(drop=True).copy()

    if curve_id is not None or participant_id is not None:
        raise ValueError(
            "curve_id and participant_id are not accepted for level='overall'"
        )
    return result.overall_diagnostics.reset_index(drop=True).copy()

eyetrajectoriespy.functional_mixed_effects_residual_pair_frame

functional_mixed_effects_residual_pair_frame(result: FunctionalMixedEffectsResidualDiagnosticsResult, *, lag_index: int, curve_id: str | None = None, participant_id: str | None = None) -> pd.DataFrame

Expose exact within-trial residual pairs for one declared index lag.

No physical-lag bins are created. This helper is intended for auditing non-equally-spaced common grids or for analyst-declared downstream physical-lag summaries.

Source code in src/eyetrajectoriespy/functional_mixed_effects_diagnostics.py
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
def functional_mixed_effects_residual_pair_frame(
    result: FunctionalMixedEffectsResidualDiagnosticsResult,
    *,
    lag_index: int,
    curve_id: str | None = None,
    participant_id: str | None = None,
) -> pd.DataFrame:
    """Expose exact within-trial residual pairs for one declared index lag.

    No physical-lag bins are created. This helper is intended for auditing
    non-equally-spaced common grids or for analyst-declared downstream
    physical-lag summaries.
    """

    if not isinstance(result, FunctionalMixedEffectsResidualDiagnosticsResult):
        raise TypeError(
            "result must be a FunctionalMixedEffectsResidualDiagnosticsResult"
        )
    if isinstance(lag_index, bool) or not isinstance(
        lag_index, (int, np.integer)
    ):
        raise TypeError("lag_index must be an integer")
    lag = int(lag_index)
    if lag < 0 or lag > result.max_lag:
        raise ValueError(
            f"lag_index must be between 0 and {result.max_lag}, inclusive"
        )

    reference = result.reference
    residuals = _residual_array(reference, result.residual_scale)
    time = np.asarray(reference.time, dtype=float)
    rows: list[dict[str, object]] = []

    for curve_index, residual in enumerate(residuals):
        this_curve = str(reference.source_curve_ids[curve_index])
        this_participant = str(reference.curve_participant_ids[curve_index])
        if curve_id is not None and this_curve != str(curve_id):
            continue
        if participant_id is not None and this_participant != str(participant_id):
            continue

        centered = residual - float(np.mean(residual))
        starts = range(time.size - lag) if lag else range(time.size)
        for start in starts:
            end = start + lag
            rows.append(
                {
                    "curve_index": int(curve_index),
                    "curve_id": this_curve,
                    "participant_id": this_participant,
                    "lag_index": lag,
                    "start_index": int(start),
                    "end_index": int(end),
                    "time_start": float(time[start]),
                    "time_end": float(time[end]),
                    "physical_lag": float(time[end] - time[start]),
                    "residual_start": float(residual[start]),
                    "residual_end": float(residual[end]),
                    "centered_product": float(centered[start] * centered[end]),
                    "semivariance_contribution": float(
                        0.5 * (residual[end] - residual[start]) ** 2
                    ),
                }
            )

    frame = pd.DataFrame(rows)
    if curve_id is not None and frame.empty:
        raise KeyError(f"Unknown curve_id {str(curve_id)!r}")
    if participant_id is not None and frame.empty:
        raise KeyError(f"Unknown participant_id {str(participant_id)!r}")
    return frame

eyetrajectoriespy.compare_functional_mixed_effects_residual_diagnostics

compare_functional_mixed_effects_residual_diagnostics(reference: FunctionalMixedEffectsResult, comparison: FunctionalMixedEffectsResult, *, max_lag: int, reference_label: str = 'reference', comparison_label: str = 'comparison', residual_scale: Literal['raw', 'whitened'] = 'raw') -> pd.DataFrame

Compare overall residual-dependence diagnostics for two nested analyses.

This is a descriptive sensitivity comparison. It does not rank the fits, choose a covariance structure, or perform a hypothesis test.

Source code in src/eyetrajectoriespy/functional_mixed_effects_diagnostics.py
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
def compare_functional_mixed_effects_residual_diagnostics(
    reference: FunctionalMixedEffectsResult,
    comparison: FunctionalMixedEffectsResult,
    *,
    max_lag: int,
    reference_label: str = "reference",
    comparison_label: str = "comparison",
    residual_scale: Literal["raw", "whitened"] = "raw",
) -> pd.DataFrame:
    """Compare overall residual-dependence diagnostics for two nested analyses.

    This is a descriptive sensitivity comparison. It does not rank the fits,
    choose a covariance structure, or perform a hypothesis test.
    """

    _validate_reference(reference)
    _validate_reference(comparison)

    if reference.dimension_name != comparison.dimension_name:
        raise ValueError("Fits must use the same response dimension")
    if reference.time_unit != comparison.time_unit:
        raise ValueError("Fits must use the same time unit")
    if tuple(reference.source_curve_ids) != tuple(comparison.source_curve_ids):
        raise ValueError("Fits must contain the same source curves in the same order")
    if tuple(reference.curve_participant_ids) != tuple(
        comparison.curve_participant_ids
    ):
        raise ValueError(
            "Fits must contain the same participant mapping in the same order"
        )
    if reference.time.shape != comparison.time.shape or not np.allclose(
        reference.time,
        comparison.time,
        rtol=0.0,
        atol=0.0,
    ):
        raise ValueError("Fits must use exactly the same observed time grid")

    first = functional_mixed_effects_residual_diagnostics(
        reference,
        max_lag=max_lag,
        residual_scale=residual_scale,
    ).overall_diagnostics
    second = functional_mixed_effects_residual_diagnostics(
        comparison,
        max_lag=max_lag,
        residual_scale=residual_scale,
    ).overall_diagnostics

    keep = [
        "lag_index",
        "lag_time_mean",
        "lag_time_min",
        "lag_time_max",
        "n_pairs",
        "n_trials_total",
        "n_trials_acf_defined",
        "autocovariance",
        "autocorrelation",
        "semivariance",
    ]
    merged = first[keep].merge(
        second[keep],
        on=[
            "lag_index",
            "lag_time_mean",
            "lag_time_min",
            "lag_time_max",
            "n_pairs",
            "n_trials_total",
        ],
        suffixes=("_reference", "_comparison"),
        validate="one_to_one",
    )

    for metric in ("autocovariance", "autocorrelation", "semivariance"):
        merged[f"delta_{metric}"] = (
            merged[f"{metric}_comparison"]
            - merged[f"{metric}_reference"]
        )

    merged.insert(0, "reference_label", str(reference_label))
    merged.insert(1, "comparison_label", str(comparison_label))
    merged.insert(2, "residual_scale", str(residual_scale))
    return merged

eyetrajectoriespy.plot_functional_mixed_effects_residual_acf

plot_functional_mixed_effects_residual_acf(result: FunctionalMixedEffectsResidualDiagnosticsResult, *, level: Literal['trial', 'participant', 'overall'] = 'overall', curve_id: str | None = None, participant_id: str | None = None, ax=None)

Plot residual autocorrelation against retained physical lag.

Source code in src/eyetrajectoriespy/functional_mixed_effects_diagnostics.py
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
def plot_functional_mixed_effects_residual_acf(
    result: FunctionalMixedEffectsResidualDiagnosticsResult,
    *,
    level: Literal["trial", "participant", "overall"] = "overall",
    curve_id: str | None = None,
    participant_id: str | None = None,
    ax=None,
):
    """Plot residual autocorrelation against retained physical lag."""

    return _plot_residual_metric(
        result,
        metric="autocorrelation",
        level=level,
        curve_id=curve_id,
        participant_id=participant_id,
        ax=ax,
    )

eyetrajectoriespy.plot_functional_mixed_effects_residual_variogram

plot_functional_mixed_effects_residual_variogram(result: FunctionalMixedEffectsResidualDiagnosticsResult, *, level: Literal['trial', 'participant', 'overall'] = 'overall', curve_id: str | None = None, participant_id: str | None = None, ax=None)

Plot empirical residual semivariance against retained physical lag.

Source code in src/eyetrajectoriespy/functional_mixed_effects_diagnostics.py
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
def plot_functional_mixed_effects_residual_variogram(
    result: FunctionalMixedEffectsResidualDiagnosticsResult,
    *,
    level: Literal["trial", "participant", "overall"] = "overall",
    curve_id: str | None = None,
    participant_id: str | None = None,
    ax=None,
):
    """Plot empirical residual semivariance against retained physical lag."""

    return _plot_residual_metric(
        result,
        metric="semivariance",
        level=level,
        curve_id=curve_id,
        participant_id=participant_id,
        ax=ax,
    )

eyetrajectoriespy.functional_mixed_effects_residual_reporting_text

functional_mixed_effects_residual_reporting_text(result: FunctionalMixedEffectsResidualDiagnosticsResult) -> str

Return compact manuscript-oriented residual-diagnostic reporting text.

Source code in src/eyetrajectoriespy/functional_mixed_effects_diagnostics.py
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
def functional_mixed_effects_residual_reporting_text(
    result: FunctionalMixedEffectsResidualDiagnosticsResult,
) -> str:
    """Return compact manuscript-oriented residual-diagnostic reporting text."""

    if not isinstance(result, FunctionalMixedEffectsResidualDiagnosticsResult):
        raise TypeError(
            "result must be a FunctionalMixedEffectsResidualDiagnosticsResult"
        )

    lag0 = result.trial_diagnostics.loc[
        result.trial_diagnostics["lag_index"] == 0
    ]
    zero_variance_count = int(lag0["zero_residual_variance"].sum())
    structure = (
        "random functional intercept only"
        if result.reference.random_slope_predictor is None
        else (
            "random functional intercept plus the declared random functional "
            f"slope for {result.reference.random_slope_predictor!r}"
        )
    )

    scale_text = (
        "raw conditional residual functions"
        if result.residual_scale == "raw"
        else (
            "conditional residual functions whitened within each trial by the "
            "fitted residual covariance Cholesky factor"
        )
    )
    whitening_text = (
        ""
        if result.residual_scale == "raw"
        else (
            " Whitening targets the declared residual covariance only; fitted "
            "random effects and parameter uncertainty remain conditioned on "
            "their estimates."
        )
    )

    return (
        "Within-trial residual dependence was inspected using the "
        + scale_text
        + " from the converged functional mixed-effects fit "
        "residual functions from the converged functional mixed-effects fit "
        f"({structure}). Diagnostics were computed through the explicitly "
        f"declared maximum index lag {result.max_lag}. For each trial, residuals "
        "were centered within trial before calculating autocovariance and "
        "autocorrelation; empirical semivariance used one-half of the mean "
        "squared residual difference at each lag. The observed common time grid "
        f"was retained in {result.reference.time_unit!r}; index lags retain their "
        "mean/minimum/maximum physical separations and no physical-lag binning "
        "was performed. Participant and overall summaries are descriptive "
        "pair-count-weighted summaries of trial-level diagnostics. "
        f"{zero_variance_count} trial(s) had exactly zero residual variance; "
        "such trials were retained and their autocorrelation was marked "
        "undefined. These diagnostics do not automatically select AR(1), a "
        "trial-level functional random effect, or any other residual covariance "
        "structure."
        + whitening_text
    )

Covariance-structure sensitivity

eyetrajectoriespy.FunctionalMixedEffectsCovarianceSpecification dataclass

Declared covariance structure for sensitivity analysis.

Source code in src/eyetrajectoriespy/types.py
963
964
965
966
967
968
969
970
@dataclass(frozen=True)
class FunctionalMixedEffectsCovarianceSpecification:
    """Declared covariance structure for sensitivity analysis."""

    name: str
    random_slope_predictor: str | None = None
    trial_random_effect: str | None = None
    residual_correlation: str = "iid"

eyetrajectoriespy.FunctionalMixedEffectsCovarianceSensitivityResult dataclass

Descriptive comparison across predeclared mixed-effects covariances.

Source code in src/eyetrajectoriespy/types.py
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
@dataclass(frozen=True)
class FunctionalMixedEffectsCovarianceSensitivityResult:
    """Descriptive comparison across predeclared mixed-effects covariances."""

    specifications: tuple[FunctionalMixedEffectsCovarianceSpecification, ...]
    reference_label: str
    fits: Mapping[str, FunctionalMixedEffectsResult]
    failures: Mapping[str, str]
    model_summary: pd.DataFrame
    coefficient_frame: pd.DataFrame
    coefficient_summary: pd.DataFrame
    band_width_frame: pd.DataFrame
    variance_decomposition: pd.DataFrame
    residual_diagnostics: pd.DataFrame
    max_lag: int
    provenance: Mapping[str, Any] = field(default_factory=dict)

    @property
    def n_models(self) -> int:
        return len(self.specifications)

    @property
    def n_successful(self) -> int:
        return len(self.fits)

    @property
    def n_failed(self) -> int:
        return len(self.failures)

eyetrajectoriespy.functional_mixed_effects_covariance_sensitivity

Descriptive covariance-structure sensitivity for functional mixed models.

Version 0.50 compares predeclared, already fitted covariance structures against one analyst-declared reference. It deliberately does not fit covariance combinations, rank models, select a winner, or attach likelihood-ratio p-values.

functional_mixed_effects_variance_decomposition

functional_mixed_effects_variance_decomposition(fit: FunctionalMixedEffectsResult, *, model_label: str | None = None) -> pd.DataFrame

Return participant/trial/residual variance functions on the time grid.

Participant random-intercept variance, random-slope variance, and intercept/slope cross-covariance are kept separate. The function does not collapse them into a percentage or a preferred covariance decomposition.

Source code in src/eyetrajectoriespy/functional_mixed_effects_covariance_sensitivity.py
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
def functional_mixed_effects_variance_decomposition(
    fit: FunctionalMixedEffectsResult,
    *,
    model_label: str | None = None,
) -> pd.DataFrame:
    """Return participant/trial/residual variance functions on the time grid.

    Participant random-intercept variance, random-slope variance, and
    intercept/slope cross-covariance are kept separate.  The function does not
    collapse them into a percentage or a preferred covariance decomposition.
    """

    if not isinstance(fit, FunctionalMixedEffectsResult):
        raise TypeError("fit must be a FunctionalMixedEffectsResult")
    label = "model" if model_label is None else str(model_label)
    if not label:
        raise ValueError("model_label must be non-empty when supplied")

    time = np.asarray(fit.time, dtype=float)
    basis = np.asarray(fit.random_basis, dtype=float)
    rows: list[dict[str, object]] = []

    def add_component(
        component: str,
        values: np.ndarray,
        component_type: str,
    ) -> None:
        for index, value in enumerate(np.asarray(values, dtype=float)):
            rows.append(
                {
                    "model": label,
                    "component": component,
                    "component_type": component_type,
                    "time": float(time[index]),
                    "value": float(value),
                }
            )

    intercept_variance = np.einsum(
        "ti,ij,tj->t",
        basis,
        np.asarray(fit.random_intercept_covariance, dtype=float),
        basis,
        optimize=True,
    )
    add_component(
        "participant_intercept_variance",
        intercept_variance,
        "variance",
    )

    if fit.random_slope_covariance is not None:
        slope_variance = np.einsum(
            "ti,ij,tj->t",
            basis,
            np.asarray(fit.random_slope_covariance, dtype=float),
            basis,
            optimize=True,
        )
        add_component(
            "participant_slope_variance",
            slope_variance,
            "variance",
        )
        if fit.random_intercept_slope_covariance is None:
            raise ValueError(
                "random slope covariance is present without intercept/slope "
                "cross-covariance"
            )
        cross_covariance = np.einsum(
            "ti,ij,tj->t",
            basis,
            np.asarray(
                fit.random_intercept_slope_covariance,
                dtype=float,
            ),
            basis,
            optimize=True,
        )
        add_component(
            "participant_intercept_slope_cross_covariance",
            cross_covariance,
            "covariance",
        )

    if fit.trial_random_effect == "functional_intercept":
        if (
            fit.trial_random_basis is None
            or fit.trial_random_effect_covariance is None
        ):
            raise ValueError(
                "trial random-effect result is missing its basis/covariance"
            )
        trial_basis = np.asarray(
            fit.trial_random_basis,
            dtype=float,
        )
        trial_variance = np.einsum(
            "ti,ij,tj->t",
            trial_basis,
            np.asarray(
                fit.trial_random_effect_covariance,
                dtype=float,
            ),
            trial_basis,
            optimize=True,
        )
        add_component(
            "trial_variance",
            trial_variance,
            "variance",
        )

    add_component(
        "residual_variance",
        np.full(time.size, fit.residual_variance, dtype=float),
        "variance",
    )
    return pd.DataFrame(rows)

functional_mixed_effects_covariance_sensitivity

functional_mixed_effects_covariance_sensitivity(fits: Mapping[str, FunctionalMixedEffectsResult], *, reference: str, max_lag: int, specifications: Sequence[FunctionalMixedEffectsCovarianceSpecification] | None = None, failures: Mapping[str, str] | None = None, bands: Mapping[str, FunctionalMixedEffectsBandResult] | None = None) -> FunctionalMixedEffectsCovarianceSensitivityResult

Compare already fitted, predeclared covariance structures descriptively.

The routine never fits a model, ranks structures, returns a best model, performs a likelihood-ratio test, or selects a covariance family.

Source code in src/eyetrajectoriespy/functional_mixed_effects_covariance_sensitivity.py
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
def functional_mixed_effects_covariance_sensitivity(
    fits: Mapping[str, FunctionalMixedEffectsResult],
    *,
    reference: str,
    max_lag: int,
    specifications: Sequence[
        FunctionalMixedEffectsCovarianceSpecification
    ]
    | None = None,
    failures: Mapping[str, str] | None = None,
    bands: Mapping[str, FunctionalMixedEffectsBandResult] | None = None,
) -> FunctionalMixedEffectsCovarianceSensitivityResult:
    """Compare already fitted, predeclared covariance structures descriptively.

    The routine never fits a model, ranks structures, returns a best model,
    performs a likelihood-ratio test, or selects a covariance family.
    """

    if not isinstance(fits, Mapping):
        raise TypeError("fits must be a mapping from label to fitted result")
    if not fits:
        raise ValueError("fits must contain at least one successful model")
    if not isinstance(reference, str) or not reference:
        raise TypeError("reference must be a non-empty model label")
    if reference not in fits:
        raise ValueError(
            "reference must identify a successful supplied fit"
        )
    if isinstance(max_lag, bool) or not isinstance(
        max_lag,
        (int, np.integer),
    ):
        raise TypeError("max_lag must be an integer")
    max_lag = int(max_lag)
    if max_lag < 1:
        raise ValueError("max_lag must be at least 1")

    fit_map = dict(fits)
    for label, fit in fit_map.items():
        if not isinstance(label, str) or not label:
            raise ValueError("fit labels must be non-empty strings")
        if not isinstance(fit, FunctionalMixedEffectsResult):
            raise TypeError(
                f"fit {label!r} must be a FunctionalMixedEffectsResult"
            )

    failure_map = {} if failures is None else dict(failures)
    overlap = set(fit_map) & set(failure_map)
    if overlap:
        raise ValueError(
            "a declared model cannot be both successful and failed: "
            f"{sorted(overlap)}"
        )
    for label, reason in failure_map.items():
        if not isinstance(label, str) or not label:
            raise ValueError("failure labels must be non-empty strings")
        if not isinstance(reason, str) or not reason.strip():
            raise ValueError(
                f"failure reason for {label!r} must be a non-empty string"
            )

    if specifications is None:
        if failure_map:
            raise ValueError(
                "specifications are required when failed declared models are "
                "retained, so their intended covariance structures remain "
                "auditable"
            )
        specification_tuple = tuple(
            _specification_from_fit(label, fit)
            for label, fit in fit_map.items()
        )
    else:
        specification_tuple = tuple(specifications)
        if not specification_tuple:
            raise ValueError("specifications must not be empty")
        for specification in specification_tuple:
            _validate_specification(specification)
        names = [item.name for item in specification_tuple]
        if len(set(names)) != len(names):
            raise ValueError("covariance specification names must be unique")
        declared = set(names)
        supplied = set(fit_map) | set(failure_map)
        if declared != supplied:
            raise ValueError(
                "specification names must match the union of successful fits "
                "and retained failures exactly"
            )

    specification_by_name = {
        specification.name: specification
        for specification in specification_tuple
    }
    for label, fit in fit_map.items():
        _validate_fit_matches_specification(
            fit,
            specification_by_name[label],
        )

    reference_fit = fit_map[reference]
    if not reference_fit.converged:
        raise ValueError("reference fit must be converged")
    if max_lag >= reference_fit.time.size:
        raise ValueError(
            "max_lag must be smaller than the number of observed time points"
        )

    for label, fit in fit_map.items():
        _validate_comparability(
            reference_fit,
            fit,
            label=label,
        )

    trial_fit_labels = [
        label
        for label, fit in fit_map.items()
        if fit.trial_random_effect is not None
    ]
    if len(trial_fit_labels) > 1:
        trial_reference_label = trial_fit_labels[0]
        trial_reference = fit_map[trial_reference_label]
        for label in trial_fit_labels[1:]:
            candidate = fit_map[label]
            if tuple(candidate.curve_trial_ids) != tuple(
                trial_reference.curve_trial_ids
            ):
                raise ValueError(
                    "successful fits containing trial random effects must use "
                    "the same source trial identities; "
                    f"{label!r} differs from {trial_reference_label!r}"
                )
            _exact_array_match(
                candidate.trial_random_basis,
                trial_reference.trial_random_basis,
                field="trial random-effect basis evaluations",
            )
            _exact_array_match(
                candidate.trial_random_basis_knots,
                trial_reference.trial_random_basis_knots,
                field="trial random-effect basis knots",
            )

    band_map = {} if bands is None else dict(bands)
    _validate_bands(fit_map, band_map, reference=reference)

    reference_fixed, reference_covariance, reference_ic = (
        _parameter_counts(reference_fit)
    )
    reference_ll = float(reference_fit.log_likelihood)
    n_observations = reference_fit.n_curves * reference_fit.time.size
    reference_aic = -2.0 * reference_ll + 2.0 * reference_ic
    reference_bic = (
        -2.0 * reference_ll
        + np.log(float(n_observations)) * reference_ic
    )

    model_rows: list[dict[str, object]] = []
    coefficient_rows: list[dict[str, object]] = []
    coefficient_summary_rows: list[dict[str, object]] = []
    variance_frames: list[pd.DataFrame] = []
    diagnostic_frames: list[pd.DataFrame] = []

    for specification in specification_tuple:
        label = specification.name
        if label in failure_map:
            model_rows.append(
                {
                    "model": label,
                    "status": "failed",
                    "converged": False,
                    "failure_reason": failure_map[label],
                    "is_reference": False,
                    "random_slope_predictor": (
                        specification.random_slope_predictor
                    ),
                    "trial_random_effect": specification.trial_random_effect,
                    "residual_correlation": (
                        specification.residual_correlation
                    ),
                    "reml": reference_fit.reml,
                    "log_likelihood": float("nan"),
                    "n_parameters": float("nan"),
                    "information_criterion_parameter_count": float("nan"),
                    "n_observations": n_observations,
                    "aic": float("nan"),
                    "bic": float("nan"),
                    "delta_log_likelihood": float("nan"),
                    "delta_aic": float("nan"),
                    "delta_bic": float("nan"),
                    "band_available": False,
                }
            )
            continue

        fit = fit_map[label]
        fixed_count, covariance_count, criterion_count = _parameter_counts(
            fit
        )
        total_parameters = fixed_count + covariance_count
        log_likelihood = float(fit.log_likelihood)
        aic = -2.0 * log_likelihood + 2.0 * criterion_count
        bic = (
            -2.0 * log_likelihood
            + np.log(float(n_observations)) * criterion_count
        )
        diagnostics_frame, diagnostic_summary = _diagnostic_tables(
            label,
            fit,
            max_lag=max_lag,
        )
        diagnostic_frames.append(diagnostics_frame)

        variance = functional_mixed_effects_variance_decomposition(
            fit,
            model_label=label,
        )
        variance_frames.append(variance)

        trial_trace = (
            float(np.trace(fit.trial_random_effect_covariance))
            if fit.trial_random_effect_covariance is not None
            else float("nan")
        )
        participant_trace = float(
            np.trace(fit.random_intercept_covariance)
        )
        slope_trace = (
            float(np.trace(fit.random_slope_covariance))
            if fit.random_slope_covariance is not None
            else float("nan")
        )

        model_rows.append(
            {
                "model": label,
                "status": "success",
                "converged": bool(fit.converged),
                "failure_reason": None,
                "is_reference": label == reference,
                "random_slope_predictor": fit.random_slope_predictor,
                "trial_random_effect": fit.trial_random_effect,
                "residual_correlation": fit.residual_correlation,
                "residual_correlation_parameter": (
                    fit.residual_correlation_parameter
                ),
                "residual_correlation_parameter_name": (
                    fit.residual_correlation_parameter_name
                ),
                "residual_correlation_parameter_unit": (
                    fit.residual_correlation_parameter_unit
                ),
                "residual_variance": float(fit.residual_variance),
                "participant_intercept_covariance_trace": participant_trace,
                "participant_slope_covariance_trace": slope_trace,
                "trial_covariance_trace": trial_trace,
                "participant_covariance_condition_number": (
                    fit.random_effect_covariance_condition_number
                ),
                "trial_covariance_condition_number": (
                    fit.trial_random_effect_covariance_condition_number
                ),
                "residual_correlation_condition_number": (
                    fit.residual_correlation_condition_number
                ),
                "any_boundary_fit": bool(fit.boundary_fit),
                "participant_covariance_min_eigenvalue": float(
                    np.min(fit.random_effect_covariance_eigenvalues)
                ),
                "participant_covariance_max_eigenvalue": float(
                    np.max(fit.random_effect_covariance_eigenvalues)
                ),
                "participant_covariance_singular": bool(
                    fit.random_effect_singular
                ),
                "random_slope_boundary_fit": bool(
                    fit.random_slope_boundary_fit
                ),
                "trial_covariance_min_eigenvalue": (
                    float(
                        np.min(
                            fit.trial_random_effect_covariance_eigenvalues
                        )
                    )
                    if fit.trial_random_effect_covariance_eigenvalues
                    is not None
                    else float("nan")
                ),
                "trial_covariance_max_eigenvalue": (
                    float(
                        np.max(
                            fit.trial_random_effect_covariance_eigenvalues
                        )
                    )
                    if fit.trial_random_effect_covariance_eigenvalues
                    is not None
                    else float("nan")
                ),
                "trial_covariance_boundary_fit": bool(
                    fit.trial_random_effect_boundary_fit
                ),
                "trial_covariance_singular": bool(
                    fit.trial_random_effect_singular
                ),
                "residual_correlation_boundary_fit": bool(
                    fit.residual_correlation_boundary_fit
                ),
                "residual_correlation_independence_limit_fit": bool(
                    fit.residual_correlation_independence_limit_fit
                ),
                "reml": bool(fit.reml),
                "log_likelihood": log_likelihood,
                "n_fixed_parameters": fixed_count,
                "n_covariance_parameters": covariance_count,
                "n_parameters": total_parameters,
                "information_criterion_parameter_count": criterion_count,
                "n_observations": n_observations,
                "aic": float(aic),
                "bic": float(bic),
                "delta_log_likelihood": float(
                    log_likelihood - reference_ll
                ),
                "delta_aic": float(aic - reference_aic),
                "delta_bic": float(bic - reference_bic),
                "band_available": label in band_map,
                "backend_warning_count": len(fit.backend_warnings),
                **diagnostic_summary,
            }
        )

        difference = (
            np.asarray(fit.coefficient_functions, dtype=float)
            - np.asarray(reference_fit.coefficient_functions, dtype=float)
        )
        for coefficient_index, coefficient_name in enumerate(
            fit.coefficient_names
        ):
            values = difference[coefficient_index]
            coefficient_summary_rows.append(
                {
                    "model": label,
                    "coefficient": coefficient_name,
                    "sup_abs_difference_from_reference": float(
                        np.max(np.abs(values))
                    ),
                    "l2_difference_from_reference": _l2_grid_norm(
                        values,
                        fit.time,
                    ),
                }
            )
            for time_index, time_value in enumerate(fit.time):
                coefficient_rows.append(
                    {
                        "model": label,
                        "coefficient": coefficient_name,
                        "time": float(time_value),
                        "estimate": float(
                            fit.coefficient_functions[
                                coefficient_index,
                                time_index,
                            ]
                        ),
                        "reference_estimate": float(
                            reference_fit.coefficient_functions[
                                coefficient_index,
                                time_index,
                            ]
                        ),
                        "difference_from_reference": float(
                            values[time_index]
                        ),
                    }
                )

    model_summary = pd.DataFrame(model_rows)
    coefficient_frame = pd.DataFrame(coefficient_rows)
    coefficient_summary = pd.DataFrame(coefficient_summary_rows)
    variance_decomposition = (
        pd.concat(variance_frames, ignore_index=True)
        if variance_frames
        else pd.DataFrame()
    )
    residual_diagnostics = (
        pd.concat(diagnostic_frames, ignore_index=True)
        if diagnostic_frames
        else pd.DataFrame()
    )
    band_width_frame = _band_width_frame(
        fit_map,
        band_map,
        reference=reference,
    )

    information_criterion_mode = (
        "restricted_likelihood_covariance_parameter_count"
        if reference_fit.reml
        else "maximum_likelihood_total_parameter_count"
    )

    return FunctionalMixedEffectsCovarianceSensitivityResult(
        specifications=specification_tuple,
        reference_label=reference,
        fits=fit_map,
        failures=failure_map,
        model_summary=model_summary,
        coefficient_frame=coefficient_frame,
        coefficient_summary=coefficient_summary,
        band_width_frame=band_width_frame,
        variance_decomposition=variance_decomposition,
        residual_diagnostics=residual_diagnostics,
        max_lag=max_lag,
        provenance={
            "functional_mixed_effects_covariance_sensitivity": {
                "method": (
                    "predeclared_already_fitted_covariance_sensitivity"
                ),
                "reference": reference,
                "declared_model_order": [
                    specification.name
                    for specification in specification_tuple
                ],
                "automatic_model_fitting": False,
                "automatic_model_selection": False,
                "automatic_model_ranking": False,
                "automatic_covariance_selection": False,
                "likelihood_ratio_tests": False,
                "failed_models_retained": True,
                "failed_models_excluded_from_numerical_comparisons": True,
                "comparability_contract": {
                    "same_source_curves_and_order": True,
                    "same_observed_response": True,
                    "same_fixed_design": True,
                    "same_fixed_basis": True,
                    "same_participant_random_basis": True,
                    "same_participant_mapping": True,
                    "same_time_grid": True,
                    "same_response_dimension": True,
                    "same_time_unit": True,
                    "same_ml_reml_choice": True,
                    "explicit_trial_ids_checked_when_both_models_have_trial_effects": True,
                    "all_successful_trial_effect_models_cross_checked": True,
                },
                "information_criterion_mode": information_criterion_mode,
                "bic_sample_size_definition": (
                    "n_curves_times_n_observed_time_points"
                ),
                "n_observations_for_bic": n_observations,
                "reml": bool(reference_fit.reml),
                "coefficient_difference_reference": reference,
                "l2_difference_integration": (
                    "trapezoidal_integral_over_observed_time_grid"
                ),
                "residual_diagnostic_max_lag": max_lag,
                "residual_scales": ["raw", "whitened"],
                "whitened_acf_summary_is_descriptive_not_selection_criterion": True,
                "band_comparison_requires_identical_participant_bootstrap_draws": True,
                "trial_serial_competition_is_diagnostic_not_selection_evidence": True,
            }
        },
    )

plot_covariance_sensitivity_coefficients

plot_covariance_sensitivity_coefficients(result: FunctionalMixedEffectsCovarianceSensitivityResult, *, coefficient: str, ax=None)

Plot coefficient-function differences from the declared reference.

Source code in src/eyetrajectoriespy/functional_mixed_effects_covariance_sensitivity.py
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
def plot_covariance_sensitivity_coefficients(
    result: FunctionalMixedEffectsCovarianceSensitivityResult,
    *,
    coefficient: str,
    ax=None,
):
    """Plot coefficient-function differences from the declared reference."""

    import matplotlib.pyplot as plt

    if not isinstance(
        result,
        FunctionalMixedEffectsCovarianceSensitivityResult,
    ):
        raise TypeError(
            "result must be a FunctionalMixedEffectsCovarianceSensitivityResult"
        )
    if coefficient not in set(result.coefficient_frame["coefficient"]):
        raise KeyError(f"Unknown coefficient {coefficient!r}")

    frame = result.coefficient_frame.loc[
        result.coefficient_frame["coefficient"] == coefficient
    ]
    if ax is None:
        _, ax = plt.subplots()
    for label in [
        specification.name for specification in result.specifications
    ]:
        model = frame.loc[frame["model"] == label]
        if model.empty:
            continue
        ax.plot(
            model["time"],
            model["difference_from_reference"],
            label=label,
        )
    ax.axhline(0.0, linewidth=1.0)
    ax.set_xlabel(
        f"Time ({result.fits[result.reference_label].time_unit})"
    )
    ax.set_ylabel("Coefficient difference from reference")
    ax.set_title(
        f"Covariance sensitivity: {coefficient}"
    )
    ax.legend()
    return ax

plot_covariance_sensitivity_band_widths

plot_covariance_sensitivity_band_widths(result: FunctionalMixedEffectsCovarianceSensitivityResult, *, coefficient: str, ax=None)

Plot simultaneous-band width ratios against the declared reference.

Source code in src/eyetrajectoriespy/functional_mixed_effects_covariance_sensitivity.py
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
def plot_covariance_sensitivity_band_widths(
    result: FunctionalMixedEffectsCovarianceSensitivityResult,
    *,
    coefficient: str,
    ax=None,
):
    """Plot simultaneous-band width ratios against the declared reference."""

    import matplotlib.pyplot as plt

    if not isinstance(
        result,
        FunctionalMixedEffectsCovarianceSensitivityResult,
    ):
        raise TypeError(
            "result must be a FunctionalMixedEffectsCovarianceSensitivityResult"
        )
    if result.band_width_frame.empty:
        raise ValueError(
            "result does not contain simultaneous-band sensitivity information"
        )
    if coefficient not in set(result.band_width_frame["coefficient"]):
        raise KeyError(f"Unknown coefficient {coefficient!r}")

    frame = result.band_width_frame.loc[
        result.band_width_frame["coefficient"] == coefficient
    ]
    if ax is None:
        _, ax = plt.subplots()
    for label in [
        specification.name for specification in result.specifications
    ]:
        model = frame.loc[frame["model"] == label]
        if model.empty:
            continue
        ax.plot(
            model["time"],
            model["band_width_ratio_to_reference"],
            label=label,
        )
    ax.axhline(1.0, linewidth=1.0)
    ax.set_xlabel(
        f"Time ({result.fits[result.reference_label].time_unit})"
    )
    ax.set_ylabel("Band-width ratio to reference")
    ax.set_title(
        f"Covariance sensitivity of band width: {coefficient}"
    )
    ax.legend()
    return ax

plot_functional_variance_decomposition

plot_functional_variance_decomposition(result: FunctionalMixedEffectsResult | FunctionalMixedEffectsCovarianceSensitivityResult, *, model: str | None = None, ax=None)

Plot functional variance/cross-covariance components for one fit.

Source code in src/eyetrajectoriespy/functional_mixed_effects_covariance_sensitivity.py
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
def plot_functional_variance_decomposition(
    result: (
        FunctionalMixedEffectsResult
        | FunctionalMixedEffectsCovarianceSensitivityResult
    ),
    *,
    model: str | None = None,
    ax=None,
):
    """Plot functional variance/cross-covariance components for one fit."""

    import matplotlib.pyplot as plt

    if isinstance(result, FunctionalMixedEffectsResult):
        if model is not None:
            raise ValueError(
                "model is only accepted for a covariance sensitivity result"
            )
        frame = functional_mixed_effects_variance_decomposition(result)
        time_unit = result.time_unit
        title_label = "model"
    elif isinstance(
        result,
        FunctionalMixedEffectsCovarianceSensitivityResult,
    ):
        if model is None:
            raise ValueError(
                "model must be declared when plotting a sensitivity result"
            )
        if model not in result.fits:
            if model in result.failures:
                raise ValueError(
                    f"model {model!r} failed and has no variance decomposition"
                )
            raise KeyError(f"Unknown model {model!r}")
        frame = result.variance_decomposition.loc[
            result.variance_decomposition["model"] == model
        ]
        time_unit = result.fits[model].time_unit
        title_label = model
    else:
        raise TypeError(
            "result must be a FunctionalMixedEffectsResult or "
            "FunctionalMixedEffectsCovarianceSensitivityResult"
        )

    if ax is None:
        _, ax = plt.subplots()
    for component, component_frame in frame.groupby(
        "component",
        sort=False,
    ):
        ax.plot(
            component_frame["time"],
            component_frame["value"],
            label=str(component),
        )
    ax.axhline(0.0, linewidth=1.0)
    ax.set_xlabel(f"Time ({time_unit})")
    ax.set_ylabel("Variance / cross-covariance")
    ax.set_title(f"Functional variance decomposition: {title_label}")
    ax.legend()
    return ax

functional_mixed_effects_covariance_sensitivity_reporting_text

functional_mixed_effects_covariance_sensitivity_reporting_text(result: FunctionalMixedEffectsCovarianceSensitivityResult) -> str

Return manuscript-oriented wording without selecting a covariance model.

Source code in src/eyetrajectoriespy/functional_mixed_effects_covariance_sensitivity.py
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
def functional_mixed_effects_covariance_sensitivity_reporting_text(
    result: FunctionalMixedEffectsCovarianceSensitivityResult,
) -> str:
    """Return manuscript-oriented wording without selecting a covariance model."""

    if not isinstance(
        result,
        FunctionalMixedEffectsCovarianceSensitivityResult,
    ):
        raise TypeError(
            "result must be a FunctionalMixedEffectsCovarianceSensitivityResult"
        )
    failed = result.n_failed
    mode = result.provenance[
        "functional_mixed_effects_covariance_sensitivity"
    ]["information_criterion_mode"]
    return (
        "Covariance structures were compared as a predeclared sensitivity "
        f"analysis against reference {result.reference_label!r}. "
        f"{result.n_successful} declared structure(s) converged and {failed} "
        "failed structure(s) were retained explicitly rather than omitted. "
        "Successful fits used identical observations, fixed-effect design and "
        "basis, participant mapping, response dimension, time grid, and "
        "ML/REML mode. Fixed coefficient-function changes, simultaneous-band "
        "widths where supplied, functional participant/trial/residual variance "
        "decomposition, raw and whitened residual dependence, covariance "
        "diagnostics, log likelihood, AIC, and BIC were reported "
        "descriptively. No covariance structure was ranked or automatically "
        "selected and no likelihood-ratio p-values were computed. "
        f"Information criteria used the recorded convention {mode!r}; BIC "
        "used the explicit observation count n_curves × n_time."
    )

eyetrajectoriespy.functional_mixed_effects_variance_decomposition

functional_mixed_effects_variance_decomposition(fit: FunctionalMixedEffectsResult, *, model_label: str | None = None) -> pd.DataFrame

Return participant/trial/residual variance functions on the time grid.

Participant random-intercept variance, random-slope variance, and intercept/slope cross-covariance are kept separate. The function does not collapse them into a percentage or a preferred covariance decomposition.

Source code in src/eyetrajectoriespy/functional_mixed_effects_covariance_sensitivity.py
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
def functional_mixed_effects_variance_decomposition(
    fit: FunctionalMixedEffectsResult,
    *,
    model_label: str | None = None,
) -> pd.DataFrame:
    """Return participant/trial/residual variance functions on the time grid.

    Participant random-intercept variance, random-slope variance, and
    intercept/slope cross-covariance are kept separate.  The function does not
    collapse them into a percentage or a preferred covariance decomposition.
    """

    if not isinstance(fit, FunctionalMixedEffectsResult):
        raise TypeError("fit must be a FunctionalMixedEffectsResult")
    label = "model" if model_label is None else str(model_label)
    if not label:
        raise ValueError("model_label must be non-empty when supplied")

    time = np.asarray(fit.time, dtype=float)
    basis = np.asarray(fit.random_basis, dtype=float)
    rows: list[dict[str, object]] = []

    def add_component(
        component: str,
        values: np.ndarray,
        component_type: str,
    ) -> None:
        for index, value in enumerate(np.asarray(values, dtype=float)):
            rows.append(
                {
                    "model": label,
                    "component": component,
                    "component_type": component_type,
                    "time": float(time[index]),
                    "value": float(value),
                }
            )

    intercept_variance = np.einsum(
        "ti,ij,tj->t",
        basis,
        np.asarray(fit.random_intercept_covariance, dtype=float),
        basis,
        optimize=True,
    )
    add_component(
        "participant_intercept_variance",
        intercept_variance,
        "variance",
    )

    if fit.random_slope_covariance is not None:
        slope_variance = np.einsum(
            "ti,ij,tj->t",
            basis,
            np.asarray(fit.random_slope_covariance, dtype=float),
            basis,
            optimize=True,
        )
        add_component(
            "participant_slope_variance",
            slope_variance,
            "variance",
        )
        if fit.random_intercept_slope_covariance is None:
            raise ValueError(
                "random slope covariance is present without intercept/slope "
                "cross-covariance"
            )
        cross_covariance = np.einsum(
            "ti,ij,tj->t",
            basis,
            np.asarray(
                fit.random_intercept_slope_covariance,
                dtype=float,
            ),
            basis,
            optimize=True,
        )
        add_component(
            "participant_intercept_slope_cross_covariance",
            cross_covariance,
            "covariance",
        )

    if fit.trial_random_effect == "functional_intercept":
        if (
            fit.trial_random_basis is None
            or fit.trial_random_effect_covariance is None
        ):
            raise ValueError(
                "trial random-effect result is missing its basis/covariance"
            )
        trial_basis = np.asarray(
            fit.trial_random_basis,
            dtype=float,
        )
        trial_variance = np.einsum(
            "ti,ij,tj->t",
            trial_basis,
            np.asarray(
                fit.trial_random_effect_covariance,
                dtype=float,
            ),
            trial_basis,
            optimize=True,
        )
        add_component(
            "trial_variance",
            trial_variance,
            "variance",
        )

    add_component(
        "residual_variance",
        np.full(time.size, fit.residual_variance, dtype=float),
        "variance",
    )
    return pd.DataFrame(rows)

eyetrajectoriespy.plot_covariance_sensitivity_coefficients

plot_covariance_sensitivity_coefficients(result: FunctionalMixedEffectsCovarianceSensitivityResult, *, coefficient: str, ax=None)

Plot coefficient-function differences from the declared reference.

Source code in src/eyetrajectoriespy/functional_mixed_effects_covariance_sensitivity.py
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
def plot_covariance_sensitivity_coefficients(
    result: FunctionalMixedEffectsCovarianceSensitivityResult,
    *,
    coefficient: str,
    ax=None,
):
    """Plot coefficient-function differences from the declared reference."""

    import matplotlib.pyplot as plt

    if not isinstance(
        result,
        FunctionalMixedEffectsCovarianceSensitivityResult,
    ):
        raise TypeError(
            "result must be a FunctionalMixedEffectsCovarianceSensitivityResult"
        )
    if coefficient not in set(result.coefficient_frame["coefficient"]):
        raise KeyError(f"Unknown coefficient {coefficient!r}")

    frame = result.coefficient_frame.loc[
        result.coefficient_frame["coefficient"] == coefficient
    ]
    if ax is None:
        _, ax = plt.subplots()
    for label in [
        specification.name for specification in result.specifications
    ]:
        model = frame.loc[frame["model"] == label]
        if model.empty:
            continue
        ax.plot(
            model["time"],
            model["difference_from_reference"],
            label=label,
        )
    ax.axhline(0.0, linewidth=1.0)
    ax.set_xlabel(
        f"Time ({result.fits[result.reference_label].time_unit})"
    )
    ax.set_ylabel("Coefficient difference from reference")
    ax.set_title(
        f"Covariance sensitivity: {coefficient}"
    )
    ax.legend()
    return ax

eyetrajectoriespy.plot_covariance_sensitivity_band_widths

plot_covariance_sensitivity_band_widths(result: FunctionalMixedEffectsCovarianceSensitivityResult, *, coefficient: str, ax=None)

Plot simultaneous-band width ratios against the declared reference.

Source code in src/eyetrajectoriespy/functional_mixed_effects_covariance_sensitivity.py
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
def plot_covariance_sensitivity_band_widths(
    result: FunctionalMixedEffectsCovarianceSensitivityResult,
    *,
    coefficient: str,
    ax=None,
):
    """Plot simultaneous-band width ratios against the declared reference."""

    import matplotlib.pyplot as plt

    if not isinstance(
        result,
        FunctionalMixedEffectsCovarianceSensitivityResult,
    ):
        raise TypeError(
            "result must be a FunctionalMixedEffectsCovarianceSensitivityResult"
        )
    if result.band_width_frame.empty:
        raise ValueError(
            "result does not contain simultaneous-band sensitivity information"
        )
    if coefficient not in set(result.band_width_frame["coefficient"]):
        raise KeyError(f"Unknown coefficient {coefficient!r}")

    frame = result.band_width_frame.loc[
        result.band_width_frame["coefficient"] == coefficient
    ]
    if ax is None:
        _, ax = plt.subplots()
    for label in [
        specification.name for specification in result.specifications
    ]:
        model = frame.loc[frame["model"] == label]
        if model.empty:
            continue
        ax.plot(
            model["time"],
            model["band_width_ratio_to_reference"],
            label=label,
        )
    ax.axhline(1.0, linewidth=1.0)
    ax.set_xlabel(
        f"Time ({result.fits[result.reference_label].time_unit})"
    )
    ax.set_ylabel("Band-width ratio to reference")
    ax.set_title(
        f"Covariance sensitivity of band width: {coefficient}"
    )
    ax.legend()
    return ax

eyetrajectoriespy.plot_functional_variance_decomposition

plot_functional_variance_decomposition(result: FunctionalMixedEffectsResult | FunctionalMixedEffectsCovarianceSensitivityResult, *, model: str | None = None, ax=None)

Plot functional variance/cross-covariance components for one fit.

Source code in src/eyetrajectoriespy/functional_mixed_effects_covariance_sensitivity.py
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
def plot_functional_variance_decomposition(
    result: (
        FunctionalMixedEffectsResult
        | FunctionalMixedEffectsCovarianceSensitivityResult
    ),
    *,
    model: str | None = None,
    ax=None,
):
    """Plot functional variance/cross-covariance components for one fit."""

    import matplotlib.pyplot as plt

    if isinstance(result, FunctionalMixedEffectsResult):
        if model is not None:
            raise ValueError(
                "model is only accepted for a covariance sensitivity result"
            )
        frame = functional_mixed_effects_variance_decomposition(result)
        time_unit = result.time_unit
        title_label = "model"
    elif isinstance(
        result,
        FunctionalMixedEffectsCovarianceSensitivityResult,
    ):
        if model is None:
            raise ValueError(
                "model must be declared when plotting a sensitivity result"
            )
        if model not in result.fits:
            if model in result.failures:
                raise ValueError(
                    f"model {model!r} failed and has no variance decomposition"
                )
            raise KeyError(f"Unknown model {model!r}")
        frame = result.variance_decomposition.loc[
            result.variance_decomposition["model"] == model
        ]
        time_unit = result.fits[model].time_unit
        title_label = model
    else:
        raise TypeError(
            "result must be a FunctionalMixedEffectsResult or "
            "FunctionalMixedEffectsCovarianceSensitivityResult"
        )

    if ax is None:
        _, ax = plt.subplots()
    for component, component_frame in frame.groupby(
        "component",
        sort=False,
    ):
        ax.plot(
            component_frame["time"],
            component_frame["value"],
            label=str(component),
        )
    ax.axhline(0.0, linewidth=1.0)
    ax.set_xlabel(f"Time ({time_unit})")
    ax.set_ylabel("Variance / cross-covariance")
    ax.set_title(f"Functional variance decomposition: {title_label}")
    ax.legend()
    return ax

eyetrajectoriespy.functional_mixed_effects_covariance_sensitivity_reporting_text

functional_mixed_effects_covariance_sensitivity_reporting_text(result: FunctionalMixedEffectsCovarianceSensitivityResult) -> str

Return manuscript-oriented wording without selecting a covariance model.

Source code in src/eyetrajectoriespy/functional_mixed_effects_covariance_sensitivity.py
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
def functional_mixed_effects_covariance_sensitivity_reporting_text(
    result: FunctionalMixedEffectsCovarianceSensitivityResult,
) -> str:
    """Return manuscript-oriented wording without selecting a covariance model."""

    if not isinstance(
        result,
        FunctionalMixedEffectsCovarianceSensitivityResult,
    ):
        raise TypeError(
            "result must be a FunctionalMixedEffectsCovarianceSensitivityResult"
        )
    failed = result.n_failed
    mode = result.provenance[
        "functional_mixed_effects_covariance_sensitivity"
    ]["information_criterion_mode"]
    return (
        "Covariance structures were compared as a predeclared sensitivity "
        f"analysis against reference {result.reference_label!r}. "
        f"{result.n_successful} declared structure(s) converged and {failed} "
        "failed structure(s) were retained explicitly rather than omitted. "
        "Successful fits used identical observations, fixed-effect design and "
        "basis, participant mapping, response dimension, time grid, and "
        "ML/REML mode. Fixed coefficient-function changes, simultaneous-band "
        "widths where supplied, functional participant/trial/residual variance "
        "decomposition, raw and whitened residual dependence, covariance "
        "diagnostics, log likelihood, AIC, and BIC were reported "
        "descriptively. No covariance structure was ranked or automatically "
        "selected and no likelihood-ratio p-values were computed. "
        f"Information criteria used the recorded convention {mode!r}; BIC "
        "used the explicit observation count n_curves × n_time."
    )

Function-on-scalar regression

eyetrajectoriespy.FunctionOnScalarResult dataclass

Observed-grid function-on-scalar regression fit.

Source code in src/eyetrajectoriespy/types.py
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
@dataclass(frozen=True)
class FunctionOnScalarResult:
    """Observed-grid function-on-scalar regression fit."""

    coefficients: np.ndarray
    standard_errors: np.ndarray
    fitted_functions: np.ndarray
    residual_functions: np.ndarray
    observed_functions: np.ndarray
    design_matrix: np.ndarray
    coefficient_names: tuple[str, ...]
    predictor_names: tuple[str, ...]
    design_rank: int
    residual_degrees_of_freedom: int
    unit: str
    unit_ids: tuple[str, ...]
    curves_per_unit: tuple[int, ...]
    participant_column: str | None
    time: np.ndarray
    dimension_names: tuple[str, ...]
    coordinate_system: str
    time_unit: str
    source_curve_ids: tuple[str, ...]
    provenance: Mapping[str, Any] = field(default_factory=dict)

    @property
    def n_coefficients(self) -> int:
        return len(self.coefficient_names)

    @property
    def n_units(self) -> int:
        return len(self.unit_ids)

eyetrajectoriespy.FunctionOnScalarBootstrapResult dataclass

Wild-bootstrap coefficient replicates for function-on-scalar regression.

Source code in src/eyetrajectoriespy/types.py
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
@dataclass(frozen=True)
class FunctionOnScalarBootstrapResult:
    """Wild-bootstrap coefficient replicates for function-on-scalar regression."""

    reference: FunctionOnScalarResult
    bootstrap_coefficients: np.ndarray
    multipliers: np.ndarray
    multiplier: str
    random_state: int | None
    provenance: Mapping[str, Any] = field(default_factory=dict)

    @property
    def n_bootstrap(self) -> int:
        return self.bootstrap_coefficients.shape[0]

eyetrajectoriespy.FunctionOnScalarBandResult dataclass

Observed-grid simultaneous bands for function-on-scalar coefficients.

Source code in src/eyetrajectoriespy/types.py
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
@dataclass(frozen=True)
class FunctionOnScalarBandResult:
    """Observed-grid simultaneous bands for function-on-scalar coefficients."""

    reference: FunctionOnScalarResult
    lower: np.ndarray
    upper: np.ndarray
    critical_values: np.ndarray
    max_statistics: np.ndarray
    confidence_level: float
    simultaneous_scope: str
    bootstrap: FunctionOnScalarBootstrapResult
    provenance: Mapping[str, Any] = field(default_factory=dict)

    @property
    def n_coefficients(self) -> int:
        return self.lower.shape[0]

eyetrajectoriespy.fit_function_on_scalar_regression

fit_function_on_scalar_regression(trajectories: TrajectorySet, design: DataFrame, predictors: Sequence[str], *, dimensions: Sequence[str] | None = None, participant_column: str | None = None, unit: str = 'curve') -> FunctionOnScalarResult

Fit common-grid function-on-scalar OLS with explicit inference units.

The model is fitted independently at every observed time by dimension grid point using one shared scalar design matrix. No smoothing, basis expansion, coefficient regularization, categorical encoding, centering, scaling, interaction construction, or model selection is performed.

With unit='participant', repeated source curves are first averaged within participant and every declared predictor must be constant within participant. This supports participant-level between-subject regression without pseudo-replicating trials. It is not a functional mixed-effects model and deliberately refuses trial-varying predictors.

Source code in src/eyetrajectoriespy/function_on_scalar.py
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
def fit_function_on_scalar_regression(
    trajectories: TrajectorySet,
    design: pd.DataFrame,
    predictors: Sequence[str],
    *,
    dimensions: Sequence[str] | None = None,
    participant_column: str | None = None,
    unit: str = "curve",
) -> FunctionOnScalarResult:
    """Fit common-grid function-on-scalar OLS with explicit inference units.

    The model is fitted independently at every observed time by dimension grid
    point using one shared scalar design matrix. No smoothing, basis expansion,
    coefficient regularization, categorical encoding, centering, scaling,
    interaction construction, or model selection is performed.

    With unit='participant', repeated source curves are first averaged within
    participant and every declared predictor must be constant within
    participant. This supports participant-level between-subject regression
    without pseudo-replicating trials. It is not a functional mixed-effects
    model and deliberately refuses trial-varying predictors.
    """

    validate_trajectory_set(trajectories, require_complete=True)
    if not np.all(np.isfinite(trajectories.values)):
        raise ValueError(
            "function-on-scalar regression requires finite complete trajectories"
        )
    if trajectories.coordinate_system == "probability_simplex":
        raise ValueError(
            "Direct Euclidean function-on-scalar regression is not supported "
            "for probability_simplex trajectories; transform to an explicit "
            "log-ratio representation first"
        )

    aligned_design, predictor_names = _validate_design_alignment(
        trajectories,
        design,
        predictors,
    )
    dimension_names, dimension_indices = _select_dimensions(
        trajectories,
        dimensions,
    )
    selected_values = trajectories.values[:, :, dimension_indices]

    (
        unit_values,
        unit_predictors,
        unit_ids,
        curves_per_unit,
        unit_provenance,
    ) = _participant_units(
        trajectories,
        aligned_design,
        predictor_names,
        selected_values,
        unit=unit,
        participant_column=participant_column,
    )

    (
        design_matrix,
        coefficients,
        fitted,
        residuals,
        standard_errors,
        rank,
        residual_df,
    ) = _fit_arrays(unit_values, unit_predictors)

    coefficient_names = ("Intercept",) + predictor_names
    return FunctionOnScalarResult(
        coefficients=coefficients,
        standard_errors=standard_errors,
        fitted_functions=fitted,
        residual_functions=residuals,
        observed_functions=unit_values,
        design_matrix=design_matrix,
        coefficient_names=coefficient_names,
        predictor_names=predictor_names,
        design_rank=rank,
        residual_degrees_of_freedom=residual_df,
        unit=unit,
        unit_ids=unit_ids,
        curves_per_unit=curves_per_unit,
        participant_column=participant_column if unit == "participant" else None,
        time=trajectories.time.copy(),
        dimension_names=dimension_names,
        coordinate_system=trajectories.coordinate_system,
        time_unit=trajectories.time_unit,
        source_curve_ids=trajectories.curve_ids,
        provenance={
            **dict(trajectories.provenance),
            "function_on_scalar_regression": {
                "method": "observed_grid_ordinary_least_squares",
                "pointwise_standard_errors": "HC1_sandwich",
                "coefficient_regularization": False,
                "smoothing": False,
                "basis_expansion": False,
                "categorical_encoding": False,
                "predictor_centering": False,
                "predictor_scaling": False,
                "interaction_construction": False,
                "automatic_model_selection": False,
                "intercept": True,
                "predictors": list(predictor_names),
                "dimensions": list(dimension_names),
                "design_alignment": aligned_design.attrs[
                    "eyetrajectoriespy_alignment"
                ],
                "source_n_curves": trajectories.n_curves,
                "n_inference_units": len(unit_ids),
                "unit": unit,
                "participant_column": (
                    participant_column if unit == "participant" else None
                ),
                "curves_per_unit": list(curves_per_unit),
                "design_rank": rank,
                "residual_degrees_of_freedom": residual_df,
                "simultaneous_inference": False,
                "functional_mixed_effects_model": False,
                **unit_provenance,
            },
        },
    )

eyetrajectoriespy.bootstrap_function_on_scalar_coefficients

bootstrap_function_on_scalar_coefficients(result: FunctionOnScalarResult, *, n_bootstrap: int = 1000, multiplier: str = 'rademacher', random_state: int | None = 0) -> FunctionOnScalarBootstrapResult

Wild-bootstrap function-on-scalar coefficient curves.

Source code in src/eyetrajectoriespy/function_on_scalar.py
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
def bootstrap_function_on_scalar_coefficients(
    result: FunctionOnScalarResult,
    *,
    n_bootstrap: int = 1000,
    multiplier: str = "rademacher",
    random_state: int | None = 0,
) -> FunctionOnScalarBootstrapResult:
    """Wild-bootstrap function-on-scalar coefficient curves."""

    if not isinstance(result, FunctionOnScalarResult):
        raise TypeError("result must be a FunctionOnScalarResult")
    if isinstance(n_bootstrap, bool) or not isinstance(n_bootstrap, int):
        raise TypeError("n_bootstrap must be an integer")
    if n_bootstrap < 100:
        raise ValueError("n_bootstrap must be at least 100")
    if multiplier not in {"rademacher", "normal"}:
        raise ValueError("multiplier must be 'rademacher' or 'normal'")

    rng = np.random.default_rng(random_state)
    n_units = result.design_matrix.shape[0]
    n_coefficients = result.design_matrix.shape[1]
    xtx_inverse = np.linalg.inv(result.design_matrix.T @ result.design_matrix)
    projector = xtx_inverse @ result.design_matrix.T

    bootstrap_coefficients = np.empty(
        (
            n_bootstrap,
            n_coefficients,
            result.time.size,
            len(result.dimension_names),
        ),
        dtype=float,
    )
    multipliers = np.empty((n_bootstrap, n_units), dtype=float)

    for bootstrap_index in range(n_bootstrap):
        if multiplier == "rademacher":
            weights = rng.choice(
                np.array([-1.0, 1.0]),
                size=n_units,
                replace=True,
            )
        else:
            weights = rng.normal(size=n_units)
        multipliers[bootstrap_index] = weights
        y_star = (
            result.fitted_functions
            + weights[:, None, None] * result.residual_functions
        )
        bootstrap_coefficients[bootstrap_index] = np.einsum(
            "pn,ntd->ptd",
            projector,
            y_star,
            optimize=True,
        )

    return FunctionOnScalarBootstrapResult(
        reference=result,
        bootstrap_coefficients=bootstrap_coefficients,
        multipliers=multipliers,
        multiplier=multiplier,
        random_state=random_state,
        provenance={
            **dict(result.provenance),
            "function_on_scalar_bootstrap": {
                "method": "fixed_design_wild_bootstrap",
                "n_bootstrap": n_bootstrap,
                "multiplier": multiplier,
                "random_state": random_state,
                "unit": result.unit,
                "n_units": n_units,
                "design_resampled": False,
                "residual_functions_multiplied_as_whole_functions": True,
                "simultaneous_band_calibration": False,
            },
        },
    )

eyetrajectoriespy.function_on_scalar_simultaneous_bands

function_on_scalar_simultaneous_bands(bootstrap: FunctionOnScalarBootstrapResult, *, confidence_level: float = 0.95, simultaneous_scope: str = 'coefficient') -> FunctionOnScalarBandResult

Calibrate observed-grid simultaneous bands for coefficient functions.

Source code in src/eyetrajectoriespy/function_on_scalar.py
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
def function_on_scalar_simultaneous_bands(
    bootstrap: FunctionOnScalarBootstrapResult,
    *,
    confidence_level: float = 0.95,
    simultaneous_scope: str = "coefficient",
) -> FunctionOnScalarBandResult:
    """Calibrate observed-grid simultaneous bands for coefficient functions."""

    if not isinstance(bootstrap, FunctionOnScalarBootstrapResult):
        raise TypeError("bootstrap must be a FunctionOnScalarBootstrapResult")
    if not 0 < confidence_level < 1:
        raise ValueError("confidence_level must lie in (0, 1)")
    if simultaneous_scope not in {"coefficient", "family"}:
        raise ValueError(
            "simultaneous_scope must be 'coefficient' or 'family'"
        )

    reference = bootstrap.reference
    standard_errors = reference.standard_errors
    positive = standard_errors > np.finfo(float).eps

    deviations = bootstrap.bootstrap_coefficients - reference.coefficients[None]
    standardized = np.zeros_like(deviations)
    np.divide(
        deviations,
        standard_errors[None],
        out=standardized,
        where=positive[None],
    )
    absolute = np.abs(standardized)

    if simultaneous_scope == "coefficient":
        max_statistics = np.max(absolute, axis=(2, 3))
        critical_values = np.quantile(
            max_statistics,
            confidence_level,
            axis=0,
            method="higher",
        )
    else:
        family_max = np.max(absolute, axis=(1, 2, 3))
        critical = float(
            np.quantile(
                family_max,
                confidence_level,
                method="higher",
            )
        )
        max_statistics = family_max[:, None]
        critical_values = np.full(
            len(reference.coefficient_names),
            critical,
            dtype=float,
        )

    lower = (
        reference.coefficients
        - critical_values[:, None, None] * standard_errors
    )
    upper = (
        reference.coefficients
        + critical_values[:, None, None] * standard_errors
    )

    return FunctionOnScalarBandResult(
        reference=reference,
        lower=lower,
        upper=upper,
        critical_values=np.asarray(critical_values, dtype=float),
        max_statistics=np.asarray(max_statistics, dtype=float),
        confidence_level=float(confidence_level),
        simultaneous_scope=simultaneous_scope,
        bootstrap=bootstrap,
        provenance={
            **dict(bootstrap.provenance),
            "function_on_scalar_simultaneous_bands": {
                "method": "wild_bootstrap_reference_studentized_maximum",
                "confidence_level": float(confidence_level),
                "simultaneous_scope": simultaneous_scope,
                "simultaneous_domain": (
                    "observed_time_by_dimension_grid_per_coefficient"
                    if simultaneous_scope == "coefficient"
                    else "coefficient_by_time_by_dimension_observed_grid"
                ),
                "continuous_between_grid_points": False,
                "pointwise_standard_errors": "HC1_sandwich",
                "zero_standard_error_cells": int(
                    np.size(positive) - np.count_nonzero(positive)
                ),
                "zero_standard_error_cells_receive_zero_width": True,
            },
        },
    )

eyetrajectoriespy.function_on_scalar_coefficient_frame

function_on_scalar_coefficient_frame(result: FunctionOnScalarResult, *, band: FunctionOnScalarBandResult | None = None) -> pd.DataFrame

Return coefficient functions and optional simultaneous bands in long form.

Source code in src/eyetrajectoriespy/function_on_scalar.py
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
def function_on_scalar_coefficient_frame(
    result: FunctionOnScalarResult,
    *,
    band: FunctionOnScalarBandResult | None = None,
) -> pd.DataFrame:
    """Return coefficient functions and optional simultaneous bands in long form."""

    if not isinstance(result, FunctionOnScalarResult):
        raise TypeError("result must be a FunctionOnScalarResult")
    if band is not None:
        if not isinstance(band, FunctionOnScalarBandResult):
            raise TypeError("band must be a FunctionOnScalarBandResult or None")
        if band.reference is not result:
            raise ValueError("band.reference must be the supplied result object")

    rows: list[dict[str, float | str]] = []
    for coefficient_index, coefficient_name in enumerate(result.coefficient_names):
        for dimension_index, dimension_name in enumerate(result.dimension_names):
            for time_index, time in enumerate(result.time):
                row: dict[str, float | str] = {
                    "coefficient": coefficient_name,
                    "time": float(time),
                    "dimension": dimension_name,
                    "estimate": float(
                        result.coefficients[
                            coefficient_index,
                            time_index,
                            dimension_index,
                        ]
                    ),
                    "standard_error": float(
                        result.standard_errors[
                            coefficient_index,
                            time_index,
                            dimension_index,
                        ]
                    ),
                }
                if band is not None:
                    row["lower"] = float(
                        band.lower[
                            coefficient_index,
                            time_index,
                            dimension_index,
                        ]
                    )
                    row["upper"] = float(
                        band.upper[
                            coefficient_index,
                            time_index,
                            dimension_index,
                        ]
                    )
                    row["critical_value"] = float(
                        band.critical_values[coefficient_index]
                    )
                rows.append(row)
    return pd.DataFrame(rows)

eyetrajectoriespy.plot_function_on_scalar_coefficients

plot_function_on_scalar_coefficients(result: FunctionOnScalarResult | FunctionOnScalarBandResult, *, coefficient: str | int, dimension: str | None = None, ax=None)

Plot one function-on-scalar coefficient with an optional simultaneous band.

Source code in src/eyetrajectoriespy/plotting.py
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
def plot_function_on_scalar_coefficients(
    result: FunctionOnScalarResult | FunctionOnScalarBandResult,
    *,
    coefficient: str | int,
    dimension: str | None = None,
    ax=None,
):
    """Plot one function-on-scalar coefficient with an optional simultaneous band."""

    if isinstance(result, FunctionOnScalarBandResult):
        fit = result.reference
        band = result
    elif isinstance(result, FunctionOnScalarResult):
        fit = result
        band = None
    else:
        raise TypeError(
            "result must be a FunctionOnScalarResult or FunctionOnScalarBandResult"
        )

    if isinstance(coefficient, str):
        if coefficient not in fit.coefficient_names:
            raise KeyError(f"Unknown coefficient {coefficient!r}")
        coefficient_index = fit.coefficient_names.index(coefficient)
    elif isinstance(coefficient, bool) or not isinstance(
        coefficient, (int, np.integer)
    ):
        raise TypeError("coefficient must be a name or integer index")
    else:
        coefficient_index = int(coefficient)
        if coefficient_index < 0 or coefficient_index >= fit.n_coefficients:
            raise IndexError("coefficient index is out of range")

    if dimension is None:
        dimension = fit.dimension_names[0]
    if dimension not in fit.dimension_names:
        raise KeyError(f"Unknown dimension {dimension!r}")
    dimension_index = fit.dimension_names.index(dimension)

    if ax is None:
        _, ax = plt.subplots()

    if band is not None:
        ax.fill_between(
            fit.time,
            band.lower[coefficient_index, :, dimension_index],
            band.upper[coefficient_index, :, dimension_index],
            alpha=0.2,
            label=(
                f"{100 * band.confidence_level:.1f}% simultaneous band "
                f"({band.simultaneous_scope})"
            ),
        )

    ax.plot(
        fit.time,
        fit.coefficients[coefficient_index, :, dimension_index],
        label=fit.coefficient_names[coefficient_index],
    )
    ax.axhline(0.0, linestyle="--")
    ax.set_xlabel(f"Time ({fit.time_unit})")
    ax.set_ylabel(f"Coefficient: {dimension}")
    ax.set_title(
        f"Function-on-scalar coefficient: "
        f"{fit.coefficient_names[coefficient_index]}"
    )
    ax.legend()
    return ax

eyetrajectoriespy.function_on_scalar_reporting_text

function_on_scalar_reporting_text(result: FunctionOnScalarResult, *, band: FunctionOnScalarBandResult | None = None) -> str

Generate manuscript-oriented function-on-scalar model wording.

Source code in src/eyetrajectoriespy/reporting.py
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
def function_on_scalar_reporting_text(
    result: FunctionOnScalarResult,
    *,
    band: FunctionOnScalarBandResult | None = None,
) -> str:
    """Generate manuscript-oriented function-on-scalar model wording."""

    if not isinstance(result, FunctionOnScalarResult):
        raise TypeError("result must be a FunctionOnScalarResult")
    if band is not None:
        if not isinstance(band, FunctionOnScalarBandResult):
            raise TypeError("band must be a FunctionOnScalarBandResult or None")
        if band.reference is not result:
            raise ValueError("band.reference must be the supplied result object")

    predictor_text = ", ".join(result.predictor_names)
    dimension_text = ", ".join(result.dimension_names)
    if result.unit == "participant":
        unit_text = (
            f"{result.n_units} participant-average functional responses "
            f"from {len(result.source_curve_ids)} source curves; declared "
            "predictors were required to be constant within participant"
        )
    else:
        unit_text = (
            f"{result.n_units} curve-level functional responses treated as "
            "independent inference units"
        )

    band_text = ""
    if band is not None:
        band_text = (
            f" Wild-bootstrap {100 * band.confidence_level:.1f}% simultaneous "
            f"coefficient bands were calibrated with "
            f"{band.bootstrap.n_bootstrap} "
            f"{band.bootstrap.multiplier} multiplier replicates using "
            f"{band.simultaneous_scope}-scope maxima over the observed grid."
        )

    return (
        "Function-on-scalar regression was fitted by ordinary least squares "
        "independently at each observed time-by-dimension grid point with a "
        f"shared design matrix (predictors: {predictor_text}; functional "
        f"dimensions: {dimension_text}). Inference used {unit_text}. "
        "Pointwise standard errors used the HC1 sandwich estimator. No "
        "functional smoothing, basis regularization, automatic categorical "
        "encoding, predictor scaling, interaction construction, or model "
        "selection was performed."
        + band_text
        + " The model is not a functional mixed-effects model, and any "
        "simultaneous-band claim applies to the observed grid rather than "
        "unsampled times."
    )

Generalized function-on-scalar regression

eyetrajectoriespy.GeneralizedFunctionOnScalarResult dataclass

Marginal generalized function-on-scalar regression fit.

Source code in src/eyetrajectoriespy/types.py
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
@dataclass(frozen=True)
class GeneralizedFunctionOnScalarResult:
    """Marginal generalized function-on-scalar regression fit."""

    coefficient_functions: np.ndarray
    coefficient_standard_errors: np.ndarray
    basis_coefficients: np.ndarray
    parameter_covariance: np.ndarray
    basis: np.ndarray
    basis_knots: np.ndarray
    linear_predictor_functions: np.ndarray
    mean_functions: np.ndarray
    observed_functions: np.ndarray
    scalar_design_matrix: np.ndarray
    expanded_design_rank: int
    coefficient_names: tuple[str, ...]
    predictor_names: tuple[str, ...]
    participant_column: str
    participant_ids: tuple[str, ...]
    curve_participant_ids: tuple[str, ...]
    curves_per_participant: tuple[int, ...]
    source_curve_ids: tuple[str, ...]
    time: np.ndarray
    dimension_name: str
    coordinate_system: str
    time_unit: str
    family: str
    link: str
    basis_size: int
    spline_degree: int
    working_correlation: str
    covariance_type: str
    scale: float
    maxiter: int
    ctol: float
    converged: bool
    backend_warnings: tuple[str, ...]
    exposure: np.ndarray | None = None
    log_exposure: np.ndarray | None = None
    rate_functions: np.ndarray | None = None
    linear_predictor_rate: np.ndarray | None = None
    linear_predictor_count: np.ndarray | None = None
    exposure_units: str | None = None
    exposure_expanded_from_curve: bool = False
    binomial_successes: np.ndarray | None = None
    binomial_denominators: np.ndarray | None = None
    binomial_observed_proportions: np.ndarray | None = None
    binomial_expected_successes: np.ndarray | None = None
    binomial_denominator_expanded_from_curve: bool = False
    provenance: Mapping[str, Any] = field(default_factory=dict)
    model: Any = field(default=None, repr=False)

    @property
    def n_coefficients(self) -> int:
        return len(self.coefficient_names)

    @property
    def n_curves(self) -> int:
        return len(self.source_curve_ids)

    @property
    def n_participants(self) -> int:
        return len(self.participant_ids)

eyetrajectoriespy.GeneralizedFunctionOnScalarBootstrapResult dataclass

Whole-participant bootstrap for generalized function-on-scalar curves.

Source code in src/eyetrajectoriespy/types.py
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
@dataclass(frozen=True)
class GeneralizedFunctionOnScalarBootstrapResult:
    """Whole-participant bootstrap for generalized function-on-scalar curves."""

    reference: GeneralizedFunctionOnScalarResult
    bootstrap_coefficient_functions: np.ndarray
    sampled_participant_indices: np.ndarray
    sampled_source_participant_ids: tuple[tuple[str, ...], ...]
    sampled_bootstrap_participant_ids: tuple[tuple[str, ...], ...]
    random_state: int | None
    failure_policy: str
    provenance: Mapping[str, Any] = field(default_factory=dict)

    @property
    def n_bootstrap(self) -> int:
        return self.bootstrap_coefficient_functions.shape[0]

eyetrajectoriespy.GeneralizedFunctionOnScalarBandResult dataclass

Observed-grid simultaneous bands for generalized FoSR coefficients.

Source code in src/eyetrajectoriespy/types.py
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
@dataclass(frozen=True)
class GeneralizedFunctionOnScalarBandResult:
    """Observed-grid simultaneous bands for generalized FoSR coefficients."""

    reference: GeneralizedFunctionOnScalarResult
    lower: np.ndarray
    upper: np.ndarray
    critical_values: np.ndarray
    max_statistics: np.ndarray
    confidence_level: float
    simultaneous_scope: str
    bootstrap: GeneralizedFunctionOnScalarBootstrapResult
    provenance: Mapping[str, Any] = field(default_factory=dict)

    @property
    def n_coefficients(self) -> int:
        return self.lower.shape[0]

eyetrajectoriespy.fit_generalized_function_on_scalar_regression

fit_generalized_function_on_scalar_regression(trajectories: TrajectorySet, design: DataFrame, predictors: Sequence[str], *, participant_column: str, dimension: str, family: str, binomial_denominator: ndarray | Sequence[float] | None = None, exposure: ndarray | Sequence[float] | None = None, exposure_units: str | None = None, basis_size: int = 5, spline_degree: int = 3, working_correlation: str = 'independence', covariance_type: str = 'robust', maxiter: int = 100, ctol: float = 1e-08) -> GeneralizedFunctionOnScalarResult

Fit a marginal generalized function-on-scalar model by clustered GEE.

Version 0.54 supports Bernoulli/logit and Poisson/log functional outcomes, including explicit grouped-binomial denominators and the 0.53 positive Poisson exposure contract. Coefficient functions use an explicitly sized clamped B-spline basis. Participants are the independent GEE clusters; trial-varying predictors are allowed. The only working correlation in this tranche is independence, paired with the robust sandwich covariance.

No family, link, basis size, working dependence structure, smoothing penalty, categorical encoding, predictor scaling, or model is selected automatically.

Source code in src/eyetrajectoriespy/generalized_function_on_scalar.py
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
def fit_generalized_function_on_scalar_regression(
    trajectories: TrajectorySet,
    design: pd.DataFrame,
    predictors: Sequence[str],
    *,
    participant_column: str,
    dimension: str,
    family: str,
    binomial_denominator: np.ndarray | Sequence[float] | None = None,
    exposure: np.ndarray | Sequence[float] | None = None,
    exposure_units: str | None = None,
    basis_size: int = 5,
    spline_degree: int = 3,
    working_correlation: str = "independence",
    covariance_type: str = "robust",
    maxiter: int = 100,
    ctol: float = 1e-8,
) -> GeneralizedFunctionOnScalarResult:
    """Fit a marginal generalized function-on-scalar model by clustered GEE.

    Version 0.54 supports Bernoulli/logit and Poisson/log functional outcomes,
    including explicit grouped-binomial denominators and the 0.53 positive
    Poisson exposure contract.
    Coefficient functions use an explicitly sized clamped B-spline basis.
    Participants are the independent GEE clusters; trial-varying predictors are
    allowed.  The only working correlation in this tranche is independence,
    paired with the robust sandwich covariance.

    No family, link, basis size, working dependence structure, smoothing
    penalty, categorical encoding, predictor scaling, or model is selected
    automatically.
    """

    validate_trajectory_set(trajectories, require_complete=True)
    if not np.all(np.isfinite(trajectories.values)):
        raise ValueError(
            "generalized function-on-scalar regression requires finite "
            "complete trajectories"
        )
    if trajectories.coordinate_system == "probability_simplex":
        raise ValueError(
            "generalized function-on-scalar regression does not interpret "
            "simplex-valued AOI probabilities as Bernoulli/count observations"
        )
    if working_correlation != "independence":
        raise ValueError(
            "working_correlation must be 'independence' for the 0.54 "
            "marginal GEE contract"
        )
    if covariance_type != "robust":
        raise ValueError(
            "covariance_type must be 'robust'; naive working-correlation "
            "standard errors are not exposed in 0.54"
        )
    if isinstance(maxiter, bool) or not isinstance(maxiter, int):
        raise TypeError("maxiter must be an integer")
    if maxiter < 1:
        raise ValueError("maxiter must be positive")
    if not np.isfinite(ctol) or ctol <= 0:
        raise ValueError("ctol must be finite and positive")

    aligned_design, predictor_names = _validate_design_alignment(
        trajectories,
        design,
        predictors,
    )
    dimension_index, dimension_name = _validate_dimension(
        trajectories,
        dimension,
    )
    observed_functions = trajectories.values[
        :,
        :,
        dimension_index,
    ].copy()
    (
        binomial_successes,
        binomial_denominators,
        binomial_proportions,
        denominator_expanded,
    ) = _validate_grouped_binomial(
        observed_functions,
        binomial_denominator,
        family=family,
        n_curves=trajectories.n_curves,
        n_time=trajectories.n_time,
    )
    _, link_name = _validate_family_response(
        observed_functions,
        family=family,
        grouped_binomial=binomial_denominators is not None,
    )
    exposure_array, log_exposure, exposure_expanded = _validate_poisson_exposure(
        exposure,
        family=family,
        n_curves=trajectories.n_curves,
        n_time=trajectories.n_time,
    )
    if exposure_array is not None and binomial_denominators is not None:
        raise ValueError(
            "Poisson exposure and grouped-binomial denominators cannot be "
            "used in the same model"
        )
    if exposure_units is not None:
        if not isinstance(exposure_units, str) or not exposure_units.strip():
            raise TypeError("exposure_units must be a non-empty string or None")
        if exposure_array is None:
            raise ValueError("exposure_units requires an explicit exposure array")
        exposure_units = exposure_units.strip()

    basis, basis_knots = _bspline_basis(
        trajectories.time,
        n_basis=basis_size,
        degree=spline_degree,
    )
    if np.linalg.matrix_rank(basis) != basis_size:
        raise ValueError(
            "generalized function-on-scalar coefficient basis is rank "
            "deficient on the observed grid"
        )

    predictor_matrix = aligned_design.loc[
        :,
        list(predictor_names),
    ].to_numpy(dtype=float)
    scalar_design = np.column_stack(
        [
            np.ones(trajectories.n_curves, dtype=float),
            predictor_matrix,
        ]
    )
    scalar_rank = int(np.linalg.matrix_rank(scalar_design))
    if scalar_rank != scalar_design.shape[1]:
        raise ValueError(
            "scalar design matrix is rank deficient; remove redundant "
            "predictors or encode the model explicitly"
        )
    coefficient_names = ("Intercept",) + predictor_names
    n_parameters = len(coefficient_names) * basis_size

    (
        curve_participants,
        participant_ids,
        curves_per_participant,
    ) = _validate_participant_clusters(
        trajectories,
        participant_column=participant_column,
        n_parameters=n_parameters,
    )

    state = _fit_gee_arrays(
        observed_functions=observed_functions,
        scalar_design=scalar_design,
        curve_participants=curve_participants,
        basis=basis,
        family=family,
        exposure=exposure_array,
        binomial_denominator=binomial_denominators,
        maxiter=maxiter,
        ctol=ctol,
    )

    family_name = str(family).lower().strip()
    return GeneralizedFunctionOnScalarResult(
        coefficient_functions=state["coefficient_functions"],
        coefficient_standard_errors=state[
            "coefficient_standard_errors"
        ],
        basis_coefficients=state["basis_coefficients"],
        parameter_covariance=state["parameter_covariance"],
        basis=basis.copy(),
        basis_knots=basis_knots.copy(),
        linear_predictor_functions=state[
            "linear_predictor_functions"
        ],
        mean_functions=state["mean_functions"],
        observed_functions=observed_functions,
        scalar_design_matrix=scalar_design,
        expanded_design_rank=state["expanded_design_rank"],
        coefficient_names=coefficient_names,
        predictor_names=predictor_names,
        participant_column=participant_column,
        participant_ids=participant_ids,
        curve_participant_ids=tuple(map(str, curve_participants)),
        curves_per_participant=curves_per_participant,
        source_curve_ids=trajectories.curve_ids,
        time=trajectories.time.copy(),
        dimension_name=dimension_name,
        coordinate_system=trajectories.coordinate_system,
        time_unit=trajectories.time_unit,
        family=family_name,
        link=link_name,
        basis_size=basis_size,
        spline_degree=spline_degree,
        working_correlation=working_correlation,
        covariance_type=covariance_type,
        scale=state["scale"],
        maxiter=maxiter,
        ctol=float(ctol),
        converged=True,
        backend_warnings=state["warning_messages"],
        exposure=(
            None if exposure_array is None else exposure_array.copy()
        ),
        log_exposure=(
            None if log_exposure is None else log_exposure.copy()
        ),
        rate_functions=state["rate_functions"],
        linear_predictor_rate=state["linear_predictor_rate"],
        linear_predictor_count=state["linear_predictor_count"],
        exposure_units=exposure_units,
        exposure_expanded_from_curve=exposure_expanded,
        binomial_successes=(
            None if binomial_successes is None else binomial_successes.copy()
        ),
        binomial_denominators=(
            None
            if binomial_denominators is None
            else binomial_denominators.copy()
        ),
        binomial_observed_proportions=(
            None
            if binomial_proportions is None
            else binomial_proportions.copy()
        ),
        binomial_expected_successes=state["binomial_expected_successes"],
        binomial_denominator_expanded_from_curve=denominator_expanded,
        provenance={
            **dict(trajectories.provenance),
            "generalized_function_on_scalar_regression": {
                "method": "marginal_function_on_scalar_gee",
                "backend": "statsmodels.GEE",
                "family": family_name,
                "link": link_name,
                "response_dimension": dimension_name,
                "participant_column": participant_column,
                "n_participants": len(participant_ids),
                "n_source_curves": trajectories.n_curves,
                "n_grid_observations": int(
                    trajectories.n_curves * trajectories.n_time
                ),
                "coefficient_names": list(coefficient_names),
                "predictors": list(predictor_names),
                "design_alignment": aligned_design.attrs[
                    "eyetrajectoriespy_alignment"
                ],
                "coefficient_basis": "clamped_bspline",
                "basis_size": basis_size,
                "spline_degree": spline_degree,
                "basis_size_selected_automatically": False,
                "smoothing_penalty": False,
                "working_correlation": "independence",
                "working_correlation_selected_automatically": False,
                "covariance_type": "robust_sandwich",
                "independent_cluster": "participant",
                "trial_varying_predictors_supported": True,
                "random_effects": False,
                "conditional_effect_interpretation": False,
                "marginal_population_averaged_interpretation": True,
                "categorical_encoding": False,
                "predictor_centering": False,
                "predictor_scaling": False,
                "interaction_construction": False,
                "automatic_family_selection": False,
                "automatic_link_selection": False,
                "automatic_model_selection": False,
                "cluster_count_guard": (
                    "require n_participants > expanded_coefficient_parameter_count"
                ),
                "expanded_coefficient_parameter_count": n_parameters,
                "cluster_count_guard_is_adequacy_theorem": False,
                "grouped_binomial_success_denominator_supported": True,
                "grouped_binomial_supplied": (
                    binomial_denominators is not None
                ),
                "grouped_binomial_response_representation": (
                    None
                    if binomial_denominators is None
                    else "integer_successes_plus_integer_denominator"
                ),
                "grouped_binomial_backend_representation": (
                    None
                    if binomial_denominators is None
                    else "proportion_plus_gee_weights"
                ),
                "binomial_denominator_shape": (
                    None
                    if binomial_denominators is None
                    else list(binomial_denominators.shape)
                ),
                "binomial_denominator_expanded_from_curve": (
                    denominator_expanded
                ),
                "binomial_denominator_inferred": False,
                "binomial_denominator_observed_and_fixed": (
                    binomial_denominators is not None
                ),
                "binomial_denominator_measurement_uncertainty": False,
                "generic_proportion_input_supported": False,
                "generic_offset_supported": False,
                "poisson_exposure_supported": True,
                "exposure_supplied": exposure_array is not None,
                "exposure_shape": (
                    None
                    if exposure_array is None
                    else list(exposure_array.shape)
                ),
                "exposure_units": exposure_units,
                "exposure_expanded_from_curve": exposure_expanded,
                "exposure_inferred_from_time_grid": False,
                "exposure_inferred_from_trial_duration": False,
                "exposure_inferred_from_metadata": False,
                "exposure_observed_and_fixed": exposure_array is not None,
                "exposure_measurement_uncertainty": False,
                "converged": True,
                "backend_warnings": list(state["warning_messages"]),
            },
        },
        model=state["fitted"],
    )

eyetrajectoriespy.bootstrap_generalized_function_on_scalar_coefficients

bootstrap_generalized_function_on_scalar_coefficients(result: GeneralizedFunctionOnScalarResult, *, n_bootstrap: int = 1000, random_state: int | None = 0, failed_replicate_policy: str = 'raise') -> GeneralizedFunctionOnScalarBootstrapResult

Whole-participant case bootstrap for generalized FoSR coefficients.

Source code in src/eyetrajectoriespy/generalized_function_on_scalar.py
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
def bootstrap_generalized_function_on_scalar_coefficients(
    result: GeneralizedFunctionOnScalarResult,
    *,
    n_bootstrap: int = 1000,
    random_state: int | None = 0,
    failed_replicate_policy: str = "raise",
) -> GeneralizedFunctionOnScalarBootstrapResult:
    """Whole-participant case bootstrap for generalized FoSR coefficients."""

    if not isinstance(result, GeneralizedFunctionOnScalarResult):
        raise TypeError(
            "result must be a GeneralizedFunctionOnScalarResult"
        )
    if isinstance(n_bootstrap, bool) or not isinstance(n_bootstrap, int):
        raise TypeError("n_bootstrap must be an integer")
    if n_bootstrap < 100:
        raise ValueError("n_bootstrap must be at least 100")
    if failed_replicate_policy != "raise":
        raise ValueError(
            "failed_replicate_policy must be 'raise'; failed GEE bootstrap "
            "replicates are not silently dropped or redrawn"
        )

    rng = np.random.default_rng(random_state)
    participant_ids = result.participant_ids
    curve_participants = np.asarray(
        result.curve_participant_ids,
        dtype=str,
    )
    n_participants = len(participant_ids)

    participant_curve_indices = [
        np.flatnonzero(curve_participants == participant_id)
        for participant_id in participant_ids
    ]
    sampled_indices = rng.integers(
        0,
        n_participants,
        size=(n_bootstrap, n_participants),
    )
    bootstrap_coefficients = np.empty(
        (
            n_bootstrap,
            result.n_coefficients,
            result.time.size,
        ),
        dtype=float,
    )
    source_id_audit: list[tuple[str, ...]] = []
    bootstrap_id_audit: list[tuple[str, ...]] = []

    for bootstrap_index in range(n_bootstrap):
        curve_indices: list[int] = []
        groups: list[str] = []
        source_ids: list[str] = []
        bootstrap_ids: list[str] = []

        for draw_index, source_index in enumerate(
            sampled_indices[bootstrap_index]
        ):
            source_id = participant_ids[int(source_index)]
            bootstrap_id = (
                f"bootstrap_{bootstrap_index:04d}_participant_"
                f"{draw_index:04d}"
            )
            indices = participant_curve_indices[int(source_index)]
            curve_indices.extend(int(index) for index in indices)
            groups.extend(bootstrap_id for _ in indices)
            source_ids.append(source_id)
            bootstrap_ids.append(bootstrap_id)

        index = np.asarray(curve_indices, dtype=int)
        try:
            state = _fit_gee_arrays(
                observed_functions=result.observed_functions[index],
                scalar_design=result.scalar_design_matrix[index],
                curve_participants=np.asarray(groups, dtype=str),
                basis=result.basis,
                family=result.family,
                exposure=(
                    None
                    if result.exposure is None
                    else result.exposure[index]
                ),
                binomial_denominator=(
                    None
                    if result.binomial_denominators is None
                    else result.binomial_denominators[index]
                ),
                maxiter=result.maxiter,
                ctol=result.ctol,
            )
        except Exception as exc:
            raise RuntimeError(
                "generalized function-on-scalar participant bootstrap failed "
                f"at replicate {bootstrap_index}; the replicate was retained "
                "as a failure and was not silently dropped or redrawn"
            ) from exc

        bootstrap_coefficients[bootstrap_index] = state[
            "coefficient_functions"
        ]
        source_id_audit.append(tuple(source_ids))
        bootstrap_id_audit.append(tuple(bootstrap_ids))

    return GeneralizedFunctionOnScalarBootstrapResult(
        reference=result,
        bootstrap_coefficient_functions=bootstrap_coefficients,
        sampled_participant_indices=sampled_indices,
        sampled_source_participant_ids=tuple(source_id_audit),
        sampled_bootstrap_participant_ids=tuple(bootstrap_id_audit),
        random_state=random_state,
        failure_policy=failed_replicate_policy,
        provenance={
            **dict(result.provenance),
            "generalized_function_on_scalar_bootstrap": {
                "method": "whole_participant_case_bootstrap_full_gee_refit",
                "n_bootstrap": n_bootstrap,
                "random_state": random_state,
                "resampling_unit": "participant",
                "whole_participant_curve_bundles_resampled": True,
                "duplicate_source_participants_receive_distinct_group_ids": True,
                "family_refit": result.family,
                "link_refit": result.link,
                "working_correlation_refit": "independence",
                "basis_refit": False,
                "basis_selected_automatically": False,
                "failed_replicate_policy": "raise",
                "failed_replicates_redrawn": False,
                "exposure_observed_and_fixed": result.exposure is not None,
                "exposure_resampled_with_response_bundle": (
                    result.exposure is not None
                ),
                "exposure_measurement_uncertainty": False,
                "binomial_denominator_observed_and_fixed": (
                    result.binomial_denominators is not None
                ),
                "binomial_denominator_resampled_with_response_bundle": (
                    result.binomial_denominators is not None
                ),
                "binomial_denominator_measurement_uncertainty": False,
            },
        },
    )

eyetrajectoriespy.generalized_function_on_scalar_simultaneous_bands

generalized_function_on_scalar_simultaneous_bands(bootstrap: GeneralizedFunctionOnScalarBootstrapResult, *, confidence_level: float = 0.95, simultaneous_scope: str = 'coefficient') -> GeneralizedFunctionOnScalarBandResult

Calibrate observed-grid link-scale simultaneous coefficient bands.

Source code in src/eyetrajectoriespy/generalized_function_on_scalar.py
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
def generalized_function_on_scalar_simultaneous_bands(
    bootstrap: GeneralizedFunctionOnScalarBootstrapResult,
    *,
    confidence_level: float = 0.95,
    simultaneous_scope: str = "coefficient",
) -> GeneralizedFunctionOnScalarBandResult:
    """Calibrate observed-grid link-scale simultaneous coefficient bands."""

    if not isinstance(
        bootstrap,
        GeneralizedFunctionOnScalarBootstrapResult,
    ):
        raise TypeError(
            "bootstrap must be a GeneralizedFunctionOnScalarBootstrapResult"
        )
    if not 0 < confidence_level < 1:
        raise ValueError("confidence_level must lie in (0, 1)")
    if simultaneous_scope not in {"coefficient", "family"}:
        raise ValueError(
            "simultaneous_scope must be 'coefficient' or 'family'"
        )

    reference = bootstrap.reference
    standard_errors = np.asarray(
        reference.coefficient_standard_errors,
        dtype=float,
    )
    positive = standard_errors > np.finfo(float).eps
    deviations = (
        bootstrap.bootstrap_coefficient_functions
        - reference.coefficient_functions[None, :, :]
    )
    standardized = np.zeros_like(deviations)
    np.divide(
        deviations,
        standard_errors[None, :, :],
        out=standardized,
        where=positive[None, :, :],
    )
    absolute = np.abs(standardized)

    if simultaneous_scope == "coefficient":
        max_statistics = np.max(absolute, axis=2)
        critical_values = np.quantile(
            max_statistics,
            confidence_level,
            axis=0,
            method="higher",
        )
    else:
        family_max = np.max(absolute, axis=(1, 2))
        critical = float(
            np.quantile(
                family_max,
                confidence_level,
                method="higher",
            )
        )
        max_statistics = family_max[:, None]
        critical_values = np.full(
            reference.n_coefficients,
            critical,
            dtype=float,
        )

    lower = (
        reference.coefficient_functions
        - critical_values[:, None] * standard_errors
    )
    upper = (
        reference.coefficient_functions
        + critical_values[:, None] * standard_errors
    )
    return GeneralizedFunctionOnScalarBandResult(
        reference=reference,
        lower=lower,
        upper=upper,
        critical_values=critical_values,
        max_statistics=max_statistics,
        confidence_level=confidence_level,
        simultaneous_scope=simultaneous_scope,
        bootstrap=bootstrap,
        provenance={
            **dict(bootstrap.provenance),
            "generalized_function_on_scalar_simultaneous_band": {
                "confidence_level": confidence_level,
                "simultaneous_scope": simultaneous_scope,
                "coefficient_scale": "link",
                "calibration": "participant_bootstrap_max_standardized_deviation",
                "pointwise_standard_error": "gee_robust_sandwich",
                "response_scale_coefficient_band": False,
                "automatic_model_selection": False,
            },
        },
    )

eyetrajectoriespy.generalized_function_on_scalar_coefficient_frame

generalized_function_on_scalar_coefficient_frame(result: GeneralizedFunctionOnScalarResult | GeneralizedFunctionOnScalarBandResult) -> pd.DataFrame

Return one row per coefficient and observed time point.

Source code in src/eyetrajectoriespy/generalized_function_on_scalar.py
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
def generalized_function_on_scalar_coefficient_frame(
    result: (
        GeneralizedFunctionOnScalarResult
        | GeneralizedFunctionOnScalarBandResult
    ),
) -> pd.DataFrame:
    """Return one row per coefficient and observed time point."""

    if isinstance(result, GeneralizedFunctionOnScalarBandResult):
        fit = result.reference
        band = result
    elif isinstance(result, GeneralizedFunctionOnScalarResult):
        fit = result
        band = None
    else:
        raise TypeError(
            "result must be a generalized function-on-scalar fit or band"
        )

    rows: list[dict[str, object]] = []
    for coefficient_index, coefficient_name in enumerate(
        fit.coefficient_names
    ):
        for time_index, time_value in enumerate(fit.time):
            row = {
                "coefficient": coefficient_name,
                "time": float(time_value),
                "estimate": float(
                    fit.coefficient_functions[
                        coefficient_index,
                        time_index,
                    ]
                ),
                "standard_error": float(
                    fit.coefficient_standard_errors[
                        coefficient_index,
                        time_index,
                    ]
                ),
                "scale": "link",
                "family": fit.family,
                "link": fit.link,
            }
            if band is not None:
                row["lower"] = float(
                    band.lower[coefficient_index, time_index]
                )
                row["upper"] = float(
                    band.upper[coefficient_index, time_index]
                )
                row["critical_value"] = float(
                    band.critical_values[coefficient_index]
                )
            rows.append(row)
    return pd.DataFrame(rows)

eyetrajectoriespy.generalized_function_on_scalar_exposure_frame

generalized_function_on_scalar_exposure_frame(result: GeneralizedFunctionOnScalarResult) -> pd.DataFrame

Return a per-curve audit of an explicitly supplied Poisson exposure.

Source code in src/eyetrajectoriespy/generalized_function_on_scalar.py
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
def generalized_function_on_scalar_exposure_frame(
    result: GeneralizedFunctionOnScalarResult,
) -> pd.DataFrame:
    """Return a per-curve audit of an explicitly supplied Poisson exposure."""

    if not isinstance(result, GeneralizedFunctionOnScalarResult):
        raise TypeError("result must be a GeneralizedFunctionOnScalarResult")
    if result.exposure is None:
        raise ValueError("the fitted model does not contain an exposure array")

    exposure = np.asarray(result.exposure, dtype=float)
    rows = []
    for index, curve_id in enumerate(result.source_curve_ids):
        values = exposure[index]
        minimum = float(np.min(values))
        maximum = float(np.max(values))
        rows.append(
            {
                "curve_id": curve_id,
                "minimum_exposure": minimum,
                "maximum_exposure": maximum,
                "exposure_range": maximum - minimum,
                "exposure_ratio": maximum / minimum,
                "varies_over_time": bool(
                    not np.allclose(values, values[0], rtol=0.0, atol=0.0)
                ),
                "exposure_units": result.exposure_units,
            }
        )
    frame = pd.DataFrame(rows)
    global_min = float(np.min(exposure))
    global_max = float(np.max(exposure))
    curve_means = np.mean(exposure, axis=1)
    frame.attrs["exposure_audit"] = {
        "minimum_exposure": global_min,
        "maximum_exposure": global_max,
        "exposure_range": global_max - global_min,
        "extreme_exposure_ratio": global_max / global_min,
        "varies_over_time": bool(np.any(frame["varies_over_time"])),
        "varies_between_curves": bool(
            not np.allclose(curve_means, curve_means[0], rtol=0.0, atol=0.0)
        ),
        "exposure_units": result.exposure_units,
        "exposure_expanded_from_curve": result.exposure_expanded_from_curve,
        "exposure_observed_and_fixed": True,
        "exposure_measurement_uncertainty": False,
    }
    return frame

eyetrajectoriespy.plot_generalized_function_on_scalar_coefficients

plot_generalized_function_on_scalar_coefficients(result: GeneralizedFunctionOnScalarResult | GeneralizedFunctionOnScalarBandResult, *, coefficient: str, ax=None)

Plot one generalized FoSR coefficient on the declared link scale.

Source code in src/eyetrajectoriespy/generalized_function_on_scalar.py
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
def plot_generalized_function_on_scalar_coefficients(
    result: (
        GeneralizedFunctionOnScalarResult
        | GeneralizedFunctionOnScalarBandResult
    ),
    *,
    coefficient: str,
    ax=None,
):
    """Plot one generalized FoSR coefficient on the declared link scale."""

    import matplotlib.pyplot as plt

    if isinstance(result, GeneralizedFunctionOnScalarBandResult):
        fit = result.reference
        band = result
    elif isinstance(result, GeneralizedFunctionOnScalarResult):
        fit = result
        band = None
    else:
        raise TypeError(
            "result must be a generalized function-on-scalar fit or band"
        )
    if coefficient not in fit.coefficient_names:
        raise KeyError(f"Unknown coefficient {coefficient!r}")
    coefficient_index = fit.coefficient_names.index(coefficient)

    if ax is None:
        _, ax = plt.subplots()
    estimate = fit.coefficient_functions[coefficient_index]
    ax.plot(fit.time, estimate, label="estimate")
    if band is not None:
        ax.fill_between(
            fit.time,
            band.lower[coefficient_index],
            band.upper[coefficient_index],
            alpha=0.2,
            label=(
                f"{100 * band.confidence_level:.0f}% simultaneous band"
            ),
        )
    ax.axhline(0.0, linewidth=1.0)
    ax.set_xlabel(f"Time ({fit.time_unit})")
    ax.set_ylabel(f"{coefficient} coefficient ({fit.link} scale)")
    ax.set_title(
        f"Generalized function-on-scalar coefficient: {coefficient}"
    )
    ax.legend()
    return ax

eyetrajectoriespy.generalized_function_on_scalar_reporting_text

generalized_function_on_scalar_reporting_text(result: GeneralizedFunctionOnScalarResult, *, band: GeneralizedFunctionOnScalarBandResult | None = None) -> str

Return manuscript-oriented wording for marginal generalized FoSR.

Source code in src/eyetrajectoriespy/generalized_function_on_scalar.py
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
def generalized_function_on_scalar_reporting_text(
    result: GeneralizedFunctionOnScalarResult,
    *,
    band: GeneralizedFunctionOnScalarBandResult | None = None,
) -> str:
    """Return manuscript-oriented wording for marginal generalized FoSR."""

    if not isinstance(result, GeneralizedFunctionOnScalarResult):
        raise TypeError(
            "result must be a GeneralizedFunctionOnScalarResult"
        )
    if band is not None:
        if not isinstance(band, GeneralizedFunctionOnScalarBandResult):
            raise TypeError(
                "band must be a GeneralizedFunctionOnScalarBandResult or None"
            )
        if band.reference is not result:
            raise ValueError("band.reference must be the supplied fit object")

    band_text = (
        " No simultaneous coefficient band was requested."
        if band is None
        else (
            f" A {100 * band.confidence_level:.1f}% observed-grid "
            f"{band.simultaneous_scope}-simultaneous coefficient band was "
            "calibrated on the link scale using whole-participant case "
            f"bootstrap refits ({band.bootstrap.n_bootstrap} replicates)."
        )
    )
    grouped_text = ""
    if result.family == "binomial" and result.binomial_denominators is not None:
        grouped_text = (
            " The response was modeled as explicit grouped-binomial integer "
            "success counts with observed integer denominators; the backend "
            "used success proportions with denominator weights. Coefficients "
            "therefore describe marginal log odds of success. Denominators "
            "were treated as observed and fixed, were not inferred, and their "
            "measurement uncertainty was not modeled."
        )

    exposure_text = ""
    if result.family == "poisson" and result.exposure is not None:
        exposure_text = (
            " A strictly positive observed exposure was included explicitly "
            f"({result.exposure_units or 'units not declared'}); coefficients "
            "therefore describe marginal log rates and exponentiated "
            "coefficients are rate ratios holding exposure fixed. Exposure "
            "was treated as observed and fixed, was not inferred, and "
            "measurement uncertainty in exposure was not modeled."
        )
    elif result.family == "poisson":
        exposure_text = (
            " No exposure was supplied, so Poisson coefficients describe "
            "marginal log expected counts rather than rates."
        )

    return (
        "Marginal generalized function-on-scalar regression modeled "
        f"{result.dimension_name!r} using a {result.family} family with "
        f"{result.link} link and {result.basis_size} clamped B-spline basis "
        "functions per coefficient. Participants were treated as independent "
        "clusters and within-participant observations were fit with working "
        "independence; coefficient uncertainty used the robust GEE sandwich "
        "covariance. Coefficients therefore have a population-averaged "
        "marginal interpretation on the link scale, not a conditional "
        "random-effects interpretation. No working correlation, smoothing "
        "penalty, exposure definition, family, link, or model was selected "
        "automatically."
        + grouped_text
        + exposure_text
        + band_text
    )

Generalized FoSR fixed-profile prediction

eyetrajectoriespy.GeneralizedFunctionOnScalarPredictionResult dataclass

Fixed-profile marginal predictions for generalized FoSR.

Source code in src/eyetrajectoriespy/types.py
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
@dataclass(frozen=True)
class GeneralizedFunctionOnScalarPredictionResult:
    """Fixed-profile marginal predictions for generalized FoSR."""

    reference: GeneralizedFunctionOnScalarResult
    profile_ids: tuple[str, ...]
    profile_design_matrix: np.ndarray
    predictor_values: np.ndarray
    linear_predictor_functions: np.ndarray
    linear_predictor_standard_errors: np.ndarray
    mean_functions: np.ndarray
    mean_standard_errors: np.ndarray
    extrapolation_flags: np.ndarray
    predictor_minima: np.ndarray
    predictor_maxima: np.ndarray
    prediction_scale: str = "response"
    exposure_profiles: np.ndarray | None = None
    log_exposure_profiles: np.ndarray | None = None
    rate_functions: np.ndarray | None = None
    expected_count_functions: np.ndarray | None = None
    linear_predictor_rate: np.ndarray | None = None
    linear_predictor_count: np.ndarray | None = None
    provenance: Mapping[str, Any] = field(default_factory=dict)

    @property
    def n_profiles(self) -> int:
        return len(self.profile_ids)

eyetrajectoriespy.GeneralizedFunctionOnScalarPredictionBootstrapResult dataclass

Participant-bootstrap predictions for fixed generalized-FoSR profiles.

Source code in src/eyetrajectoriespy/types.py
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
@dataclass(frozen=True)
class GeneralizedFunctionOnScalarPredictionBootstrapResult:
    """Participant-bootstrap predictions for fixed generalized-FoSR profiles."""

    prediction: GeneralizedFunctionOnScalarPredictionResult
    coefficient_bootstrap: GeneralizedFunctionOnScalarBootstrapResult
    bootstrap_linear_predictor_functions: np.ndarray
    bootstrap_mean_functions: np.ndarray
    bootstrap_rate_functions: np.ndarray | None = None
    bootstrap_expected_count_functions: np.ndarray | None = None
    provenance: Mapping[str, Any] = field(default_factory=dict)

    @property
    def n_bootstrap(self) -> int:
        return self.bootstrap_linear_predictor_functions.shape[0]

eyetrajectoriespy.GeneralizedFunctionOnScalarPredictionBandResult dataclass

Observed-grid simultaneous bands for fixed marginal profiles.

Source code in src/eyetrajectoriespy/types.py
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
@dataclass(frozen=True)
class GeneralizedFunctionOnScalarPredictionBandResult:
    """Observed-grid simultaneous bands for fixed marginal profiles."""

    prediction: GeneralizedFunctionOnScalarPredictionResult
    linear_lower: np.ndarray
    linear_upper: np.ndarray
    mean_lower: np.ndarray
    mean_upper: np.ndarray
    critical_values: np.ndarray
    max_statistics: np.ndarray
    confidence_level: float
    simultaneous_scope: str
    bootstrap: GeneralizedFunctionOnScalarPredictionBootstrapResult
    provenance: Mapping[str, Any] = field(default_factory=dict)

    @property
    def n_profiles(self) -> int:
        return self.prediction.n_profiles

eyetrajectoriespy.GeneralizedFunctionOnScalarMeanDifferenceResult dataclass

Simultaneous response-scale mean-difference band for two profiles.

Source code in src/eyetrajectoriespy/types.py
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
@dataclass(frozen=True)
class GeneralizedFunctionOnScalarMeanDifferenceResult:
    """Simultaneous response-scale mean-difference band for two profiles."""

    prediction_bootstrap: GeneralizedFunctionOnScalarPredictionBootstrapResult
    profile_a: str
    profile_b: str
    estimate: np.ndarray
    standard_error: np.ndarray
    lower: np.ndarray
    upper: np.ndarray
    bootstrap_estimates: np.ndarray
    max_statistics: np.ndarray
    critical_value: float
    confidence_level: float
    physical_lower_bound: float | None
    physical_upper_bound: float | None
    interval_exceeds_physical_bounds: bool
    contrast_scale: str = "response_difference"
    inference_scale: str = "response"
    provenance: Mapping[str, Any] = field(default_factory=dict)

eyetrajectoriespy.generalized_function_on_scalar_predict

generalized_function_on_scalar_predict(result: GeneralizedFunctionOnScalarResult, profiles: DataFrame, *, profile_id_column: str = 'profile_id', exposure_profiles=None, prediction_scale: str | None = None) -> GeneralizedFunctionOnScalarPredictionResult

Predict fixed marginal response profiles under a generalized FoSR fit.

Profiles are fixed analyst-declared targets. Their predictor values are never estimated, averaged, resampled, centered, scaled, or encoded by this function. Targets outside the observed scalar predictor ranges are retained and explicitly flagged as extrapolations.

Source code in src/eyetrajectoriespy/generalized_function_on_scalar_prediction.py
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
def generalized_function_on_scalar_predict(
    result: GeneralizedFunctionOnScalarResult,
    profiles: pd.DataFrame,
    *,
    profile_id_column: str = "profile_id",
    exposure_profiles=None,
    prediction_scale: str | None = None,
) -> GeneralizedFunctionOnScalarPredictionResult:
    """Predict fixed marginal response profiles under a generalized FoSR fit.

    Profiles are fixed analyst-declared targets.  Their predictor values are
    never estimated, averaged, resampled, centered, scaled, or encoded by this
    function.  Targets outside the observed scalar predictor ranges are retained
    and explicitly flagged as extrapolations.
    """

    _, profile_ids, predictor_values = _validate_profiles(
        result,
        profiles,
        profile_id_column=profile_id_column,
    )
    target_exposure, target_log_exposure = _validate_prediction_exposure(
        result,
        exposure_profiles,
        n_profiles=len(profile_ids),
    )
    if result.family == "binomial":
        if prediction_scale not in {None, "probability"}:
            raise ValueError(
                "binomial prediction_scale must be 'probability' or None"
            )
        selected_scale = "probability"
    elif result.exposure is None:
        if prediction_scale not in {None, "expected_count"}:
            raise ValueError(
                "a Poisson fit without exposure supports expected_count "
                "prediction only; refit with exposure for a rate estimand"
            )
        selected_scale = "expected_count"
    else:
        if prediction_scale is None:
            selected_scale = "rate"
        elif prediction_scale in {"rate", "expected_count"}:
            selected_scale = prediction_scale
        else:
            raise ValueError(
                "Poisson exposure prediction_scale must be 'rate' or "
                "'expected_count'"
            )
        if selected_scale == "rate" and target_exposure is not None:
            raise ValueError(
                "exposure_profiles must be omitted for rate predictions"
            )
        if selected_scale == "expected_count" and target_exposure is None:
            raise ValueError(
                "expected-count prediction from an exposure-adjusted fit "
                "requires explicit exposure_profiles"
            )

    scalar_design = np.column_stack(
        [
            np.ones(len(profile_ids), dtype=float),
            predictor_values,
        ]
    )
    expanded_design = _fixed_effect_design(
        scalar_design,
        result.basis,
    )
    parameter_vector = np.asarray(
        result.basis_coefficients,
        dtype=float,
    ).reshape(-1)
    linear_rate = (
        expanded_design @ parameter_vector
    ).reshape(len(profile_ids), result.time.size)
    if selected_scale == "expected_count" and target_log_exposure is not None:
        linear = linear_rate + target_log_exposure
    else:
        linear = linear_rate

    covariance = np.asarray(
        result.parameter_covariance,
        dtype=float,
    )
    linear_variance = np.einsum(
        "ij,jk,ik->i",
        expanded_design,
        covariance,
        expanded_design,
        optimize=True,
    ).reshape(len(profile_ids), result.time.size)
    linear_se = np.sqrt(np.maximum(linear_variance, 0.0))

    mean = _inverse_link(result.family, linear)
    rate = (
        _inverse_link("poisson", linear_rate)
        if result.family == "poisson" and result.exposure is not None
        else None
    )
    expected_count = (
        mean
        if result.family == "poisson" and selected_scale == "expected_count"
        else None
    )
    derivative = _inverse_link_derivative(result.family, mean)
    mean_se = derivative * linear_se

    observed_predictors = np.asarray(
        result.scalar_design_matrix[:, 1:],
        dtype=float,
    )
    minima = np.min(observed_predictors, axis=0)
    maxima = np.max(observed_predictors, axis=0)
    scale = np.maximum(
        1.0,
        np.maximum(np.abs(minima), np.abs(maxima)),
    )
    tolerance = 1e-12 * scale
    extrapolation = np.any(
        (predictor_values < minima[None, :] - tolerance[None, :])
        | (predictor_values > maxima[None, :] + tolerance[None, :]),
        axis=1,
    )

    return GeneralizedFunctionOnScalarPredictionResult(
        reference=result,
        profile_ids=profile_ids,
        profile_design_matrix=scalar_design,
        predictor_values=predictor_values,
        linear_predictor_functions=linear,
        linear_predictor_standard_errors=linear_se,
        mean_functions=mean,
        mean_standard_errors=mean_se,
        extrapolation_flags=extrapolation,
        predictor_minima=minima,
        predictor_maxima=maxima,
        prediction_scale=selected_scale,
        exposure_profiles=target_exposure,
        log_exposure_profiles=target_log_exposure,
        rate_functions=rate,
        expected_count_functions=expected_count,
        linear_predictor_rate=(
            linear_rate if result.family == "poisson" else None
        ),
        linear_predictor_count=(
            linear
            if result.family == "poisson" and selected_scale == "expected_count"
            else None
        ),
        provenance={
            **dict(result.provenance),
            "generalized_function_on_scalar_prediction": {
                "method": "fixed_profile_marginal_prediction",
                "profile_id_column": profile_id_column,
                "profile_ids": list(profile_ids),
                "n_profiles": len(profile_ids),
                "predictors": list(result.predictor_names),
                "profile_values_fixed": True,
                "profile_values_resampled": False,
                "categorical_encoding": False,
                "predictor_centering": False,
                "predictor_scaling": False,
                "marginal_population_averaged_interpretation": True,
                "linear_predictor_scale": result.link,
                "mean_scale": selected_scale,
                "poisson_rate_estimand": (
                    result.family == "poisson" and result.exposure is not None
                ),
                "target_exposure_supplied": target_exposure is not None,
                "target_exposure_assumed_unit": False,
                "pointwise_linear_predictor_standard_error": (
                    "delta_from_gee_robust_parameter_covariance"
                ),
                "pointwise_mean_standard_error": (
                    "inverse_link_delta_method"
                ),
                "predictor_minima": minima.tolist(),
                "predictor_maxima": maxima.tolist(),
                "extrapolation_flags": extrapolation.tolist(),
                "extrapolated_profiles_retained": True,
                "automatic_profile_selection": False,
                "simultaneous_inference": False,
            },
        },
    )

eyetrajectoriespy.bootstrap_generalized_function_on_scalar_predictions

bootstrap_generalized_function_on_scalar_predictions(bootstrap: GeneralizedFunctionOnScalarBootstrapResult, profiles: DataFrame, *, profile_id_column: str = 'profile_id', exposure_profiles=None, prediction_scale: str | None = None) -> GeneralizedFunctionOnScalarPredictionBootstrapResult

Project participant-bootstrap coefficient functions to fixed profiles.

Source code in src/eyetrajectoriespy/generalized_function_on_scalar_prediction.py
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
def bootstrap_generalized_function_on_scalar_predictions(
    bootstrap: GeneralizedFunctionOnScalarBootstrapResult,
    profiles: pd.DataFrame,
    *,
    profile_id_column: str = "profile_id",
    exposure_profiles=None,
    prediction_scale: str | None = None,
) -> GeneralizedFunctionOnScalarPredictionBootstrapResult:
    """Project participant-bootstrap coefficient functions to fixed profiles."""

    if not isinstance(
        bootstrap,
        GeneralizedFunctionOnScalarBootstrapResult,
    ):
        raise TypeError(
            "bootstrap must be a GeneralizedFunctionOnScalarBootstrapResult"
        )
    prediction = generalized_function_on_scalar_predict(
        bootstrap.reference,
        profiles,
        profile_id_column=profile_id_column,
        exposure_profiles=exposure_profiles,
        prediction_scale=prediction_scale,
    )
    design = np.asarray(
        prediction.profile_design_matrix,
        dtype=float,
    )
    coefficient_draws = np.asarray(
        bootstrap.bootstrap_coefficient_functions,
        dtype=float,
    )
    linear_rate_draws = np.einsum(
        "pc,bct->bpt",
        design,
        coefficient_draws,
        optimize=True,
    )
    rate_draws = (
        _inverse_link("poisson", linear_rate_draws)
        if bootstrap.reference.family == "poisson"
        and bootstrap.reference.exposure is not None
        else None
    )
    if (
        prediction.prediction_scale == "expected_count"
        and prediction.log_exposure_profiles is not None
    ):
        linear_draws = (
            linear_rate_draws
            + prediction.log_exposure_profiles[None, :, :]
        )
    else:
        linear_draws = linear_rate_draws
    mean_draws = _inverse_link(
        bootstrap.reference.family,
        linear_draws,
    )
    expected_count_draws = (
        mean_draws
        if bootstrap.reference.family == "poisson"
        and prediction.prediction_scale == "expected_count"
        else None
    )
    if not np.all(np.isfinite(linear_draws)):
        raise RuntimeError(
            "bootstrap fixed-profile linear predictors contain non-finite values"
        )
    if not np.all(np.isfinite(mean_draws)):
        raise RuntimeError(
            "bootstrap fixed-profile marginal means contain non-finite values"
        )

    return GeneralizedFunctionOnScalarPredictionBootstrapResult(
        prediction=prediction,
        coefficient_bootstrap=bootstrap,
        bootstrap_linear_predictor_functions=linear_draws,
        bootstrap_mean_functions=mean_draws,
        bootstrap_rate_functions=rate_draws,
        bootstrap_expected_count_functions=expected_count_draws,
        provenance={
            **dict(bootstrap.provenance),
            "generalized_function_on_scalar_prediction_bootstrap": {
                "method": (
                    "fixed_profile_projection_of_whole_participant_"
                    "coefficient_bootstrap"
                ),
                "n_bootstrap": bootstrap.n_bootstrap,
                "profile_ids": list(prediction.profile_ids),
                "profile_values_fixed_across_bootstrap": True,
                "profile_values_resampled": False,
                "same_participant_draws_as_coefficient_bootstrap": True,
                "response_transformation": (
                    "inverse_logit"
                    if bootstrap.reference.family == "binomial"
                    else "exponential"
                ),
                "prediction_scale": prediction.prediction_scale,
                "target_exposure_supplied": (
                    prediction.exposure_profiles is not None
                ),
                "automatic_profile_selection": False,
            },
        },
    )

eyetrajectoriespy.generalized_function_on_scalar_prediction_bands

generalized_function_on_scalar_prediction_bands(bootstrap: GeneralizedFunctionOnScalarPredictionBootstrapResult, *, confidence_level: float = 0.95, simultaneous_scope: str = 'profile') -> GeneralizedFunctionOnScalarPredictionBandResult

Calibrate simultaneous fixed-profile marginal mean bands.

Calibration is performed on the linear-predictor scale. Because the logit and log inverse links are strictly monotone, transforming both endpoints produces simultaneous marginal probability/mean bands with the same bootstrap event on the observed grid.

Source code in src/eyetrajectoriespy/generalized_function_on_scalar_prediction.py
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
def generalized_function_on_scalar_prediction_bands(
    bootstrap: GeneralizedFunctionOnScalarPredictionBootstrapResult,
    *,
    confidence_level: float = 0.95,
    simultaneous_scope: str = "profile",
) -> GeneralizedFunctionOnScalarPredictionBandResult:
    """Calibrate simultaneous fixed-profile marginal mean bands.

    Calibration is performed on the linear-predictor scale.  Because the logit
    and log inverse links are strictly monotone, transforming both endpoints
    produces simultaneous marginal probability/mean bands with the same
    bootstrap event on the observed grid.
    """

    if not isinstance(
        bootstrap,
        GeneralizedFunctionOnScalarPredictionBootstrapResult,
    ):
        raise TypeError(
            "bootstrap must be a "
            "GeneralizedFunctionOnScalarPredictionBootstrapResult"
        )
    if not 0 < confidence_level < 1:
        raise ValueError("confidence_level must lie in (0, 1)")
    if simultaneous_scope not in {"profile", "family"}:
        raise ValueError(
            "simultaneous_scope must be 'profile' or 'family'"
        )

    prediction = bootstrap.prediction
    se = np.asarray(
        prediction.linear_predictor_standard_errors,
        dtype=float,
    )
    if np.any(~np.isfinite(se)) or np.any(se <= np.finfo(float).eps):
        raise ValueError(
            "prediction simultaneous bands require strictly positive finite "
            "linear-predictor standard errors at every profile/time point"
        )

    deviations = (
        bootstrap.bootstrap_linear_predictor_functions
        - prediction.linear_predictor_functions[None, :, :]
    )
    standardized = np.abs(deviations / se[None, :, :])

    if simultaneous_scope == "profile":
        max_statistics = np.max(standardized, axis=2)
        critical_values = np.quantile(
            max_statistics,
            confidence_level,
            axis=0,
            method="higher",
        )
    else:
        family_max = np.max(standardized, axis=(1, 2))
        critical = float(
            np.quantile(
                family_max,
                confidence_level,
                method="higher",
            )
        )
        max_statistics = family_max[:, None]
        critical_values = np.full(
            prediction.n_profiles,
            critical,
            dtype=float,
        )

    linear_lower = (
        prediction.linear_predictor_functions
        - critical_values[:, None] * se
    )
    linear_upper = (
        prediction.linear_predictor_functions
        + critical_values[:, None] * se
    )
    mean_lower = _inverse_link(
        prediction.reference.family,
        linear_lower,
    )
    mean_upper = _inverse_link(
        prediction.reference.family,
        linear_upper,
    )

    return GeneralizedFunctionOnScalarPredictionBandResult(
        prediction=prediction,
        linear_lower=linear_lower,
        linear_upper=linear_upper,
        mean_lower=mean_lower,
        mean_upper=mean_upper,
        critical_values=critical_values,
        max_statistics=max_statistics,
        confidence_level=confidence_level,
        simultaneous_scope=simultaneous_scope,
        bootstrap=bootstrap,
        provenance={
            **dict(bootstrap.provenance),
            "generalized_function_on_scalar_prediction_band": {
                "confidence_level": confidence_level,
                "simultaneous_scope": simultaneous_scope,
                "calibration_scale": "linear_predictor",
                "calibration": (
                    "whole_participant_bootstrap_max_standardized_deviation"
                ),
                "linear_predictor_standard_error": (
                    "gee_robust_delta_method"
                ),
                "mean_band_transformation": (
                    "strictly_monotone_inverse_link_endpoints"
                ),
                "observed_grid_only": True,
                "between_grid_coverage_claim": False,
                "profiles_fixed": True,
                "extrapolated_profiles_retained": True,
                "automatic_profile_selection": False,
            },
        },
    )

eyetrajectoriespy.generalized_function_on_scalar_mean_difference_band

generalized_function_on_scalar_mean_difference_band(bootstrap: GeneralizedFunctionOnScalarPredictionBootstrapResult, *, profile_a: str, profile_b: str, confidence_level: float = 0.95, contrast_scale: str | None = None) -> GeneralizedFunctionOnScalarMeanDifferenceResult

Construct one predeclared simultaneous marginal profile contrast.

Bernoulli fits support a probability difference. Poisson fits without exposure support an expected-count difference. Exposure-adjusted Poisson fits support an explicit rate difference, rate ratio, or expected-count difference when target exposure was supplied for count prediction.

Source code in src/eyetrajectoriespy/generalized_function_on_scalar_prediction.py
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
def generalized_function_on_scalar_mean_difference_band(
    bootstrap: GeneralizedFunctionOnScalarPredictionBootstrapResult,
    *,
    profile_a: str,
    profile_b: str,
    confidence_level: float = 0.95,
    contrast_scale: str | None = None,
) -> GeneralizedFunctionOnScalarMeanDifferenceResult:
    """Construct one predeclared simultaneous marginal profile contrast.

    Bernoulli fits support a probability difference. Poisson fits without
    exposure support an expected-count difference. Exposure-adjusted Poisson
    fits support an explicit rate difference, rate ratio, or expected-count
    difference when target exposure was supplied for count prediction.
    """

    if not isinstance(
        bootstrap,
        GeneralizedFunctionOnScalarPredictionBootstrapResult,
    ):
        raise TypeError(
            "bootstrap must be a "
            "GeneralizedFunctionOnScalarPredictionBootstrapResult"
        )
    if not isinstance(profile_a, str) or not profile_a:
        raise TypeError("profile_a must be a non-empty string")
    if not isinstance(profile_b, str) or not profile_b:
        raise TypeError("profile_b must be a non-empty string")
    if profile_a == profile_b:
        raise ValueError("profile_a and profile_b must be different")
    if not 0 < confidence_level < 1:
        raise ValueError("confidence_level must lie in (0, 1)")

    prediction = bootstrap.prediction
    family = prediction.reference.family
    has_exposure = prediction.reference.exposure is not None
    if family == "binomial":
        allowed = {"probability_difference"}
        selected_contrast = (
            "probability_difference"
            if contrast_scale is None
            else contrast_scale
        )
    elif has_exposure:
        allowed = {
            "rate_difference",
            "rate_ratio",
            "expected_count_difference",
        }
        selected_contrast = (
            "rate_difference" if contrast_scale is None else contrast_scale
        )
    else:
        allowed = {"expected_count_difference"}
        selected_contrast = (
            "expected_count_difference"
            if contrast_scale is None
            else contrast_scale
        )
    if selected_contrast not in allowed:
        raise ValueError(
            f"contrast_scale must be one of {sorted(allowed)} for this fit"
        )
    if (
        selected_contrast == "expected_count_difference"
        and prediction.expected_count_functions is None
    ):
        raise ValueError(
            "expected_count_difference requires predictions created with "
            "prediction_scale='expected_count' and explicit target exposure "
            "for exposure-adjusted fits"
        )

    lookup = {
        profile_id: index
        for index, profile_id in enumerate(prediction.profile_ids)
    }
    missing = [
        value
        for value in (profile_a, profile_b)
        if value not in lookup
    ]
    if missing:
        raise KeyError(f"Unknown profile identifier(s): {missing}")

    a = lookup[profile_a]
    b = lookup[profile_b]
    if selected_contrast == "rate_difference":
        estimate = prediction.rate_functions[a] - prediction.rate_functions[b]
        bootstrap_estimates = (
            bootstrap.bootstrap_rate_functions[:, a, :]
            - bootstrap.bootstrap_rate_functions[:, b, :]
        )
        inference_estimate = estimate
        inference_draws = bootstrap_estimates
        inference_scale = "rate_difference"
    elif selected_contrast == "rate_ratio":
        estimate = prediction.rate_functions[a] / prediction.rate_functions[b]
        bootstrap_estimates = (
            bootstrap.bootstrap_rate_functions[:, a, :]
            / bootstrap.bootstrap_rate_functions[:, b, :]
        )
        inference_estimate = np.log(estimate)
        inference_draws = np.log(bootstrap_estimates)
        inference_scale = "log_rate_ratio"
    elif selected_contrast == "expected_count_difference":
        estimate = (
            prediction.expected_count_functions[a]
            - prediction.expected_count_functions[b]
        )
        bootstrap_estimates = (
            bootstrap.bootstrap_expected_count_functions[:, a, :]
            - bootstrap.bootstrap_expected_count_functions[:, b, :]
        )
        inference_estimate = estimate
        inference_draws = bootstrap_estimates
        inference_scale = "expected_count_difference"
    else:
        estimate = prediction.mean_functions[a] - prediction.mean_functions[b]
        bootstrap_estimates = (
            bootstrap.bootstrap_mean_functions[:, a, :]
            - bootstrap.bootstrap_mean_functions[:, b, :]
        )
        inference_estimate = estimate
        inference_draws = bootstrap_estimates
        inference_scale = "probability_difference"

    standard_error = np.std(inference_draws, axis=0, ddof=1)
    if (
        np.any(~np.isfinite(standard_error))
        or np.any(standard_error <= np.finfo(float).eps)
    ):
        raise ValueError(
            "contrast simultaneous band requires strictly positive finite "
            "bootstrap standard errors at every observed time point"
        )

    standardized = np.abs(
        (inference_draws - inference_estimate[None, :])
        / standard_error[None, :]
    )
    max_statistics = np.max(standardized, axis=1)
    critical_value = float(
        np.quantile(
            max_statistics,
            confidence_level,
            method="higher",
        )
    )
    inference_lower = inference_estimate - critical_value * standard_error
    inference_upper = inference_estimate + critical_value * standard_error
    if selected_contrast == "rate_ratio":
        lower = np.exp(inference_lower)
        upper = np.exp(inference_upper)
    else:
        lower = inference_lower
        upper = inference_upper

    if selected_contrast == "probability_difference":
        physical_lower: float | None = -1.0
        physical_upper: float | None = 1.0
        exceeds = bool(
            np.any(lower < physical_lower)
            or np.any(upper > physical_upper)
        )
    else:
        physical_lower = None
        physical_upper = None
        exceeds = False

    extrapolated = bool(
        prediction.extrapolation_flags[a]
        or prediction.extrapolation_flags[b]
    )
    return GeneralizedFunctionOnScalarMeanDifferenceResult(
        prediction_bootstrap=bootstrap,
        profile_a=profile_a,
        profile_b=profile_b,
        estimate=estimate,
        standard_error=standard_error,
        lower=lower,
        upper=upper,
        bootstrap_estimates=bootstrap_estimates,
        max_statistics=max_statistics,
        critical_value=critical_value,
        confidence_level=confidence_level,
        physical_lower_bound=physical_lower,
        physical_upper_bound=physical_upper,
        interval_exceeds_physical_bounds=exceeds,
        contrast_scale=selected_contrast,
        inference_scale=inference_scale,
        provenance={
            **dict(bootstrap.provenance),
            "generalized_function_on_scalar_mean_difference_band": {
                "contrast": f"{profile_a} - {profile_b}",
                "response_scale": selected_contrast,
                "inference_scale": inference_scale,
                "rate_ratio_band_calibrated_on_log_scale": (
                    selected_contrast == "rate_ratio"
                ),
                "profiles_fixed": True,
                "profile_pair_predeclared": True,
                "profile_pair_extrapolated": extrapolated,
                "standard_error": "participant_bootstrap_standard_deviation",
                "calibration": (
                    "observed_grid_max_standardized_bootstrap_deviation"
                ),
                "confidence_level": confidence_level,
                "physical_bounds_clipped": False,
                "interval_exceeds_physical_bounds": exceeds,
                "multiple_contrast_family_adjustment": False,
                "automatic_contrast_selection": False,
            },
        },
    )

eyetrajectoriespy.generalized_function_on_scalar_prediction_frame

generalized_function_on_scalar_prediction_frame(result: GeneralizedFunctionOnScalarPredictionResult | GeneralizedFunctionOnScalarPredictionBandResult) -> pd.DataFrame

Return one row per fixed profile and observed time point.

Source code in src/eyetrajectoriespy/generalized_function_on_scalar_prediction.py
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
def generalized_function_on_scalar_prediction_frame(
    result: (
        GeneralizedFunctionOnScalarPredictionResult
        | GeneralizedFunctionOnScalarPredictionBandResult
    ),
) -> pd.DataFrame:
    """Return one row per fixed profile and observed time point."""

    if isinstance(result, GeneralizedFunctionOnScalarPredictionBandResult):
        prediction = result.prediction
        band = result
    elif isinstance(result, GeneralizedFunctionOnScalarPredictionResult):
        prediction = result
        band = None
    else:
        raise TypeError(
            "result must be a generalized FoSR prediction or prediction band"
        )

    rows: list[dict[str, object]] = []
    for profile_index, profile_id in enumerate(prediction.profile_ids):
        for time_index, time_value in enumerate(
            prediction.reference.time
        ):
            row: dict[str, object] = {
                "profile_id": profile_id,
                "time": float(time_value),
                "linear_predictor": float(
                    prediction.linear_predictor_functions[
                        profile_index,
                        time_index,
                    ]
                ),
                "linear_predictor_standard_error": float(
                    prediction.linear_predictor_standard_errors[
                        profile_index,
                        time_index,
                    ]
                ),
                "mean": float(
                    prediction.mean_functions[
                        profile_index,
                        time_index,
                    ]
                ),
                "mean_standard_error": float(
                    prediction.mean_standard_errors[
                        profile_index,
                        time_index,
                    ]
                ),
                "family": prediction.reference.family,
                "link": prediction.reference.link,
                "prediction_scale": prediction.prediction_scale,
                "rate": (
                    None
                    if prediction.rate_functions is None
                    else float(
                        prediction.rate_functions[
                            profile_index, time_index
                        ]
                    )
                ),
                "expected_count": (
                    None
                    if prediction.expected_count_functions is None
                    else float(
                        prediction.expected_count_functions[
                            profile_index, time_index
                        ]
                    )
                ),
                "exposure": (
                    None
                    if prediction.exposure_profiles is None
                    else float(
                        prediction.exposure_profiles[
                            profile_index, time_index
                        ]
                    )
                ),
                "extrapolation": bool(
                    prediction.extrapolation_flags[profile_index]
                ),
            }
            if band is not None:
                row.update(
                    {
                        "linear_lower": float(
                            band.linear_lower[
                                profile_index,
                                time_index,
                            ]
                        ),
                        "linear_upper": float(
                            band.linear_upper[
                                profile_index,
                                time_index,
                            ]
                        ),
                        "mean_lower": float(
                            band.mean_lower[
                                profile_index,
                                time_index,
                            ]
                        ),
                        "mean_upper": float(
                            band.mean_upper[
                                profile_index,
                                time_index,
                            ]
                        ),
                        "critical_value": float(
                            band.critical_values[profile_index]
                        ),
                    }
                )
            rows.append(row)
    return pd.DataFrame(rows)

eyetrajectoriespy.generalized_function_on_scalar_mean_difference_frame

generalized_function_on_scalar_mean_difference_frame(result: GeneralizedFunctionOnScalarMeanDifferenceResult) -> pd.DataFrame

Return the observed-grid response-scale mean-difference band.

Source code in src/eyetrajectoriespy/generalized_function_on_scalar_prediction.py
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
def generalized_function_on_scalar_mean_difference_frame(
    result: GeneralizedFunctionOnScalarMeanDifferenceResult,
) -> pd.DataFrame:
    """Return the observed-grid response-scale mean-difference band."""

    if not isinstance(
        result,
        GeneralizedFunctionOnScalarMeanDifferenceResult,
    ):
        raise TypeError(
            "result must be a GeneralizedFunctionOnScalarMeanDifferenceResult"
        )
    time = result.prediction_bootstrap.prediction.reference.time
    return pd.DataFrame(
        {
            "time": np.asarray(time, dtype=float),
            "estimate": result.estimate,
            "standard_error": result.standard_error,
            "lower": result.lower,
            "upper": result.upper,
            "profile_a": result.profile_a,
            "profile_b": result.profile_b,
            "critical_value": result.critical_value,
            "contrast_scale": result.contrast_scale,
            "inference_scale": result.inference_scale,
            "interval_exceeds_physical_bounds": (
                result.interval_exceeds_physical_bounds
            ),
        }
    )

eyetrajectoriespy.plot_generalized_function_on_scalar_predictions

plot_generalized_function_on_scalar_predictions(result: GeneralizedFunctionOnScalarPredictionResult | GeneralizedFunctionOnScalarPredictionBandResult, *, ax=None)

Plot fixed-profile marginal mean functions and optional bands.

Source code in src/eyetrajectoriespy/generalized_function_on_scalar_prediction.py
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
def plot_generalized_function_on_scalar_predictions(
    result: (
        GeneralizedFunctionOnScalarPredictionResult
        | GeneralizedFunctionOnScalarPredictionBandResult
    ),
    *,
    ax=None,
):
    """Plot fixed-profile marginal mean functions and optional bands."""

    import matplotlib.pyplot as plt

    if isinstance(result, GeneralizedFunctionOnScalarPredictionBandResult):
        prediction = result.prediction
        band = result
    elif isinstance(result, GeneralizedFunctionOnScalarPredictionResult):
        prediction = result
        band = None
    else:
        raise TypeError(
            "result must be a generalized FoSR prediction or prediction band"
        )

    if ax is None:
        _, ax = plt.subplots()
    for index, profile_id in enumerate(prediction.profile_ids):
        label = (
            f"{profile_id} (extrapolation)"
            if prediction.extrapolation_flags[index]
            else profile_id
        )
        ax.plot(
            prediction.reference.time,
            prediction.mean_functions[index],
            label=label,
        )
        if band is not None:
            ax.fill_between(
                prediction.reference.time,
                band.mean_lower[index],
                band.mean_upper[index],
                alpha=0.2,
            )
    ax.set_xlabel(
        f"Time ({prediction.reference.time_unit})"
    )
    if prediction.reference.family == "binomial":
        ylabel = "Marginal probability"
    elif prediction.prediction_scale == "rate":
        ylabel = "Marginal exposure-adjusted rate"
    else:
        ylabel = "Marginal expected count"
    ax.set_ylabel(ylabel)
    ax.set_title("Generalized FoSR fixed-profile marginal predictions")
    ax.legend()
    return ax

eyetrajectoriespy.plot_generalized_function_on_scalar_mean_difference

plot_generalized_function_on_scalar_mean_difference(result: GeneralizedFunctionOnScalarMeanDifferenceResult, *, ax=None)

Plot one predeclared response-scale profile contrast band.

Source code in src/eyetrajectoriespy/generalized_function_on_scalar_prediction.py
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
def plot_generalized_function_on_scalar_mean_difference(
    result: GeneralizedFunctionOnScalarMeanDifferenceResult,
    *,
    ax=None,
):
    """Plot one predeclared response-scale profile contrast band."""

    import matplotlib.pyplot as plt

    if not isinstance(
        result,
        GeneralizedFunctionOnScalarMeanDifferenceResult,
    ):
        raise TypeError(
            "result must be a GeneralizedFunctionOnScalarMeanDifferenceResult"
        )
    if ax is None:
        _, ax = plt.subplots()
    time = result.prediction_bootstrap.prediction.reference.time
    ax.plot(time, result.estimate, label="estimate")
    ax.fill_between(
        time,
        result.lower,
        result.upper,
        alpha=0.2,
        label=(
            f"{100 * result.confidence_level:.0f}% simultaneous band"
        ),
    )
    ax.axhline(
        1.0 if result.contrast_scale == "rate_ratio" else 0.0,
        linewidth=1.0,
    )
    ax.set_xlabel(
        "Time "
        f"({result.prediction_bootstrap.prediction.reference.time_unit})"
    )
    ylabel = {
        "probability_difference": "Marginal probability difference",
        "rate_difference": "Marginal rate difference",
        "rate_ratio": "Marginal rate ratio",
        "expected_count_difference": "Marginal expected-count difference",
    }[result.contrast_scale]
    ax.set_ylabel(ylabel)
    ax.set_title(
        f"Generalized FoSR {result.contrast_scale.replace('_', ' ')}: "
        f"{result.profile_a} - {result.profile_b}"
    )
    ax.legend()
    return ax

eyetrajectoriespy.generalized_function_on_scalar_prediction_reporting_text

generalized_function_on_scalar_prediction_reporting_text(result: GeneralizedFunctionOnScalarPredictionBandResult) -> str

Return manuscript-oriented wording for fixed-profile prediction.

Source code in src/eyetrajectoriespy/generalized_function_on_scalar_prediction.py
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
def generalized_function_on_scalar_prediction_reporting_text(
    result: GeneralizedFunctionOnScalarPredictionBandResult,
) -> str:
    """Return manuscript-oriented wording for fixed-profile prediction."""

    if not isinstance(
        result,
        GeneralizedFunctionOnScalarPredictionBandResult,
    ):
        raise TypeError(
            "result must be a GeneralizedFunctionOnScalarPredictionBandResult"
        )
    prediction = result.prediction
    extrapolated = [
        profile_id
        for profile_id, flag in zip(
            prediction.profile_ids,
            prediction.extrapolation_flags,
            strict=True,
        )
        if flag
    ]
    extrapolation_text = (
        " No declared profile lay outside the observed scalar predictor "
        "ranges."
        if not extrapolated
        else (
            " The following fixed profiles were retained but flagged as "
            "scalar-predictor extrapolations: "
            + ", ".join(extrapolated)
            + "."
        )
    )
    scale = {
        "probability": "marginal probability",
        "rate": "marginal exposure-adjusted rate",
        "expected_count": "marginal expected count",
    }[prediction.prediction_scale]
    grouped_text = (
        " The fitted binomial model used explicit grouped success counts and "
        "denominators; fixed-profile prediction targets success probability "
        "and does not require or infer a target denominator."
        if prediction.reference.binomial_denominators is not None
        else ""
    )
    return (
        f"Fixed-profile {scale} functions were derived from the fitted "
        f"{prediction.reference.family}/{prediction.reference.link} marginal "
        "generalized function-on-scalar model. Profile values were treated as "
        "fixed scientific targets and were not resampled. Simultaneous bands "
        "were calibrated on the linear-predictor scale using the retained "
        "whole-participant coefficient bootstrap and then transformed through "
        "the strictly monotone inverse link. The simultaneous scope was "
        f"{result.simultaneous_scope!r} over the observed grid; no "
        "between-grid coverage is claimed and no profile was selected "
        "automatically."
        + grouped_text
        + extrapolation_text
    )

eyetrajectoriespy.generalized_function_on_scalar_mean_difference_reporting_text

generalized_function_on_scalar_mean_difference_reporting_text(result: GeneralizedFunctionOnScalarMeanDifferenceResult) -> str

Return manuscript wording for one predeclared profile contrast band.

Source code in src/eyetrajectoriespy/generalized_function_on_scalar_prediction.py
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
def generalized_function_on_scalar_mean_difference_reporting_text(
    result: GeneralizedFunctionOnScalarMeanDifferenceResult,
) -> str:
    """Return manuscript wording for one predeclared profile contrast band."""

    if not isinstance(
        result,
        GeneralizedFunctionOnScalarMeanDifferenceResult,
    ):
        raise TypeError(
            "result must be a GeneralizedFunctionOnScalarMeanDifferenceResult"
        )
    measure = {
        "probability_difference": "marginal probability difference",
        "rate_difference": "marginal exposure-adjusted rate difference",
        "rate_ratio": "marginal exposure-adjusted rate ratio",
        "expected_count_difference": "marginal expected-count difference",
    }[result.contrast_scale]
    bounds_text = ""
    if result.interval_exceeds_physical_bounds:
        bounds_text = (
            " The untrimmed simultaneous band extended beyond the logical "
            "[-1, 1] range of a probability difference; bounds were retained "
            "rather than silently clipped."
        )
    return (
        f"One predeclared {measure} function was evaluated as "
        f"{result.profile_a!r} minus {result.profile_b!r}. The paired "
        "whole-participant bootstrap coefficient draws were propagated through "
        "both fixed profiles, preserving their dependence, and an observed-grid "
        "maximum standardized-deviation band was calibrated at "
        f"{100 * result.confidence_level:.1f}%. The profile pair was not "
        "selected from the bootstrap results and no multiple-contrast family "
        "adjustment is claimed."
        + (
            " The simultaneous interval was calibrated on the log-rate-ratio "
            "scale and exponentiated, preserving positivity."
            if result.contrast_scale == "rate_ratio"
            else ""
        )
        + bounds_text
    )

Downstream analysis

eyetrajectoriespy.functional_l2_distance

functional_l2_distance(a: ndarray, b: ndarray, *, time: ndarray, dimension_weights: ndarray | None = None) -> float

Integrated L2 distance between two complete multivariate functions.

Source code in src/eyetrajectoriespy/analysis.py
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
def functional_l2_distance(
    a: np.ndarray,
    b: np.ndarray,
    *,
    time: np.ndarray,
    dimension_weights: np.ndarray | None = None,
) -> float:
    """Integrated L2 distance between two complete multivariate functions."""
    a = np.asarray(a, dtype=float)
    b = np.asarray(b, dtype=float)
    if a.shape != b.shape or a.ndim != 2:
        raise ValueError("a and b must have the same shape (n_time, n_dimensions)")
    if np.isnan(a).any() or np.isnan(b).any():
        raise ValueError("functional_l2_distance requires complete trajectories")
    weights = functional_trapezoid_weights(time)
    if dimension_weights is None:
        dw = np.ones(a.shape[1])
    else:
        dw = np.asarray(dimension_weights, dtype=float)
        if dw.shape != (a.shape[1],) or np.any(dw < 0):
            raise ValueError("dimension_weights must be non-negative with one value per dimension")
    squared = (a - b) ** 2 * dw[None, :]
    return float(np.sqrt(np.sum(squared * weights[:, None])))

eyetrajectoriespy.pairwise_functional_distances

pairwise_functional_distances(trajectories: TrajectorySet, *, dimension_weights: ndarray | None = None) -> np.ndarray

Pairwise integrated L2 distance matrix for complete trajectories.

Source code in src/eyetrajectoriespy/analysis.py
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
def pairwise_functional_distances(
    trajectories: TrajectorySet,
    *,
    dimension_weights: np.ndarray | None = None,
) -> np.ndarray:
    """Pairwise integrated L2 distance matrix for complete trajectories."""
    validate_trajectory_set(trajectories, require_complete=True)
    n = trajectories.n_curves
    result = np.zeros((n, n), dtype=float)
    for i in range(n):
        for j in range(i + 1, n):
            d = functional_l2_distance(
                trajectories.values[i],
                trajectories.values[j],
                time=trajectories.time,
                dimension_weights=dimension_weights,
            )
            result[i, j] = result[j, i] = d
    return result

eyetrajectoriespy.discrete_frechet_distance

discrete_frechet_distance(a: ndarray, b: ndarray, *, dimension_weights: ndarray | None = None, return_coupling: bool = False) -> float | DiscreteFrechetResult

Compute discrete Fréchet distance between ordered point sequences.

The coupling is monotone in both sequence indices. Elapsed time is not used. No interpolation, resampling, coordinate normalization, or path simplification is performed.

With return_coupling=True, one deterministic optimal coupling is returned. Multiple optimal couplings can exist; ties prefer a diagonal predecessor, then advancing a, then advancing b.

Source code in src/eyetrajectoriespy/analysis.py
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
def discrete_frechet_distance(
    a: np.ndarray,
    b: np.ndarray,
    *,
    dimension_weights: np.ndarray | None = None,
    return_coupling: bool = False,
) -> float | DiscreteFrechetResult:
    """Compute discrete Fréchet distance between ordered point sequences.

    The coupling is monotone in both sequence indices. Elapsed time is not
    used. No interpolation, resampling, coordinate normalization, or path
    simplification is performed.

    With return_coupling=True, one deterministic optimal coupling is returned.
    Multiple optimal couplings can exist; ties prefer a diagonal predecessor,
    then advancing a, then advancing b.
    """

    if not isinstance(return_coupling, (bool, np.bool_)):
        raise TypeError("return_coupling must be boolean")
    a_arr, b_arr, weights = _validate_discrete_frechet_inputs(
        a, b, dimension_weights=dimension_weights
    )
    n_a, n_b = a_arr.shape[0], b_arr.shape[0]
    local = np.sqrt(
        np.sum(
            (a_arr[:, None, :] - b_arr[None, :, :]) ** 2
            * weights[None, None, :],
            axis=2,
        )
    )

    cumulative = np.empty((n_a, n_b), dtype=float)
    predecessor = np.full((n_a, n_b, 2), -1, dtype=int)
    cumulative[0, 0] = local[0, 0]

    for i in range(1, n_a):
        cumulative[i, 0] = max(cumulative[i - 1, 0], local[i, 0])
        predecessor[i, 0] = (i - 1, 0)
    for j in range(1, n_b):
        cumulative[0, j] = max(cumulative[0, j - 1], local[0, j])
        predecessor[0, j] = (0, j - 1)

    for i in range(1, n_a):
        for j in range(1, n_b):
            candidates = (
                (cumulative[i - 1, j - 1], i - 1, j - 1),
                (cumulative[i - 1, j], i - 1, j),
                (cumulative[i, j - 1], i, j - 1),
            )
            previous, prev_i, prev_j = min(candidates, key=lambda item: item[0])
            cumulative[i, j] = max(local[i, j], previous)
            predecessor[i, j] = (prev_i, prev_j)

    distance = float(cumulative[-1, -1])
    if not return_coupling:
        return distance

    path: list[tuple[int, int]] = []
    i, j = n_a - 1, n_b - 1
    while True:
        path.append((i, j))
        if i == 0 and j == 0:
            break
        i, j = predecessor[i, j]
    path.reverse()
    coupling = np.asarray(path, dtype=int)
    coupled_local = local[coupling[:, 0], coupling[:, 1]]
    return DiscreteFrechetResult(
        distance=distance,
        coupling=coupling,
        local_distances=coupled_local,
        n_points_a=n_a,
        n_points_b=n_b,
        n_dimensions=a_arr.shape[1],
        provenance={
            "operation": "discrete_frechet_distance",
            "local_metric": "weighted_euclidean",
            "dimension_weights": weights.tolist(),
            "continuous_frechet": False,
            "elapsed_time_used": False,
            "sample_order_preserved": True,
            "backtracking_allowed": False,
            "interpolation": False,
            "resampling": False,
            "coordinate_normalization": False,
            "path_simplification": False,
            "tie_break_order": ("diagonal", "advance_a", "advance_b"),
            "optimal_coupling_not_necessarily_unique": True,
        },
    )

eyetrajectoriespy.pairwise_discrete_frechet_distances

pairwise_discrete_frechet_distances(trajectories: TrajectorySet, *, dimensions: tuple[str, ...] | list[str] | None = None, dimension_weights: ndarray | None = None) -> np.ndarray

Pairwise discrete Fréchet distances for complete trajectories.

dimensions=None uses every stored functional dimension in its current order. No time values are passed to the Fréchet recurrence.

Source code in src/eyetrajectoriespy/analysis.py
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
def pairwise_discrete_frechet_distances(
    trajectories: TrajectorySet,
    *,
    dimensions: tuple[str, ...] | list[str] | None = None,
    dimension_weights: np.ndarray | None = None,
) -> np.ndarray:
    """Pairwise discrete Fréchet distances for complete trajectories.

    dimensions=None uses every stored functional dimension in its current
    order. No time values are passed to the Fréchet recurrence.
    """

    validate_trajectory_set(trajectories, require_complete=True)
    if dimensions is None:
        selected = trajectories.dimension_names
    else:
        if isinstance(dimensions, (str, bytes)):
            raise TypeError("dimensions must be a non-string sequence")
        selected = tuple(dimensions)
        if not selected:
            raise ValueError("dimensions must contain at least one dimension")
        if len(set(selected)) != len(selected):
            raise ValueError("dimensions must not contain duplicates")
        missing = [
            name for name in selected if name not in trajectories.dimension_names
        ]
        if missing:
            raise KeyError(f"Unknown trajectory dimensions: {missing}")

    indices = [trajectories.dimension_names.index(name) for name in selected]
    values = trajectories.values[:, :, indices]
    n = trajectories.n_curves
    if n > 0:
        _validate_discrete_frechet_inputs(
            values[0],
            values[0],
            dimension_weights=dimension_weights,
        )
    result = np.zeros((n, n), dtype=float)
    for i in range(n):
        for j in range(i + 1, n):
            distance = discrete_frechet_distance(
                values[i], values[j], dimension_weights=dimension_weights
            )
            result[i, j] = result[j, i] = float(distance)
    return result

eyetrajectoriespy.dynamic_time_warping_distance

dynamic_time_warping_distance(a: ndarray, b: ndarray, *, dimension_weights: ndarray | None = None, window_radius: int | None = None, step_pattern: str = 'symmetric1', normalize: bool = False, return_path: bool = False) -> float | DynamicTimeWarpingResult

Compute DTW using explicit symmetric1 or symmetric2 step weighting.

symmetric1 preserves the 0.33 contract: every visited local distance contributes once and the resulting cumulative cost is not normalizable by a path-independent length denominator.

symmetric2 gives diagonal moves weight two and horizontal/vertical moves weight one. Its cumulative cost can be normalized by n_a + n_b. Set normalize=True to return that normalized value.

window_radius is an optional Sakoe-Chiba band in sample-index units. Recorded timestamps are not used. No interpolation, resampling, smoothing, coordinate normalization, path simplification, missing-value deletion, or automatic step-pattern/window selection is performed.

Source code in src/eyetrajectoriespy/analysis.py
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
def dynamic_time_warping_distance(
    a: np.ndarray,
    b: np.ndarray,
    *,
    dimension_weights: np.ndarray | None = None,
    window_radius: int | None = None,
    step_pattern: str = "symmetric1",
    normalize: bool = False,
    return_path: bool = False,
) -> float | DynamicTimeWarpingResult:
    """Compute DTW using explicit symmetric1 or symmetric2 step weighting.

    symmetric1 preserves the 0.33 contract: every visited local distance
    contributes once and the resulting cumulative cost is not normalizable by
    a path-independent length denominator.

    symmetric2 gives diagonal moves weight two and horizontal/vertical
    moves weight one. Its cumulative cost can be normalized by n_a + n_b.
    Set normalize=True to return that normalized value.

    window_radius is an optional Sakoe-Chiba band in sample-index units.
    Recorded timestamps are not used. No interpolation, resampling, smoothing,
    coordinate normalization, path simplification, missing-value deletion, or
    automatic step-pattern/window selection is performed.
    """

    if not isinstance(return_path, (bool, np.bool_)):
        raise TypeError("return_path must be boolean")
    resolved_pattern, normalize_bool = _validate_dynamic_time_warping_options(
        step_pattern=step_pattern,
        normalize=normalize,
    )

    a_arr, b_arr, weights, resolved_window = _validate_dynamic_time_warping_inputs(
        a,
        b,
        dimension_weights=dimension_weights,
        window_radius=window_radius,
    )
    n_a, n_b = a_arr.shape[0], b_arr.shape[0]
    local = np.sqrt(
        np.sum(
            (a_arr[:, None, :] - b_arr[None, :, :]) ** 2
            * weights[None, None, :],
            axis=2,
        )
    )

    cumulative = np.full((n_a, n_b), np.inf, dtype=float)
    predecessor = np.full((n_a, n_b, 2), -1, dtype=int)
    transition_weight = np.full((n_a, n_b), np.nan, dtype=float)

    initial_weight = 2.0 if resolved_pattern == "symmetric2" else 1.0
    cumulative[0, 0] = initial_weight * local[0, 0]
    transition_weight[0, 0] = initial_weight

    for i in range(n_a):
        if resolved_window is None:
            j_start, j_stop = 0, n_b
        else:
            j_start = max(0, i - resolved_window)
            j_stop = min(n_b, i + resolved_window + 1)

        for j in range(j_start, j_stop):
            if i == 0 and j == 0:
                continue

            candidates: list[tuple[float, int, int, float]] = []
            if i > 0 and j > 0 and np.isfinite(cumulative[i - 1, j - 1]):
                step_weight = 2.0 if resolved_pattern == "symmetric2" else 1.0
                candidates.append(
                    (
                        cumulative[i - 1, j - 1] + step_weight * local[i, j],
                        i - 1,
                        j - 1,
                        step_weight,
                    )
                )
            if i > 0 and np.isfinite(cumulative[i - 1, j]):
                candidates.append(
                    (
                        cumulative[i - 1, j] + local[i, j],
                        i - 1,
                        j,
                        1.0,
                    )
                )
            if j > 0 and np.isfinite(cumulative[i, j - 1]):
                candidates.append(
                    (
                        cumulative[i, j - 1] + local[i, j],
                        i,
                        j - 1,
                        1.0,
                    )
                )
            if not candidates:
                continue

            total, prev_i, prev_j, step_weight = min(
                candidates,
                key=lambda item: item[0],
            )
            cumulative[i, j] = total
            predecessor[i, j] = (prev_i, prev_j)
            transition_weight[i, j] = step_weight

    raw_distance = float(cumulative[-1, -1])
    if not np.isfinite(raw_distance):
        raise ValueError(
            "No admissible DTW path reaches the endpoint under window_radius"
        )

    normalization_denominator = (
        float(n_a + n_b) if resolved_pattern == "symmetric2" else None
    )
    normalized_distance = (
        raw_distance / normalization_denominator
        if normalization_denominator is not None
        else None
    )
    distance = (
        float(normalized_distance)
        if normalize_bool and normalized_distance is not None
        else raw_distance
    )
    if not return_path:
        return distance

    path: list[tuple[int, int]] = []
    path_weights: list[float] = []
    i, j = n_a - 1, n_b - 1
    while True:
        path.append((i, j))
        path_weights.append(float(transition_weight[i, j]))
        if i == 0 and j == 0:
            break
        prev_i, prev_j = predecessor[i, j]
        if prev_i < 0 or prev_j < 0:
            raise RuntimeError("DTW predecessor chain is incomplete")
        i, j = int(prev_i), int(prev_j)
    path.reverse()
    path_weights.reverse()

    alignment_path = np.asarray(path, dtype=int)
    step_weights = np.asarray(path_weights, dtype=float)
    aligned_local = local[alignment_path[:, 0], alignment_path[:, 1]]
    weighted_local = aligned_local * step_weights
    if not np.isclose(np.sum(weighted_local), raw_distance):
        raise RuntimeError("DTW path audit does not reproduce the cumulative cost")

    return DynamicTimeWarpingResult(
        distance=distance,
        path=alignment_path,
        local_distances=aligned_local,
        path_length=len(path),
        mean_local_distance=float(np.mean(aligned_local)),
        n_points_a=n_a,
        n_points_b=n_b,
        n_dimensions=a_arr.shape[1],
        window_radius=resolved_window,
        raw_distance=raw_distance,
        normalized_distance=normalized_distance,
        step_pattern=resolved_pattern,
        normalization_denominator=normalization_denominator,
        step_weights=step_weights,
        weighted_local_costs=weighted_local,
        provenance={
            "operation": "dynamic_time_warping_distance",
            "local_metric": "weighted_euclidean",
            "dimension_weights": weights.tolist(),
            "distance_aggregation": "weighted_sum",
            "step_pattern": resolved_pattern,
            "normalizable": resolved_pattern == "symmetric2",
            "normalization_requested": normalize_bool,
            "normalization_denominator": normalization_denominator,
            "distance_returned": (
                "normalized" if normalize_bool else "raw_cumulative"
            ),
            "recorded_time_used": False,
            "sequence_index_warping": True,
            "sample_order_preserved": True,
            "backtracking_allowed": False,
            "window_constraint": (
                "unconstrained"
                if resolved_window is None
                else "sakoe_chiba_index_band"
            ),
            "window_radius": resolved_window,
            "interpolation": False,
            "resampling": False,
            "smoothing": False,
            "coordinate_normalization": False,
            "path_simplification": False,
            "missing_value_deletion": False,
            "automatic_step_pattern_selection": False,
            "automatic_window_selection": False,
            "tie_break_order": ("diagonal", "advance_a", "advance_b"),
            "optimal_path_not_necessarily_unique": True,
        },
    )

eyetrajectoriespy.pairwise_dynamic_time_warping_distances

pairwise_dynamic_time_warping_distances(trajectories: TrajectorySet, *, dimensions: tuple[str, ...] | list[str] | None = None, dimension_weights: ndarray | None = None, window_radius: int | None = None, step_pattern: str = 'symmetric1', normalize: bool = False) -> np.ndarray

Pairwise DTW distances for complete trajectories.

The TrajectorySet time grid is not passed into the recurrence. window_radius constrains sample-index displacement, not physical time. The 0.33 symmetric1 raw-cost behavior remains the default.

Source code in src/eyetrajectoriespy/analysis.py
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
def pairwise_dynamic_time_warping_distances(
    trajectories: TrajectorySet,
    *,
    dimensions: tuple[str, ...] | list[str] | None = None,
    dimension_weights: np.ndarray | None = None,
    window_radius: int | None = None,
    step_pattern: str = "symmetric1",
    normalize: bool = False,
) -> np.ndarray:
    """Pairwise DTW distances for complete trajectories.

    The TrajectorySet time grid is not passed into the recurrence.
    window_radius constrains sample-index displacement, not physical time.
    The 0.33 symmetric1 raw-cost behavior remains the default.
    """

    resolved_pattern, normalize_bool = _validate_dynamic_time_warping_options(
        step_pattern=step_pattern,
        normalize=normalize,
    )
    validate_trajectory_set(trajectories, require_complete=True)
    if dimensions is None:
        selected = trajectories.dimension_names
    else:
        if isinstance(dimensions, (str, bytes)):
            raise TypeError("dimensions must be a non-string sequence")
        selected = tuple(dimensions)
        if not selected:
            raise ValueError("dimensions must contain at least one dimension")
        if len(set(selected)) != len(selected):
            raise ValueError("dimensions must not contain duplicates")
        missing = [
            name for name in selected if name not in trajectories.dimension_names
        ]
        if missing:
            raise KeyError(f"Unknown trajectory dimensions: {missing}")

    indices = [trajectories.dimension_names.index(name) for name in selected]
    values = trajectories.values[:, :, indices]
    n = trajectories.n_curves
    if n > 0:
        _validate_dynamic_time_warping_inputs(
            values[0],
            values[0],
            dimension_weights=dimension_weights,
            window_radius=window_radius,
        )

    result = np.zeros((n, n), dtype=float)
    for i in range(n):
        for j in range(i + 1, n):
            distance = dynamic_time_warping_distance(
                values[i],
                values[j],
                dimension_weights=dimension_weights,
                window_radius=window_radius,
                step_pattern=resolved_pattern,
                normalize=normalize_bool,
            )
            result[i, j] = result[j, i] = float(distance)
    return result

eyetrajectoriespy.cluster_fpca_scores

cluster_fpca_scores(fpca: FPCAResult, *, n_clusters: int, n_components: int | None = None, random_state: int = 0, n_init: int | str = 'auto') -> ClusterResult

Cluster curves using a deterministic K-means fit to retained FPCA scores.

Source code in src/eyetrajectoriespy/analysis.py
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
def cluster_fpca_scores(
    fpca: FPCAResult,
    *,
    n_clusters: int,
    n_components: int | None = None,
    random_state: int = 0,
    n_init: int | str = "auto",
) -> ClusterResult:
    """Cluster curves using a deterministic K-means fit to retained FPCA scores."""
    if n_clusters < 2 or n_clusters > len(fpca.curve_ids):
        raise ValueError("n_clusters must be between 2 and number of trajectories")
    if n_components is None:
        n_components = fpca.n_components
    if n_components < 1 or n_components > fpca.n_components:
        raise ValueError("n_components is outside the fitted FPCA range")
    model = KMeans(n_clusters=n_clusters, random_state=random_state, n_init=n_init)
    labels = model.fit_predict(fpca.scores[:, :n_components])
    return ClusterResult(
        labels=labels,
        centers=model.cluster_centers_,
        method="kmeans_fpca_scores",
        model=model,
        provenance={"n_components": n_components, "n_clusters": n_clusters, "random_state": random_state},
    )

eyetrajectoriespy.fit_scalar_on_function_regression

fit_scalar_on_function_regression(fpca: FPCAResult, outcome: ndarray | Series, *, n_components: int | None = None, family: str = 'gaussian', covariates: DataFrame | None = None) -> FunctionalRegressionResult

Approximate scalar-on-function regression through FPCA score predictors.

Source code in src/eyetrajectoriespy/analysis.py
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
def fit_scalar_on_function_regression(
    fpca: FPCAResult,
    outcome: np.ndarray | pd.Series,
    *,
    n_components: int | None = None,
    family: str = "gaussian",
    covariates: pd.DataFrame | None = None,
) -> FunctionalRegressionResult:
    """Approximate scalar-on-function regression through FPCA score predictors."""
    y = np.asarray(outcome, dtype=float)
    if y.shape != (len(fpca.curve_ids),):
        raise ValueError("outcome must contain exactly one value per trajectory")
    if np.isnan(y).any():
        raise ValueError("outcome contains missing values; handle them explicitly")
    if n_components is None:
        n_components = fpca.n_components
    if n_components < 1 or n_components > fpca.n_components:
        raise ValueError("n_components is outside the fitted range")

    x = pd.DataFrame(fpca.scores[:, :n_components], columns=[f"FPC{i + 1}" for i in range(n_components)])
    if covariates is not None:
        if len(covariates) != len(x):
            raise ValueError("covariates must contain one row per trajectory")
        if covariates.isna().any().any():
            raise ValueError("covariates contain missing values")
        x = pd.concat([x, covariates.reset_index(drop=True)], axis=1)
    x = sm.add_constant(x, has_constant="add")

    if family == "gaussian":
        model = sm.OLS(y, x).fit()
    elif family == "binomial":
        if not set(np.unique(y)) <= {0.0, 1.0}:
            raise ValueError("binomial outcome must contain only 0/1 values")
        model = sm.GLM(y, x, family=sm.families.Binomial()).fit()
    else:
        raise ValueError("family must be 'gaussian' or 'binomial'")

    return FunctionalRegressionResult(
        model=model,
        component_indices=tuple(range(n_components)),
        coefficients=pd.Series(model.params, index=x.columns),
        predictions=np.asarray(model.predict(x)),
        family=family,
        provenance={
            "method": "scalar_on_function_via_fpca_scores",
            "n_components": n_components,
            "family": family,
            "covariates": [] if covariates is None else list(covariates.columns),
        },
    )

Plotting and reporting

eyetrajectoriespy.plot_planar_trajectories

plot_planar_trajectories(trajectories: TrajectorySet, *, max_curves: int | None = 40, alpha: float = 0.35, invert_y: bool = True, ax=None)

Plot continuous x/y gaze paths in screen space.

Source code in src/eyetrajectoriespy/plotting.py
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
def plot_planar_trajectories(
    trajectories: TrajectorySet,
    *,
    max_curves: int | None = 40,
    alpha: float = 0.35,
    invert_y: bool = True,
    ax=None,
):
    """Plot continuous x/y gaze paths in screen space."""
    if trajectories.n_dimensions < 2:
        raise ValueError("Planar plotting requires at least two functional dimensions")
    if ax is None:
        _, ax = plt.subplots()
    n = trajectories.n_curves if max_curves is None else min(max_curves, trajectories.n_curves)
    for i in range(n):
        ax.plot(trajectories.values[i, :, 0], trajectories.values[i, :, 1], alpha=alpha)
    ax.set_xlabel(trajectories.dimension_names[0])
    ax.set_ylabel(trajectories.dimension_names[1])
    ax.set_title(f"Planar gaze trajectories (n={n})")
    ax.set_aspect("equal", adjustable="box")
    if invert_y:
        ax.invert_yaxis()
    return ax

eyetrajectoriespy.plot_dynamic_time_warping_alignment

plot_dynamic_time_warping_alignment(result: DynamicTimeWarpingResult, *, ax=None)

Plot one audited DTW alignment path in sample-index space.

Source code in src/eyetrajectoriespy/plotting.py
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
def plot_dynamic_time_warping_alignment(
    result: DynamicTimeWarpingResult,
    *,
    ax=None,
):
    """Plot one audited DTW alignment path in sample-index space."""

    if not isinstance(result, DynamicTimeWarpingResult):
        raise TypeError("result must be a DynamicTimeWarpingResult")
    if ax is None:
        _, ax = plt.subplots()

    path = np.asarray(result.path, dtype=int)
    if path.ndim != 2 or path.shape[1] != 2 or path.shape[0] < 1:
        raise ValueError("result.path must have shape (n_steps, 2)")

    ax.plot(path[:, 1], path[:, 0], marker="o")
    limit = max(result.n_points_a - 1, result.n_points_b - 1)
    ax.plot([0, limit], [0, limit], linestyle="--", label="same index")
    ax.set_xlim(-0.5, max(result.n_points_b - 0.5, 0.5))
    ax.set_ylim(-0.5, max(result.n_points_a - 0.5, 0.5))
    ax.set_xlabel("Sequence B sample index")
    ax.set_ylabel("Sequence A sample index")
    distance_label = (
        f"normalized={result.distance:.4g}"
        if result.provenance.get("normalization_requested", False)
        else f"raw={result.distance:.4g}"
    )
    window_label = (
        "unconstrained"
        if result.window_radius is None
        else f"window={result.window_radius}"
    )
    ax.set_title(
        f"DTW alignment: {result.step_pattern}, {window_label}, {distance_label}"
    )
    ax.legend()
    return ax

eyetrajectoriespy.plot_fpca_component

plot_fpca_component(result: FPCAResult, *, component: int = 0, dimension: str | None = None, sd_multiplier: float = 2.0, ax=None)

Plot mean ± one FPC mode for a selected functional dimension.

Source code in src/eyetrajectoriespy/plotting.py
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
def plot_fpca_component(
    result: FPCAResult,
    *,
    component: int = 0,
    dimension: str | None = None,
    sd_multiplier: float = 2.0,
    ax=None,
):
    """Plot mean ± one FPC mode for a selected functional dimension."""
    if component < 0 or component >= result.n_components:
        raise IndexError("component is outside the fitted range")
    if dimension is None:
        dimension = result.dimension_names[0]
    if dimension not in result.dimension_names:
        raise KeyError(f"Unknown dimension {dimension!r}")
    if sd_multiplier <= 0:
        raise ValueError("sd_multiplier must be positive")
    if ax is None:
        _, ax = plt.subplots()
    dim = result.dimension_names.index(dimension)
    curves = component_trajectories(result, component, sd_multipliers=(-sd_multiplier, 0.0, sd_multiplier))
    ax.plot(result.time, curves[1, :, dim], label="mean")
    ax.plot(result.time, curves[0, :, dim], label=f"-{sd_multiplier:g} SD")
    ax.plot(result.time, curves[2, :, dim], label=f"+{sd_multiplier:g} SD")
    ax.set_xlabel(f"Time ({result.time_unit})")
    ax.set_ylabel(dimension)
    ax.set_title(f"FPC{component + 1}: {dimension}(t)")
    ax.legend()
    return ax

eyetrajectoriespy.plot_warping_functions

plot_warping_functions(result: RegistrationResult, *, displacement: bool = False, ax=None)

Plot estimated time warpings or displacement from identity.

Source code in src/eyetrajectoriespy/plotting.py
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
def plot_warping_functions(result: RegistrationResult, *, displacement: bool = False, ax=None):
    """Plot estimated time warpings or displacement from identity."""
    if ax is None:
        _, ax = plt.subplots()
    y = warping_displacement(result) if displacement else result.warping_functions
    for i in range(y.shape[0]):
        ax.plot(result.original.time, y[i], alpha=0.3)
    if not displacement:
        ax.plot(result.original.time, result.original.time, linestyle="--", label="identity")
        ax.legend()
        ax.set_ylabel("Warped source time")
    else:
        ax.axhline(0, linestyle="--")
        ax.set_ylabel("h(t) - t")
    ax.set_xlabel(f"Reference time ({result.original.time_unit})")
    return ax

eyetrajectoriespy.plot_fpca_stability

plot_fpca_stability(result: FPCAStabilityResult, *, ax=None)

Plot bootstrap absolute component similarities by reference FPC.

Source code in src/eyetrajectoriespy/plotting.py
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
def plot_fpca_stability(
    result: FPCAStabilityResult,
    *,
    ax=None,
):
    """Plot bootstrap absolute component similarities by reference FPC."""

    if ax is None:
        _, ax = plt.subplots()
    data = [result.similarities[:, k] for k in range(result.reference.n_components)]
    ax.boxplot(data, tick_labels=[f"FPC{k + 1}" for k in range(result.reference.n_components)])
    ax.set_ylim(0, 1.02)
    ax.set_ylabel("Absolute matched functional similarity")
    ax.set_title(f"FPCA bootstrap stability ({result.n_bootstrap} replicates)")
    return ax

eyetrajectoriespy.plot_reconstruction_curve

plot_reconstruction_curve(reconstruction_summary, *, ax=None)

Plot integrated reconstruction error against retained FPC count.

Source code in src/eyetrajectoriespy/plotting.py
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
def plot_reconstruction_curve(
    reconstruction_summary,
    *,
    ax=None,
):
    """Plot integrated reconstruction error against retained FPC count."""

    required = {"n_components", "mean_integrated_rmse"}
    if not required <= set(reconstruction_summary.columns):
        raise ValueError(
            "reconstruction_summary must contain n_components and mean_integrated_rmse"
        )
    if ax is None:
        _, ax = plt.subplots()
    ax.plot(
        reconstruction_summary["n_components"],
        reconstruction_summary["mean_integrated_rmse"],
        marker="o",
    )
    ax.set_xlabel("Retained functional principal components")
    ax.set_ylabel("Mean integrated RMSE")
    ax.set_title("FPCA reconstruction curve")
    return ax

eyetrajectoriespy.dynamic_time_warping_reporting_text

dynamic_time_warping_reporting_text(result: DynamicTimeWarpingResult, *, digits: int = 3) -> str

Generate compact reporting text for one audited DTW alignment.

Source code in src/eyetrajectoriespy/reporting.py
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
def dynamic_time_warping_reporting_text(
    result: DynamicTimeWarpingResult,
    *,
    digits: int = 3,
) -> str:
    """Generate compact reporting text for one audited DTW alignment."""

    if not isinstance(result, DynamicTimeWarpingResult):
        raise TypeError("result must be a DynamicTimeWarpingResult")
    if isinstance(digits, bool) or not isinstance(digits, (int, np.integer)):
        raise TypeError("digits must be an integer")
    if digits < 0:
        raise ValueError("digits must be non-negative")

    window = (
        "unconstrained"
        if result.window_radius is None
        else f"Sakoe-Chiba radius={result.window_radius} sample indices"
    )
    raw_distance = (
        float(result.distance)
        if result.raw_distance is None
        else float(result.raw_distance)
    )
    if result.normalized_distance is None:
        distance_text = (
            f"raw cumulative distance={raw_distance:.{digits}f}; "
            "no path-independent normalization is defined for symmetric1"
        )
    else:
        distance_text = (
            f"raw cumulative distance={raw_distance:.{digits}f}, "
            f"N+M-normalized distance={result.normalized_distance:.{digits}f}"
        )
    returned = (
        "normalized"
        if result.provenance.get("normalization_requested", False)
        else "raw cumulative"
    )
    return (
        f"Dynamic time warping used the {result.step_pattern} step pattern "
        f"with {window}. {distance_text}. The reported scalar was the "
        f"{returned} value. The optimal monotone path contained "
        f"{result.path_length} matched index pairs; recorded timestamps were "
        "not used by the recurrence, and no step pattern, window, "
        "preprocessing, or normalization rule was selected automatically."
    )

eyetrajectoriespy.TrajectoryDistanceSensitivityResult dataclass

Descriptive robustness diagnostics across trajectory-distance contracts.

Source code in src/eyetrajectoriespy/types.py
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
@dataclass(frozen=True)
class TrajectoryDistanceSensitivityResult:
    """Descriptive robustness diagnostics across trajectory-distance contracts."""

    specification_names: tuple[str, ...]
    distance_matrices: np.ndarray
    specification_table: pd.DataFrame
    pairwise_distance_table: pd.DataFrame
    comparison_table: pd.DataFrame
    neighbor_overlap_table: pd.DataFrame
    neighbor_orders: np.ndarray
    neighbor_cutoff_ties: np.ndarray
    curve_ids: tuple[str, ...]
    dimensions: tuple[str, ...]
    dimension_weights: np.ndarray
    neighbor_k: int
    provenance: Mapping[str, Any] = field(default_factory=dict)

    @property
    def n_specifications(self) -> int:
        return len(self.specification_names)

    @property
    def n_curves(self) -> int:
        return len(self.curve_ids)

eyetrajectoriespy.trajectory_distance_sensitivity

trajectory_distance_sensitivity(trajectories: TrajectorySet, specifications: Sequence[Mapping[str, Any]], *, dimensions: Sequence[str] | None = None, dimension_weights: ndarray | Sequence[float] | None = None, neighbor_k: int = 3) -> TrajectoryDistanceSensitivityResult

Compare trajectory-distance conclusions across declared specifications.

The function compares the same complete trajectories and selected dimensions under at least two explicitly declared distance contracts. It returns all raw distance matrices, global pairwise-distance rank agreement, and local nearest-neighbor overlap.

No distance matrix is standardized, rescaled, averaged into a consensus, or assigned a preferred metric. Correlation quantities are descriptive: no p-values are computed because the upper-triangle pair distances are not independent observations.

Source code in src/eyetrajectoriespy/similarity_sensitivity.py
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
def trajectory_distance_sensitivity(
    trajectories: TrajectorySet,
    specifications: Sequence[Mapping[str, Any]],
    *,
    dimensions: Sequence[str] | None = None,
    dimension_weights: np.ndarray | Sequence[float] | None = None,
    neighbor_k: int = 3,
) -> TrajectoryDistanceSensitivityResult:
    """Compare trajectory-distance conclusions across declared specifications.

    The function compares the *same* complete trajectories and selected
    dimensions under at least two explicitly declared distance contracts.
    It returns all raw distance matrices, global pairwise-distance rank
    agreement, and local nearest-neighbor overlap.

    No distance matrix is standardized, rescaled, averaged into a consensus,
    or assigned a preferred metric. Correlation quantities are descriptive:
    no p-values are computed because the upper-triangle pair distances are not
    independent observations.
    """

    validate_trajectory_set(trajectories, require_complete=True)
    if trajectories.n_curves < 3:
        raise ValueError(
            "trajectory distance sensitivity requires at least three curves"
        )
    if (
        isinstance(neighbor_k, bool)
        or not isinstance(neighbor_k, (int, np.integer))
    ):
        raise TypeError("neighbor_k must be an integer")
    neighbor_k = int(neighbor_k)
    if neighbor_k < 1 or neighbor_k >= trajectories.n_curves:
        raise ValueError(
            "neighbor_k must be between 1 and n_curves - 1"
        )

    dimension_names, dimension_indices = _validate_dimensions(
        trajectories,
        dimensions,
    )
    weights = _validate_dimension_weights(
        len(dimension_names),
        dimension_weights,
    )
    specs = _validate_specifications(specifications)

    values = trajectories.values[:, :, dimension_indices]
    n_specs = len(specs)
    n_curves = trajectories.n_curves
    matrices = np.empty(
        (n_specs, n_curves, n_curves),
        dtype=float,
    )

    for specification_index, specification in enumerate(specs):
        matrices[specification_index] = _pairwise_distance_matrix(
            values,
            trajectories.time,
            specification=specification,
            dimension_weights=weights,
        )

    upper = np.triu_indices(n_curves, k=1)
    condensed = matrices[:, upper[0], upper[1]]
    ranks = np.vstack(
        [
            stats.rankdata(row, method="average")
            for row in condensed
        ]
    )

    neighbor_orders = np.empty(
        (n_specs, n_curves, neighbor_k),
        dtype=int,
    )
    cutoff_ties = np.empty((n_specs, n_curves), dtype=bool)
    for specification_index in range(n_specs):
        (
            neighbor_orders[specification_index],
            cutoff_ties[specification_index],
        ) = _neighbor_orders(
            matrices[specification_index],
            neighbor_k=neighbor_k,
        )

    comparison_rows: list[dict[str, Any]] = []
    neighbor_rows: list[dict[str, Any]] = []
    for left in range(n_specs):
        for right in range(left + 1, n_specs):
            left_vector = condensed[left]
            right_vector = condensed[right]
            spearman = float(
                stats.spearmanr(
                    left_vector,
                    right_vector,
                ).statistic
            )
            if np.std(left_vector) > 0 and np.std(right_vector) > 0:
                pearson = float(
                    np.corrcoef(left_vector, right_vector)[0, 1]
                )
            else:
                pearson = float("nan")

            rank_difference = np.abs(ranks[left] - ranks[right])
            jaccards: list[float] = []
            exact_sets: list[bool] = []
            nearest_matches: list[bool] = []

            for curve_index in range(n_curves):
                left_neighbors = tuple(
                    int(value)
                    for value in neighbor_orders[left, curve_index]
                )
                right_neighbors = tuple(
                    int(value)
                    for value in neighbor_orders[right, curve_index]
                )
                left_set = set(left_neighbors)
                right_set = set(right_neighbors)
                intersection = len(left_set & right_set)
                union = len(left_set | right_set)
                jaccard = (
                    float(intersection / union)
                    if union > 0
                    else 1.0
                )
                exact_set = left_set == right_set
                nearest_match = (
                    left_neighbors[0] == right_neighbors[0]
                )
                jaccards.append(jaccard)
                exact_sets.append(exact_set)
                nearest_matches.append(nearest_match)
                neighbor_rows.append(
                    {
                        "specification_a": specs[left]["name"],
                        "specification_b": specs[right]["name"],
                        "curve_id": trajectories.curve_ids[curve_index],
                        "neighbor_k": neighbor_k,
                        "neighbors_a": tuple(
                            trajectories.curve_ids[index]
                            for index in left_neighbors
                        ),
                        "neighbors_b": tuple(
                            trajectories.curve_ids[index]
                            for index in right_neighbors
                        ),
                        "overlap_count": intersection,
                        "jaccard": jaccard,
                        "exact_neighbor_set_match": exact_set,
                        "nearest_neighbor_match": nearest_match,
                        "cutoff_tie_a": bool(
                            cutoff_ties[left, curve_index]
                        ),
                        "cutoff_tie_b": bool(
                            cutoff_ties[right, curve_index]
                        ),
                    }
                )

            comparison_rows.append(
                {
                    "specification_a": specs[left]["name"],
                    "specification_b": specs[right]["name"],
                    "n_curve_pairs": condensed.shape[1],
                    "spearman_rank_correlation": spearman,
                    "pearson_raw_distance_correlation": pearson,
                    "mean_absolute_rank_difference": float(
                        np.mean(rank_difference)
                    ),
                    "median_absolute_rank_difference": float(
                        np.median(rank_difference)
                    ),
                    "maximum_absolute_rank_difference": float(
                        np.max(rank_difference)
                    ),
                    "mean_top_k_neighbor_jaccard": float(
                        np.mean(jaccards)
                    ),
                    "exact_top_k_neighbor_set_agreement_fraction": float(
                        np.mean(exact_sets)
                    ),
                    "nearest_neighbor_identity_agreement_fraction": float(
                        np.mean(nearest_matches)
                    ),
                    "any_neighbor_cutoff_tie": bool(
                        np.any(cutoff_ties[left])
                        or np.any(cutoff_ties[right])
                    ),
                }
            )

    specification_table = pd.DataFrame(
        [
            {
                "name": specification["name"],
                "method": specification["method"],
                "step_pattern": specification.get("step_pattern"),
                "normalize": specification.get("normalize"),
                "window_radius": specification.get("window_radius"),
            }
            for specification in specs
        ]
    )

    pair_rows: list[dict[str, Any]] = []
    for specification_index, specification in enumerate(specs):
        for pair_index, (left, right) in enumerate(
            zip(upper[0], upper[1], strict=True)
        ):
            pair_rows.append(
                {
                    "specification": specification["name"],
                    "method": specification["method"],
                    "curve_id_a": trajectories.curve_ids[left],
                    "curve_id_b": trajectories.curve_ids[right],
                    "distance": float(
                        condensed[specification_index, pair_index]
                    ),
                    "distance_rank": float(
                        ranks[specification_index, pair_index]
                    ),
                }
            )

    return TrajectoryDistanceSensitivityResult(
        specification_names=tuple(
            specification["name"] for specification in specs
        ),
        distance_matrices=matrices,
        specification_table=specification_table,
        pairwise_distance_table=pd.DataFrame(pair_rows),
        comparison_table=pd.DataFrame(comparison_rows),
        neighbor_overlap_table=pd.DataFrame(neighbor_rows),
        neighbor_orders=neighbor_orders,
        neighbor_cutoff_ties=cutoff_ties,
        curve_ids=trajectories.curve_ids,
        dimensions=dimension_names,
        dimension_weights=weights,
        neighbor_k=neighbor_k,
        provenance={
            **dict(trajectories.provenance),
            "trajectory_distance_sensitivity": {
                "operation": "trajectory_distance_sensitivity",
                "specifications": [
                    dict(specification) for specification in specs
                ],
                "dimensions": list(dimension_names),
                "dimension_weights": weights.tolist(),
                "n_curves": n_curves,
                "n_curve_pairs": int(condensed.shape[1]),
                "neighbor_k": neighbor_k,
                "distance_matrix_standardization": False,
                "distance_matrix_rescaling": False,
                "consensus_distance_constructed": False,
                "preferred_metric_selected": False,
                "correlation_p_values_computed": False,
                "pairwise_distances_treated_as_independent": False,
                "rank_tie_method": "average",
                "neighbor_tie_break": (
                    "stable_original_curve_order_with_cutoff_tie_flag"
                ),
                "interpretation_boundary": (
                    "descriptive sensitivity of pairwise ordering and local "
                    "neighbor structure to the declared trajectory-distance "
                    "contract; not a test that one metric is correct"
                ),
            },
        },
    )

eyetrajectoriespy.trajectory_distance_comparison_frame

trajectory_distance_comparison_frame(result: TrajectoryDistanceSensitivityResult) -> pd.DataFrame

Return one row per pair of distance specifications.

Source code in src/eyetrajectoriespy/similarity_sensitivity.py
516
517
518
519
520
521
522
523
524
525
def trajectory_distance_comparison_frame(
    result: TrajectoryDistanceSensitivityResult,
) -> pd.DataFrame:
    """Return one row per pair of distance specifications."""

    if not isinstance(result, TrajectoryDistanceSensitivityResult):
        raise TypeError(
            "result must be a TrajectoryDistanceSensitivityResult"
        )
    return result.comparison_table.copy()

eyetrajectoriespy.trajectory_distance_neighbor_frame

trajectory_distance_neighbor_frame(result: TrajectoryDistanceSensitivityResult) -> pd.DataFrame

Return per-curve local-neighborhood agreement across specifications.

Source code in src/eyetrajectoriespy/similarity_sensitivity.py
528
529
530
531
532
533
534
535
536
537
def trajectory_distance_neighbor_frame(
    result: TrajectoryDistanceSensitivityResult,
) -> pd.DataFrame:
    """Return per-curve local-neighborhood agreement across specifications."""

    if not isinstance(result, TrajectoryDistanceSensitivityResult):
        raise TypeError(
            "result must be a TrajectoryDistanceSensitivityResult"
        )
    return result.neighbor_overlap_table.copy()

eyetrajectoriespy.plot_trajectory_distance_rank_correlations

plot_trajectory_distance_rank_correlations(result: TrajectoryDistanceSensitivityResult, *, ax=None)

Plot descriptive Spearman agreement among distance specifications.

Source code in src/eyetrajectoriespy/plotting.py
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
def plot_trajectory_distance_rank_correlations(
    result: TrajectoryDistanceSensitivityResult,
    *,
    ax=None,
):
    """Plot descriptive Spearman agreement among distance specifications."""

    if not isinstance(result, TrajectoryDistanceSensitivityResult):
        raise TypeError(
            "result must be a TrajectoryDistanceSensitivityResult"
        )
    n = result.n_specifications
    matrix = np.eye(n, dtype=float)
    lookup = {
        name: index
        for index, name in enumerate(result.specification_names)
    }
    for row in result.comparison_table.itertuples(index=False):
        left = lookup[row.specification_a]
        right = lookup[row.specification_b]
        value = float(row.spearman_rank_correlation)
        matrix[left, right] = value
        matrix[right, left] = value

    if ax is None:
        _, ax = plt.subplots()
    image = ax.imshow(matrix, vmin=-1.0, vmax=1.0)
    ax.set_xticks(np.arange(n), labels=result.specification_names, rotation=45, ha="right")
    ax.set_yticks(np.arange(n), labels=result.specification_names)
    ax.set_title("Trajectory-distance rank agreement")
    for row in range(n):
        for column in range(n):
            value = matrix[row, column]
            text_value = "nan" if not np.isfinite(value) else f"{value:.2f}"
            ax.text(column, row, text_value, ha="center", va="center")
    ax.figure.colorbar(image, ax=ax, label="Spearman rank correlation")
    return ax

eyetrajectoriespy.trajectory_distance_sensitivity_reporting_text

trajectory_distance_sensitivity_reporting_text(result: TrajectoryDistanceSensitivityResult, *, digits: int = 3) -> str

Generate manuscript-oriented wording for distance-contract sensitivity.

Source code in src/eyetrajectoriespy/reporting.py
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
def trajectory_distance_sensitivity_reporting_text(
    result: TrajectoryDistanceSensitivityResult,
    *,
    digits: int = 3,
) -> str:
    """Generate manuscript-oriented wording for distance-contract sensitivity."""

    if not isinstance(result, TrajectoryDistanceSensitivityResult):
        raise TypeError(
            "result must be a TrajectoryDistanceSensitivityResult"
        )
    if isinstance(digits, bool) or not isinstance(digits, (int, np.integer)):
        raise TypeError("digits must be an integer")
    if digits < 0:
        raise ValueError("digits must be non-negative")

    comparison = result.comparison_table
    rho = comparison["spearman_rank_correlation"].to_numpy(dtype=float)
    jaccard = comparison["mean_top_k_neighbor_jaccard"].to_numpy(dtype=float)
    nearest = comparison[
        "nearest_neighbor_identity_agreement_fraction"
    ].to_numpy(dtype=float)
    finite_rho = rho[np.isfinite(rho)]

    rho_text = (
        "undefined for at least one constant pair-distance vector"
        if finite_rho.size == 0
        else (
            f"{np.min(finite_rho):.{digits}f} to "
            f"{np.max(finite_rho):.{digits}f}"
        )
    )
    tie_count = int(
        np.sum(result.neighbor_cutoff_ties)
    )
    return (
        f"Trajectory-similarity robustness was evaluated across "
        f"{result.n_specifications} predeclared distance specifications "
        f"({', '.join(result.specification_names)}) for "
        f"{result.n_curves} curves using dimensions "
        f"{', '.join(result.dimensions)}. Pairwise distance matrices were "
        "retained on their native scales; no standardization, rescaling, "
        "consensus distance, or preferred metric was constructed. "
        f"Across specification pairs, descriptive Spearman rank agreement "
        f"ranged from {rho_text}. Mean top-{result.neighbor_k} neighbor-set "
        f"Jaccard agreement ranged from {np.min(jaccard):.{digits}f} to "
        f"{np.max(jaccard):.{digits}f}, and nearest-neighbor identity "
        f"agreement ranged from {np.min(nearest):.{digits}f} to "
        f"{np.max(nearest):.{digits}f}. "
        f"{tie_count} specification-by-curve neighborhood cutoff ties were "
        "flagged. These quantities describe sensitivity to the declared "
        "distance contract; they are not p-values and do not identify a "
        "statistically or scientifically 'best' distance metric."
    )

eyetrajectoriespy.fpca_stability_reporting_text

fpca_stability_reporting_text(result: FPCAStabilityResult, *, similarity_threshold: float = 0.8, digits: int = 2) -> str

Generate compact descriptive text for bootstrap FPC stability.

Source code in src/eyetrajectoriespy/reporting.py
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
def fpca_stability_reporting_text(
    result: FPCAStabilityResult,
    *,
    similarity_threshold: float = 0.80,
    digits: int = 2,
) -> str:
    """Generate compact descriptive text for bootstrap FPC stability."""

    if not 0 <= similarity_threshold <= 1:
        raise ValueError("similarity_threshold must be in [0, 1]")
    medians = np.median(result.similarities, axis=0)
    fractions = np.mean(result.similarities >= similarity_threshold, axis=0)
    pieces = [
        (
            f"FPC{k + 1}: median |similarity|={medians[k]:.{digits}f}, "
            f"fraction >= {similarity_threshold:.{digits}f}={fractions[k]:.{digits}f}"
        )
        for k in range(result.reference.n_components)
    ]
    return (
        f"Bootstrap FPCA stability used {result.n_bootstrap} {result.resampling_unit}-level "
        f"replicates with component matching by absolute functional similarity. "
        + "; ".join(pieces)
        + ". Stability fractions are descriptive robustness summaries, not inferential probabilities."
    )

eyetrajectoriespy.registration_sensitivity_reporting_text

registration_sensitivity_reporting_text(result: RegistrationSensitivityResult, *, digits: int = 2) -> str

Generate descriptive text comparing FPC structure before/after registration.

Source code in src/eyetrajectoriespy/reporting.py
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
def registration_sensitivity_reporting_text(
    result: RegistrationSensitivityResult,
    *,
    digits: int = 2,
) -> str:
    """Generate descriptive text comparing FPC structure before/after registration."""

    similarity = np.abs(result.signed_component_similarity)
    score = result.score_correlations
    parts = []
    for k in range(len(similarity)):
        parts.append(
            f"FPC{k + 1}: matched shape similarity={similarity[k]:.{digits}f}, "
            f"score correlation={score[k]:.{digits}f}"
        )
    return (
        "Registration sensitivity compared matched functional principal components before "
        "and after the explicit registration step. "
        + "; ".join(parts)
        + "."
    )

eyetrajectoriespy.summarise_fpca

summarise_fpca(result: FPCAResult) -> pd.DataFrame

Return a component-level explained-variance table.

Source code in src/eyetrajectoriespy/reporting.py
446
447
448
449
450
451
452
453
def summarise_fpca(result: FPCAResult) -> pd.DataFrame:
    """Return a component-level explained-variance table."""
    return pd.DataFrame({
        "component": np.arange(1, result.n_components + 1),
        "explained_variance": result.explained_variance,
        "explained_variance_ratio": result.explained_variance_ratio,
        "cumulative_variance_ratio": np.cumsum(result.explained_variance_ratio),
    })

eyetrajectoriespy.fpca_reporting_text

fpca_reporting_text(result: FPCAResult, *, digits: int = 1) -> str

Generate compact manuscript-ready descriptive text for an FPCA fit.

Source code in src/eyetrajectoriespy/reporting.py
456
457
458
459
460
461
462
463
464
465
466
def fpca_reporting_text(result: FPCAResult, *, digits: int = 1) -> str:
    """Generate compact manuscript-ready descriptive text for an FPCA fit."""
    pct = 100 * result.cumulative_explained_variance()[-1]
    individual = 100 * result.explained_variance_ratio
    head = ", ".join(f"FPC{i + 1}={v:.{digits}f}%" for i, v in enumerate(individual[:4]))
    scaling = result.provenance.get("fpca", {}).get("scaling", "unknown")
    return (
        f"Functional PCA retained {result.n_components} component(s), explaining {pct:.{digits}f}% of the "
        f"weighted functional variance ({head}). The analysis used {result.coordinate_system} coordinates, "
        f"time unit '{result.time_unit}', and channel scaling='{scaling}'."
    )

Optional interoperability

eyetrajectoriespy.to_skfda_grid

to_skfda_grid(trajectories: TrajectorySet)

Convert trajectories to skfda.FDataGrid without changing values.

The optional dependency is not required for the package's core FPCA.

Source code in src/eyetrajectoriespy/backends.py
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
def to_skfda_grid(trajectories: TrajectorySet):
    """Convert trajectories to ``skfda.FDataGrid`` without changing values.

    The optional dependency is not required for the package's core FPCA.
    """

    validate_trajectory_set(trajectories)
    try:
        from skfda import FDataGrid
    except ImportError as exc:  # pragma: no cover - optional dependency
        raise ImportError(
            "scikit-fda is optional. Install eyetrajectoriespy with the 'fda' extra."
        ) from exc
    return FDataGrid(
        data_matrix=trajectories.values,
        grid_points=trajectories.time,
        dataset_name="eyetrajectoriespy trajectories",
        coordinate_names=trajectories.dimension_names,
    )

eyetrajectoriespy.to_skfda_basis

to_skfda_basis(trajectories: TrajectorySet, *, dimension: str, basis: str = 'bspline', n_basis: int = 15, order: int = 4) -> BasisProjectionResult

Project one functional dimension to an explicit scikit-fda basis.

Basis projection is an approximation/smoothing decision. The selected basis family and size are returned in a provenance-preserving wrapper.

Source code in src/eyetrajectoriespy/backends.py
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
def to_skfda_basis(
    trajectories: TrajectorySet,
    *,
    dimension: str,
    basis: str = "bspline",
    n_basis: int = 15,
    order: int = 4,
) -> BasisProjectionResult:
    """Project one functional dimension to an explicit scikit-fda basis.

    Basis projection is an approximation/smoothing decision. The selected
    basis family and size are returned in a provenance-preserving wrapper.
    """

    validate_trajectory_set(trajectories, require_complete=True)
    if dimension not in trajectories.dimension_names:
        raise KeyError(f"Unknown dimension {dimension!r}")
    if n_basis < 2:
        raise ValueError("n_basis must be at least 2")
    if order < 1:
        raise ValueError("order must be positive")
    if basis not in {"bspline", "fourier"}:
        raise ValueError("basis must be 'bspline' or 'fourier'")
    if basis == "bspline" and n_basis < order:
        raise ValueError("For B-splines, n_basis must be at least order")
    try:
        from skfda import FDataGrid
        from skfda.representation.basis import BSplineBasis, FourierBasis
    except ImportError as exc:  # pragma: no cover - optional dependency
        raise ImportError(
            "scikit-fda is optional. Install eyetrajectoriespy with the 'fda' extra."
        ) from exc

    index = trajectories.dimension_names.index(dimension)
    data = trajectories.values[:, :, index][:, :, None]
    grid = FDataGrid(
        data_matrix=data,
        grid_points=trajectories.time,
        dataset_name=f"eyetrajectoriespy {dimension}",
        coordinate_names=(dimension,),
    )
    domain = (float(trajectories.time[0]), float(trajectories.time[-1]))
    if basis == "bspline":
        basis_object = BSplineBasis(
            domain_range=domain,
            n_basis=n_basis,
            order=order,
        )
    elif basis == "fourier":
        basis_object = FourierBasis(
            domain_range=domain,
            n_basis=n_basis,
        )
    else:  # validated before optional backend import
        raise AssertionError("unreachable basis family")

    projected = grid.to_basis(basis_object)
    return BasisProjectionResult(
        backend_object=projected,
        dimension=dimension,
        basis_type=basis,
        n_basis=n_basis,
        time_domain=domain,
        provenance={
            **dict(trajectories.provenance),
            "basis_projection": {
                "backend": "scikit-fda",
                "basis": basis,
                "n_basis": n_basis,
                "order": order if basis == "bspline" else None,
            },
        },
    )

eyetrajectoriespy.fit_elastic_fpca

fit_elastic_fpca(trajectories: TrajectorySet, *, n_components: int = 3, rotation: bool = False, scale_curves: bool = False, lam: float = 0.0, method: str = 'DP') -> ElasticFPCAResult

Fit elastic planar-curve FPCA using fdasrsf.

Unlike ordinary grid FPCA, elastic analysis aligns curves in the square-root velocity framework and explicitly estimates warping functions. The function intentionally exposes rotation and scale_curves rather than normalizing geometry silently: rotation or scale invariance is often inappropriate for screen-based eye tracking where absolute layout matters.

Source code in src/eyetrajectoriespy/elastic.py
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
def fit_elastic_fpca(
    trajectories: TrajectorySet,
    *,
    n_components: int = 3,
    rotation: bool = False,
    scale_curves: bool = False,
    lam: float = 0.0,
    method: str = "DP",
) -> ElasticFPCAResult:
    """Fit elastic planar-curve FPCA using ``fdasrsf``.

    Unlike ordinary grid FPCA, elastic analysis aligns curves in the
    square-root velocity framework and explicitly estimates warping functions.
    The function intentionally exposes ``rotation`` and ``scale_curves`` rather
    than normalizing geometry silently: rotation or scale invariance is often
    inappropriate for screen-based eye tracking where absolute layout matters.
    """

    validate_trajectory_set(trajectories, require_complete=True)
    if trajectories.n_dimensions < 2:
        raise ValueError("Elastic curve analysis requires at least two dimensions")
    if n_components < 1:
        raise ValueError("n_components must be positive")
    fs = _require_fdasrsf()
    # fdasrsf expects (n_dimensions, n_samples, n_curves).
    beta = np.transpose(trajectories.values, (2, 1, 0))
    obj = fs.fdacurve(beta, N=trajectories.n_time, scale=scale_curves)
    obj.karcher_mean(rotation=rotation, parallel=False, lam=lam, method=method)
    obj.srvf_align(rotation=rotation, parallel=False, lam=lam, method=method)
    obj.shape_pca(no=n_components)

    betan = np.asarray(obj.betan)
    aligned_values = np.transpose(betan, (2, 1, 0))
    aligned = trajectories.with_values(
        aligned_values,
        provenance_update={
            "elastic_registration": {
                "backend": "fdasrsf",
                "rotation": rotation,
                "scale_curves": scale_curves,
                "lam": lam,
                "method": method,
            }
        },
    )
    gams = np.asarray(getattr(obj, "gams", np.empty((trajectories.n_time, trajectories.n_curves))))
    if gams.shape == (trajectories.n_time, trajectories.n_curves):
        gams = gams.T
    coef = getattr(obj, "coef", None)
    scores = None if coef is None else np.asarray(coef)
    if scores is not None and scores.ndim == 2 and scores.shape[0] != trajectories.n_curves and scores.shape[1] == trajectories.n_curves:
        scores = scores.T
    pd = getattr(obj, "pca", None)
    principal = None if pd is None else np.asarray(pd)
    mean_curve = getattr(obj, "beta_mean", None)
    mean_curve = None if mean_curve is None else np.asarray(mean_curve)
    return ElasticFPCAResult(
        aligned=aligned,
        warping_functions=gams,
        scores=scores,
        principal_directions=principal,
        mean_curve=mean_curve,
        backend="fdasrsf",
        provenance={
            "phase_amplitude_separated": True,
            "timing_warning": (
                "Warping changes traversal timing. Retain and analyze warping functions when latency is meaningful."
            ),
        },
        backend_object=obj,
    )

Discrete transfer entropy

eyetrajectoriespy.DiscreteTransferEntropyResult dataclass

Empirical plug-in transfer entropy for two discrete state sequences.

Source code in src/eyetrajectoriespy/transfer_entropy.py
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@dataclass(frozen=True)
class DiscreteTransferEntropyResult:
    """Empirical plug-in transfer entropy for two discrete state sequences."""

    transfer_entropy_bits: float
    local_transfer_entropy_bits: np.ndarray
    time_indices: np.ndarray
    source_states: np.ndarray
    target_states: np.ndarray
    target_history: int
    source_history: int
    source_lag: int
    n_observations: int
    n_effective: int
    n_source_states: int
    n_target_states: int
    n_target_histories: int
    n_joint_histories: int
    singleton_joint_history_fraction: float
    min_joint_history_count: int
    max_joint_history_count: int
    provenance: dict[str, object]

eyetrajectoriespy.TransferEntropyCircularShiftTestResult dataclass

Circular-shift surrogate test for one declared discrete TE contract.

Source code in src/eyetrajectoriespy/transfer_entropy.py
38
39
40
41
42
43
44
45
46
47
48
49
@dataclass(frozen=True)
class TransferEntropyCircularShiftTestResult:
    """Circular-shift surrogate test for one declared discrete TE contract."""

    observed: DiscreteTransferEntropyResult
    shifts: np.ndarray
    surrogate_transfer_entropy_bits: np.ndarray
    surrogate_mean_bits: float
    surrogate_centered_transfer_entropy_bits: float
    upper_tail_p_value: float
    p_value_resolution: float
    provenance: dict[str, object]

eyetrajectoriespy.discrete_transfer_entropy

discrete_transfer_entropy(source: Sequence[int] | ndarray, target: Sequence[int] | ndarray, *, target_history: int, source_history: int, source_lag: int) -> DiscreteTransferEntropyResult

Estimate empirical discrete transfer entropy from source to target.

The estimator is empirical plug-in conditional mutual information in bits. History lengths and source lag are required sample-index settings. Continuous observations are never binned, rounded, scaled, smoothed, or interpolated. A positive estimate is not interpreted as proof of causal influence.

Source code in src/eyetrajectoriespy/transfer_entropy.py
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
def discrete_transfer_entropy(
    source: Sequence[int] | np.ndarray,
    target: Sequence[int] | np.ndarray,
    *,
    target_history: int,
    source_history: int,
    source_lag: int,
) -> DiscreteTransferEntropyResult:
    """Estimate empirical discrete transfer entropy from source to target.

    The estimator is empirical plug-in conditional mutual information in bits.
    History lengths and source lag are required sample-index settings. Continuous
    observations are never binned, rounded, scaled, smoothed, or interpolated.
    A positive estimate is not interpreted as proof of causal influence.
    """

    source_states = _discrete_states(source, "source")
    target_states = _discrete_states(target, "target")
    if source_states.shape != target_states.shape:
        raise ValueError("source and target must have the same length")

    target_history = _positive_integer(target_history, "target_history")
    source_history = _positive_integer(source_history, "source_history")
    source_lag = _positive_integer(source_lag, "source_lag")
    first_index, records = _history_records(
        source_states,
        target_states,
        target_history=target_history,
        source_history=source_history,
        source_lag=source_lag,
    )

    transition_counts = Counter(records)
    joint_history_counts = Counter(
        (target_past, source_past)
        for _, target_past, source_past in records
    )
    target_transition_counts = Counter(
        (target_state, target_past)
        for target_state, target_past, _ in records
    )
    target_history_counts = Counter(
        target_past for _, target_past, _ in records
    )

    local = np.empty(len(records), dtype=float)
    for index, (target_state, target_past, source_past) in enumerate(records):
        numerator = (
            transition_counts[(target_state, target_past, source_past)]
            * target_history_counts[target_past]
        )
        denominator = (
            joint_history_counts[(target_past, source_past)]
            * target_transition_counts[(target_state, target_past)]
        )
        local[index] = np.log2(numerator / denominator)

    joint_support = np.asarray(list(joint_history_counts.values()), dtype=int)
    return DiscreteTransferEntropyResult(
        transfer_entropy_bits=float(np.mean(local)),
        local_transfer_entropy_bits=local,
        time_indices=np.arange(first_index, source_states.size, dtype=int),
        source_states=source_states.copy(),
        target_states=target_states.copy(),
        target_history=target_history,
        source_history=source_history,
        source_lag=source_lag,
        n_observations=int(source_states.size),
        n_effective=int(local.size),
        n_source_states=int(np.unique(source_states).size),
        n_target_states=int(np.unique(target_states).size),
        n_target_histories=int(len(target_history_counts)),
        n_joint_histories=int(len(joint_history_counts)),
        singleton_joint_history_fraction=float(np.mean(joint_support == 1)),
        min_joint_history_count=int(joint_support.min()),
        max_joint_history_count=int(joint_support.max()),
        provenance={
            "operation": "discrete_transfer_entropy",
            "estimator": "empirical_plugin_conditional_mutual_information",
            "log_base": 2,
            "target_history": target_history,
            "source_history": source_history,
            "source_lag_samples": source_lag,
            "state_representation": "analyst_supplied_integer_codes",
            "automatic_discretization": False,
            "automatic_history_selection": False,
            "automatic_lag_selection": False,
        },
    )

eyetrajectoriespy.transfer_entropy_local_frame

transfer_entropy_local_frame(result: DiscreteTransferEntropyResult) -> pd.DataFrame

Return local TE contributions and the histories used for each row.

Source code in src/eyetrajectoriespy/transfer_entropy.py
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
def transfer_entropy_local_frame(result: DiscreteTransferEntropyResult) -> pd.DataFrame:
    """Return local TE contributions and the histories used for each row."""

    if not isinstance(result, DiscreteTransferEntropyResult):
        raise TypeError("result must be a DiscreteTransferEntropyResult")
    rows = []
    for time_index, local_value in zip(
        result.time_indices,
        result.local_transfer_entropy_bits,
        strict=True,
    ):
        target_past = tuple(
            int(result.target_states[time_index - offset])
            for offset in range(1, result.target_history + 1)
        )
        source_past = tuple(
            int(result.source_states[time_index - result.source_lag - offset])
            for offset in range(result.source_history)
        )
        rows.append(
            {
                "time_index": int(time_index),
                "target_state": int(result.target_states[time_index]),
                "target_history": target_past,
                "source_history": source_past,
                "local_transfer_entropy_bits": float(local_value),
            }
        )
    return pd.DataFrame(rows)

eyetrajectoriespy.transfer_entropy_circular_shift_test

transfer_entropy_circular_shift_test(source: Sequence[int] | ndarray, target: Sequence[int] | ndarray, *, target_history: int, source_history: int, source_lag: int, shifts: Sequence[int]) -> TransferEntropyCircularShiftTestResult

Compare observed TE with analyst-declared circular source shifts.

The shift set is mandatory and is never generated or optimized. The returned p-value is the plus-one upper-tail Monte Carlo value. Circular shifts require a defensible wrap-around/stationarity assumption and do not establish causality.

Source code in src/eyetrajectoriespy/transfer_entropy.py
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
def transfer_entropy_circular_shift_test(
    source: Sequence[int] | np.ndarray,
    target: Sequence[int] | np.ndarray,
    *,
    target_history: int,
    source_history: int,
    source_lag: int,
    shifts: Sequence[int],
) -> TransferEntropyCircularShiftTestResult:
    """Compare observed TE with analyst-declared circular source shifts.

    The shift set is mandatory and is never generated or optimized. The returned
    p-value is the plus-one upper-tail Monte Carlo value. Circular shifts require
    a defensible wrap-around/stationarity assumption and do not establish causality.
    """

    source_states = _discrete_states(source, "source")
    target_states = _discrete_states(target, "target")
    if source_states.shape != target_states.shape:
        raise ValueError("source and target must have the same length")
    if isinstance(shifts, (str, bytes)):
        raise TypeError("shifts must be a non-string sequence of integers")
    try:
        supplied_shifts = tuple(shifts)
    except TypeError as exc:
        raise TypeError("shifts must be a non-string sequence of integers") from exc
    if not supplied_shifts:
        raise ValueError("shifts must contain at least one circular shift")

    resolved_shifts = []
    for shift in supplied_shifts:
        if isinstance(shift, (bool, np.bool_)) or not isinstance(shift, (int, np.integer)):
            raise TypeError("each circular shift must be an integer")
        resolved = int(shift)
        if not 1 <= resolved < source_states.size:
            raise ValueError(
                "each circular shift must satisfy 1 <= shift < series length"
            )
        resolved_shifts.append(resolved)
    if len(set(resolved_shifts)) != len(resolved_shifts):
        raise ValueError("circular shifts must be unique")

    observed = discrete_transfer_entropy(
        source_states,
        target_states,
        target_history=target_history,
        source_history=source_history,
        source_lag=source_lag,
    )
    surrogate = np.asarray(
        [
            discrete_transfer_entropy(
                np.roll(source_states, shift),
                target_states,
                target_history=target_history,
                source_history=source_history,
                source_lag=source_lag,
            ).transfer_entropy_bits
            for shift in resolved_shifts
        ],
        dtype=float,
    )
    surrogate_mean = float(np.mean(surrogate))
    upper_tail = float(
        (1 + np.count_nonzero(surrogate >= observed.transfer_entropy_bits))
        / (surrogate.size + 1)
    )
    return TransferEntropyCircularShiftTestResult(
        observed=observed,
        shifts=np.asarray(resolved_shifts, dtype=int),
        surrogate_transfer_entropy_bits=surrogate,
        surrogate_mean_bits=surrogate_mean,
        surrogate_centered_transfer_entropy_bits=float(
            observed.transfer_entropy_bits - surrogate_mean
        ),
        upper_tail_p_value=upper_tail,
        p_value_resolution=float(1.0 / (surrogate.size + 1)),
        provenance={
            "operation": "transfer_entropy_circular_shift_test",
            "null": "analyst_declared_circular_source_shifts",
            "tail": "upper",
            "p_value": "plus_one_monte_carlo",
            "shifts_samples": list(resolved_shifts),
            "observed_contract": dict(observed.provenance),
            "automatic_shift_generation": False,
        },
    )

eyetrajectoriespy.plot_transfer_entropy_circular_shift_test

plot_transfer_entropy_circular_shift_test(result: TransferEntropyCircularShiftTestResult, *, ax=None, bins: int | str = 'auto')

Plot the circular-shift null distribution and observed TE.

Source code in src/eyetrajectoriespy/transfer_entropy.py
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
def plot_transfer_entropy_circular_shift_test(
    result: TransferEntropyCircularShiftTestResult,
    *,
    ax=None,
    bins: int | str = "auto",
):
    """Plot the circular-shift null distribution and observed TE."""

    if not isinstance(result, TransferEntropyCircularShiftTestResult):
        raise TypeError("result must be a TransferEntropyCircularShiftTestResult")
    if ax is None:
        _, ax = plt.subplots()
    ax.hist(result.surrogate_transfer_entropy_bits, bins=bins)
    ax.axvline(result.observed.transfer_entropy_bits, linestyle="--")
    ax.set_xlabel("Transfer entropy (bits)")
    ax.set_ylabel("Circular-shift surrogates")
    ax.set_title("Transfer-entropy circular-shift test")
    return ax

eyetrajectoriespy.transfer_entropy_reporting_text

transfer_entropy_reporting_text(result: DiscreteTransferEntropyResult) -> str

Return compact manuscript-oriented text for a TE estimate.

Source code in src/eyetrajectoriespy/transfer_entropy.py
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
def transfer_entropy_reporting_text(result: DiscreteTransferEntropyResult) -> str:
    """Return compact manuscript-oriented text for a TE estimate."""

    if not isinstance(result, DiscreteTransferEntropyResult):
        raise TypeError("result must be a DiscreteTransferEntropyResult")
    return (
        "Discrete empirical transfer entropy from source to target was "
        f"{result.transfer_entropy_bits:.6g} bits using target history "
        f"k={result.target_history}, source history l={result.source_history}, "
        f"and source lag d={result.source_lag} sample(s), based on "
        f"{result.n_effective} effective transitions. Joint-history support "
        f"included {result.n_joint_histories} observed states, with "
        f"{result.singleton_joint_history_fraction:.3f} occurring once. "
        "The estimate is conditional on the declared discrete state representation "
        "and does not by itself establish causal influence."
    )

eyetrajectoriespy.transfer_entropy_circular_shift_reporting_text

transfer_entropy_circular_shift_reporting_text(result: TransferEntropyCircularShiftTestResult) -> str

Return compact reporting text for a circular-shift TE test.

Source code in src/eyetrajectoriespy/transfer_entropy.py
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
def transfer_entropy_circular_shift_reporting_text(
    result: TransferEntropyCircularShiftTestResult,
) -> str:
    """Return compact reporting text for a circular-shift TE test."""

    if not isinstance(result, TransferEntropyCircularShiftTestResult):
        raise TypeError("result must be a TransferEntropyCircularShiftTestResult")
    return (
        f"Observed transfer entropy was {result.observed.transfer_entropy_bits:.6g} bits; "
        f"the mean across {result.shifts.size} analyst-declared circular source shifts "
        f"was {result.surrogate_mean_bits:.6g} bits, giving a surrogate-centered "
        f"difference of {result.surrogate_centered_transfer_entropy_bits:.6g} bits. "
        f"The plus-one upper-tail Monte Carlo p-value was {result.upper_tail_p_value:.6g} "
        f"(minimum attainable resolution {result.p_value_resolution:.6g}). "
        "The null preserves the source marginal and circular auto-dependence but assumes "
        "the declared wrap-around shifts are scientifically defensible; the test does not "
        "establish causality."
    )

Transfer entropy sensitivity

eyetrajectoriespy.TransferEntropySensitivityResult dataclass

Declared multiverse of discrete transfer-entropy specifications.

Source code in src/eyetrajectoriespy/transfer_entropy_sensitivity.py
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
@dataclass(frozen=True)
class TransferEntropySensitivityResult:
    """Declared multiverse of discrete transfer-entropy specifications."""

    table: pd.DataFrame
    summary_table: pd.DataFrame
    parameter_columns: tuple[str, ...]
    metric_columns: tuple[str, ...]
    source_states: np.ndarray
    target_states: np.ndarray
    target_histories: tuple[int, ...]
    source_histories: tuple[int, ...]
    source_lags: tuple[int, ...]
    shifts: np.ndarray | None
    provenance: dict[str, object]

    @property
    def n_specifications(self) -> int:
        return len(self.table)

    @property
    def has_surrogate_inference(self) -> bool:
        return self.shifts is not None

eyetrajectoriespy.transfer_entropy_parameter_sensitivity

transfer_entropy_parameter_sensitivity(source: Sequence[int] | ndarray, target: Sequence[int] | ndarray, *, target_histories: Sequence[int], source_histories: Sequence[int], source_lags: Sequence[int], shifts: Sequence[int] | None = None) -> TransferEntropySensitivityResult

Evaluate a predeclared transfer-entropy specification multiverse.

Every Cartesian-product combination of target history, source history, and source lag is evaluated. If shifts are supplied, the exact same analyst-declared circular-shift set is used for every specification.

Invalid specifications abort the analysis with the failing combination identified. No failed row is removed, no parameter is selected automatically, and the descriptive summaries are not sampling distributions or multiplicity-adjusted inference.

Source code in src/eyetrajectoriespy/transfer_entropy_sensitivity.py
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
def transfer_entropy_parameter_sensitivity(
    source: Sequence[int] | np.ndarray,
    target: Sequence[int] | np.ndarray,
    *,
    target_histories: Sequence[int],
    source_histories: Sequence[int],
    source_lags: Sequence[int],
    shifts: Sequence[int] | None = None,
) -> TransferEntropySensitivityResult:
    """Evaluate a predeclared transfer-entropy specification multiverse.

    Every Cartesian-product combination of target history, source history, and
    source lag is evaluated. If shifts are supplied, the exact same
    analyst-declared circular-shift set is used for every specification.

    Invalid specifications abort the analysis with the failing combination
    identified. No failed row is removed, no parameter is selected
    automatically, and the descriptive summaries are not sampling
    distributions or multiplicity-adjusted inference.
    """

    target_grid = _positive_integer_grid(
        target_histories,
        name="target_histories",
    )
    source_grid = _positive_integer_grid(
        source_histories,
        name="source_histories",
    )
    lag_grid = _positive_integer_grid(
        source_lags,
        name="source_lags",
    )

    rows: list[dict[str, float | int]] = []
    source_states: np.ndarray | None = None
    target_states: np.ndarray | None = None
    resolved_shifts: np.ndarray | None = None

    for specification_id, (target_history, source_history, source_lag) in enumerate(
        product(target_grid, source_grid, lag_grid)
    ):
        specification = (
            f"target_history={target_history}, "
            f"source_history={source_history}, "
            f"source_lag={source_lag}"
        )
        try:
            if shifts is None:
                observed = discrete_transfer_entropy(
                    source,
                    target,
                    target_history=target_history,
                    source_history=source_history,
                    source_lag=source_lag,
                )
                surrogate_mean = float("nan")
                surrogate_centered = float("nan")
                upper_tail_p = float("nan")
                p_resolution = float("nan")
                n_shifts = 0
            else:
                shift_test = transfer_entropy_circular_shift_test(
                    source,
                    target,
                    target_history=target_history,
                    source_history=source_history,
                    source_lag=source_lag,
                    shifts=shifts,
                )
                observed = shift_test.observed
                surrogate_mean = shift_test.surrogate_mean_bits
                surrogate_centered = shift_test.surrogate_centered_transfer_entropy_bits
                upper_tail_p = shift_test.upper_tail_p_value
                p_resolution = shift_test.p_value_resolution
                n_shifts = int(shift_test.shifts.size)
                if resolved_shifts is None:
                    resolved_shifts = shift_test.shifts.copy()
                elif not np.array_equal(resolved_shifts, shift_test.shifts):
                    raise RuntimeError(
                        "circular-shift set changed across specifications"
                    )
        except (TypeError, ValueError, RuntimeError) as exc:
            raise ValueError(
                "transfer entropy sensitivity failed for "
                f"{specification}: {exc}"
            ) from exc

        if source_states is None:
            source_states = observed.source_states.copy()
            target_states = observed.target_states.copy()
        elif (
            not np.array_equal(source_states, observed.source_states)
            or target_states is None
            or not np.array_equal(target_states, observed.target_states)
        ):
            raise RuntimeError(
                "source/target states changed across sensitivity specifications"
            )

        rows.append(
            {
                "specification_id": specification_id,
                "target_history": target_history,
                "source_history": source_history,
                "source_lag": source_lag,
                "transfer_entropy_bits": observed.transfer_entropy_bits,
                "n_effective": observed.n_effective,
                "effective_fraction": observed.n_effective / observed.n_observations,
                "n_target_histories": observed.n_target_histories,
                "n_joint_histories": observed.n_joint_histories,
                "singleton_joint_history_fraction": (
                    observed.singleton_joint_history_fraction
                ),
                "min_joint_history_count": observed.min_joint_history_count,
                "max_joint_history_count": observed.max_joint_history_count,
                "mean_joint_history_count": (
                    observed.n_effective / observed.n_joint_histories
                ),
                "surrogate_mean_bits": surrogate_mean,
                "surrogate_centered_transfer_entropy_bits": surrogate_centered,
                "upper_tail_p_value": upper_tail_p,
                "p_value_resolution": p_resolution,
                "n_shifts": n_shifts,
            }
        )

    if source_states is None or target_states is None:
        raise RuntimeError("sensitivity analysis produced no specifications")

    table = pd.DataFrame(rows)
    parameter_columns = ("target_history", "source_history", "source_lag")
    metrics = [
        "transfer_entropy_bits",
        "n_effective",
        "effective_fraction",
        "n_target_histories",
        "n_joint_histories",
        "singleton_joint_history_fraction",
        "min_joint_history_count",
        "mean_joint_history_count",
    ]
    if shifts is not None:
        metrics.extend(
            [
                "surrogate_mean_bits",
                "surrogate_centered_transfer_entropy_bits",
                "upper_tail_p_value",
            ]
        )
    metric_columns = tuple(metrics)
    summary = _variation_summary(table, metric_columns)

    return TransferEntropySensitivityResult(
        table=table,
        summary_table=summary,
        parameter_columns=parameter_columns,
        metric_columns=metric_columns,
        source_states=source_states,
        target_states=target_states,
        target_histories=target_grid,
        source_histories=source_grid,
        source_lags=lag_grid,
        shifts=resolved_shifts,
        provenance={
            "operation": "transfer_entropy_parameter_sensitivity",
            "design": "full_cartesian_product",
            "target_histories": list(target_grid),
            "source_histories": list(source_grid),
            "source_lags_samples": list(lag_grid),
            "n_specifications": int(len(table)),
            "surrogate_inference": shifts is not None,
            "shifts_samples": (
                resolved_shifts.tolist() if resolved_shifts is not None else None
            ),
            "automatic_discretization": False,
            "automatic_history_selection": False,
            "automatic_lag_selection": False,
            "automatic_specification_ranking": False,
            "failed_specification_policy": "raise",
            "summary_interpretation": "descriptive_across_declared_specifications",
        },
    )

eyetrajectoriespy.plot_transfer_entropy_sensitivity

plot_transfer_entropy_sensitivity(result: TransferEntropySensitivityResult, *, parameter: str, metric: str = 'transfer_entropy_bits', filters: Mapping[str, int] | None = None, ax=None)

Plot one explicit TE-sensitivity slice without hidden averaging.

Source code in src/eyetrajectoriespy/transfer_entropy_sensitivity.py
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
def plot_transfer_entropy_sensitivity(
    result: TransferEntropySensitivityResult,
    *,
    parameter: str,
    metric: str = "transfer_entropy_bits",
    filters: Mapping[str, int] | None = None,
    ax=None,
):
    """Plot one explicit TE-sensitivity slice without hidden averaging."""

    if not isinstance(result, TransferEntropySensitivityResult):
        raise TypeError("result must be a TransferEntropySensitivityResult")
    if metric not in result.metric_columns:
        raise KeyError(
            f"metric must be one of {result.metric_columns}, got {metric!r}"
        )
    selected = _sensitivity_slice(
        result,
        parameter=parameter,
        filters=filters,
    )
    if ax is None:
        _, ax = plt.subplots()
    ax.plot(selected[parameter], selected[metric], marker="o")
    ax.set_xlabel(parameter.replace("_", " "))
    ax.set_ylabel(metric.replace("_", " "))
    ax.set_title("Transfer-entropy specification sensitivity")
    return ax

eyetrajectoriespy.transfer_entropy_parameter_sensitivity_reporting_text

transfer_entropy_parameter_sensitivity_reporting_text(result: TransferEntropySensitivityResult) -> str

Return manuscript-oriented wording for a TE specification multiverse.

Source code in src/eyetrajectoriespy/transfer_entropy_sensitivity.py
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
def transfer_entropy_parameter_sensitivity_reporting_text(
    result: TransferEntropySensitivityResult,
) -> str:
    """Return manuscript-oriented wording for a TE specification multiverse."""

    if not isinstance(result, TransferEntropySensitivityResult):
        raise TypeError("result must be a TransferEntropySensitivityResult")
    table = result.table
    te = table["transfer_entropy_bits"].to_numpy(dtype=float)
    singleton = table["singleton_joint_history_fraction"].to_numpy(dtype=float)
    support = table["min_joint_history_count"].to_numpy(dtype=float)

    text = (
        f"Transfer-entropy specification sensitivity evaluated "
        f"{result.n_specifications} predeclared combinations of target history "
        f"{result.target_histories}, source history {result.source_histories}, "
        f"and source lag {result.source_lags} sample(s). Empirical TE ranged "
        f"from {np.min(te):.6g} to {np.max(te):.6g} bits "
        f"(median {np.median(te):.6g}). Across specifications, the fraction "
        f"of observed joint histories occurring once ranged from "
        f"{np.min(singleton):.3f} to {np.max(singleton):.3f}, and the minimum "
        f"joint-history cell count ranged from {int(np.min(support))} to "
        f"{int(np.max(support))}."
    )
    if result.has_surrogate_inference:
        centered = table[
            "surrogate_centered_transfer_entropy_bits"
        ].to_numpy(dtype=float)
        pvalues = table["upper_tail_p_value"].to_numpy(dtype=float)
        text += (
            f" The same {result.shifts.size} analyst-declared circular source "
            f"shifts were used for every specification; surrogate-centered TE "
            f"ranged from {np.min(centered):.6g} to {np.max(centered):.6g} "
            f"bits and unadjusted plus-one upper-tail p-values ranged from "
            f"{np.min(pvalues):.6g} to {np.max(pvalues):.6g}."
        )
    return text + (
        " These summaries describe robustness across the declared multiverse; "
        "they are not a sampling distribution, no specification was selected "
        "or ranked automatically, and p-values across specifications are not "
        "multiplicity-adjusted causal evidence."
    )

Conditional transfer entropy

eyetrajectoriespy.ConditionalTransferEntropyResult dataclass

Empirical plug-in conditional transfer entropy for discrete sequences.

Source code in src/eyetrajectoriespy/conditional_transfer_entropy.py
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
@dataclass(frozen=True)
class ConditionalTransferEntropyResult:
    """Empirical plug-in conditional transfer entropy for discrete sequences."""

    conditional_transfer_entropy_bits: float
    local_conditional_transfer_entropy_bits: np.ndarray
    time_indices: np.ndarray
    source_states: np.ndarray
    target_states: np.ndarray
    condition_states: np.ndarray
    target_history_states: np.ndarray
    source_history_states: np.ndarray
    condition_history_states: np.ndarray
    target_history: int
    source_history: int
    condition_history: int
    source_lag: int
    condition_lag: int
    n_observations: int
    n_effective: int
    n_source_states: int
    n_target_states: int
    n_condition_states: int
    n_target_histories: int
    n_condition_histories: int
    n_target_condition_histories: int
    n_source_condition_histories: int
    n_joint_histories: int
    singleton_joint_history_fraction: float
    min_joint_history_count: int
    max_joint_history_count: int
    mean_joint_history_count: float
    provenance: dict[str, object]

eyetrajectoriespy.ConditionalTransferEntropyCircularShiftTestResult dataclass

Source-only circular-shift test for one declared conditional-TE contract.

Source code in src/eyetrajectoriespy/conditional_transfer_entropy.py
51
52
53
54
55
56
57
58
59
60
61
62
@dataclass(frozen=True)
class ConditionalTransferEntropyCircularShiftTestResult:
    """Source-only circular-shift test for one declared conditional-TE contract."""

    observed: ConditionalTransferEntropyResult
    shifts: np.ndarray
    surrogate_conditional_transfer_entropy_bits: np.ndarray
    surrogate_mean_bits: float
    surrogate_centered_conditional_transfer_entropy_bits: float
    upper_tail_p_value: float
    p_value_resolution: float
    provenance: dict[str, object]

eyetrajectoriespy.conditional_transfer_entropy

Discrete conditional transfer entropy with explicit scientific contracts.

ConditionalTransferEntropyResult dataclass

Empirical plug-in conditional transfer entropy for discrete sequences.

Source code in src/eyetrajectoriespy/conditional_transfer_entropy.py
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
@dataclass(frozen=True)
class ConditionalTransferEntropyResult:
    """Empirical plug-in conditional transfer entropy for discrete sequences."""

    conditional_transfer_entropy_bits: float
    local_conditional_transfer_entropy_bits: np.ndarray
    time_indices: np.ndarray
    source_states: np.ndarray
    target_states: np.ndarray
    condition_states: np.ndarray
    target_history_states: np.ndarray
    source_history_states: np.ndarray
    condition_history_states: np.ndarray
    target_history: int
    source_history: int
    condition_history: int
    source_lag: int
    condition_lag: int
    n_observations: int
    n_effective: int
    n_source_states: int
    n_target_states: int
    n_condition_states: int
    n_target_histories: int
    n_condition_histories: int
    n_target_condition_histories: int
    n_source_condition_histories: int
    n_joint_histories: int
    singleton_joint_history_fraction: float
    min_joint_history_count: int
    max_joint_history_count: int
    mean_joint_history_count: float
    provenance: dict[str, object]

ConditionalTransferEntropyCircularShiftTestResult dataclass

Source-only circular-shift test for one declared conditional-TE contract.

Source code in src/eyetrajectoriespy/conditional_transfer_entropy.py
51
52
53
54
55
56
57
58
59
60
61
62
@dataclass(frozen=True)
class ConditionalTransferEntropyCircularShiftTestResult:
    """Source-only circular-shift test for one declared conditional-TE contract."""

    observed: ConditionalTransferEntropyResult
    shifts: np.ndarray
    surrogate_conditional_transfer_entropy_bits: np.ndarray
    surrogate_mean_bits: float
    surrogate_centered_conditional_transfer_entropy_bits: float
    upper_tail_p_value: float
    p_value_resolution: float
    provenance: dict[str, object]

conditional_transfer_entropy

conditional_transfer_entropy(source: Sequence[int] | ndarray, target: Sequence[int] | ndarray, condition: Sequence[int] | ndarray, *, target_history: int, source_history: int, condition_history: int, source_lag: int, condition_lag: int) -> ConditionalTransferEntropyResult

Estimate empirical discrete conditional transfer entropy in bits.

The estimand is the conditional mutual information between the declared source history and the next target state, conditional on both the target history and the declared conditioning-process history.

Input state sequences must be analyst-supplied integer codes. No automatic discretization, smoothing, scaling, interpolation, history selection, lag selection, support filtering, or causal interpretation is introduced.

Source code in src/eyetrajectoriespy/conditional_transfer_entropy.py
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
def conditional_transfer_entropy(
    source: Sequence[int] | np.ndarray,
    target: Sequence[int] | np.ndarray,
    condition: Sequence[int] | np.ndarray,
    *,
    target_history: int,
    source_history: int,
    condition_history: int,
    source_lag: int,
    condition_lag: int,
) -> ConditionalTransferEntropyResult:
    """Estimate empirical discrete conditional transfer entropy in bits.

    The estimand is the conditional mutual information between the declared
    source history and the next target state, conditional on both the target
    history and the declared conditioning-process history.

    Input state sequences must be analyst-supplied integer codes. No automatic
    discretization, smoothing, scaling, interpolation, history selection, lag
    selection, support filtering, or causal interpretation is introduced.
    """

    source_states = _discrete_states(source, "source")
    target_states = _discrete_states(target, "target")
    condition_states = _discrete_states(condition, "condition")
    if not (
        source_states.shape == target_states.shape == condition_states.shape
    ):
        raise ValueError(
            "source, target, and condition must have the same length"
        )

    target_history = _positive_integer(target_history, "target_history")
    source_history = _positive_integer(source_history, "source_history")
    condition_history = _positive_integer(
        condition_history, "condition_history"
    )
    source_lag = _positive_integer(source_lag, "source_lag")
    condition_lag = _positive_integer(condition_lag, "condition_lag")

    first_index, records = _conditional_history_records(
        source_states,
        target_states,
        condition_states,
        target_history=target_history,
        source_history=source_history,
        condition_history=condition_history,
        source_lag=source_lag,
        condition_lag=condition_lag,
    )

    full_transition_counts = Counter(records)
    full_history_counts = Counter(
        (target_past, source_past, condition_past)
        for _, target_past, source_past, condition_past in records
    )
    conditioned_transition_counts = Counter(
        (target_state, target_past, condition_past)
        for target_state, target_past, _, condition_past in records
    )
    target_condition_history_counts = Counter(
        (target_past, condition_past)
        for _, target_past, _, condition_past in records
    )
    target_history_counts = Counter(
        target_past
        for _, target_past, _, _ in records
    )
    condition_history_counts = Counter(
        condition_past
        for _, _, _, condition_past in records
    )
    source_condition_history_counts = Counter(
        (source_past, condition_past)
        for _, _, source_past, condition_past in records
    )

    local = np.empty(len(records), dtype=float)
    target_history_states = np.empty(
        (len(records), target_history),
        dtype=np.int64,
    )
    source_history_states = np.empty(
        (len(records), source_history),
        dtype=np.int64,
    )
    condition_history_states = np.empty(
        (len(records), condition_history),
        dtype=np.int64,
    )

    for index, (
        target_state,
        target_past,
        source_past,
        condition_past,
    ) in enumerate(records):
        numerator = (
            full_transition_counts[
                (target_state, target_past, source_past, condition_past)
            ]
            * target_condition_history_counts[
                (target_past, condition_past)
            ]
        )
        denominator = (
            full_history_counts[
                (target_past, source_past, condition_past)
            ]
            * conditioned_transition_counts[
                (target_state, target_past, condition_past)
            ]
        )
        local[index] = np.log2(numerator / denominator)
        target_history_states[index] = target_past
        source_history_states[index] = source_past
        condition_history_states[index] = condition_past

    joint_support = np.asarray(
        list(full_history_counts.values()),
        dtype=int,
    )
    return ConditionalTransferEntropyResult(
        conditional_transfer_entropy_bits=float(np.mean(local)),
        local_conditional_transfer_entropy_bits=local,
        time_indices=np.arange(
            first_index,
            source_states.size,
            dtype=int,
        ),
        source_states=source_states.copy(),
        target_states=target_states.copy(),
        condition_states=condition_states.copy(),
        target_history_states=target_history_states,
        source_history_states=source_history_states,
        condition_history_states=condition_history_states,
        target_history=target_history,
        source_history=source_history,
        condition_history=condition_history,
        source_lag=source_lag,
        condition_lag=condition_lag,
        n_observations=int(source_states.size),
        n_effective=int(local.size),
        n_source_states=int(np.unique(source_states).size),
        n_target_states=int(np.unique(target_states).size),
        n_condition_states=int(np.unique(condition_states).size),
        n_target_histories=int(len(target_history_counts)),
        n_condition_histories=int(len(condition_history_counts)),
        n_target_condition_histories=int(
            len(target_condition_history_counts)
        ),
        n_source_condition_histories=int(
            len(source_condition_history_counts)
        ),
        n_joint_histories=int(len(full_history_counts)),
        singleton_joint_history_fraction=float(
            np.mean(joint_support == 1)
        ),
        min_joint_history_count=int(joint_support.min()),
        max_joint_history_count=int(joint_support.max()),
        mean_joint_history_count=float(np.mean(joint_support)),
        provenance={
            "operation": "conditional_transfer_entropy",
            "estimator": (
                "empirical_plugin_conditional_mutual_information"
            ),
            "log_base": 2,
            "target_history": target_history,
            "source_history": source_history,
            "condition_history": condition_history,
            "source_lag_samples": source_lag,
            "condition_lag_samples": condition_lag,
            "state_representation": "analyst_supplied_integer_codes",
            "conditioning_interpretation": (
                "incremental_directed_predictive_information"
            ),
            "automatic_discretization": False,
            "automatic_history_selection": False,
            "automatic_lag_selection": False,
            "automatic_support_filtering": False,
            "causal_identification_claimed": False,
        },
    )

conditional_transfer_entropy_local_frame

conditional_transfer_entropy_local_frame(result: ConditionalTransferEntropyResult) -> pd.DataFrame

Return local conditional-TE contributions and exact histories.

Source code in src/eyetrajectoriespy/conditional_transfer_entropy.py
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
def conditional_transfer_entropy_local_frame(
    result: ConditionalTransferEntropyResult,
) -> pd.DataFrame:
    """Return local conditional-TE contributions and exact histories."""

    if not isinstance(result, ConditionalTransferEntropyResult):
        raise TypeError(
            "result must be a ConditionalTransferEntropyResult"
        )

    rows = []
    for index, time_index in enumerate(result.time_indices):
        rows.append(
            {
                "time_index": int(time_index),
                "target_state": int(
                    result.target_states[time_index]
                ),
                "target_history": tuple(
                    int(value)
                    for value in result.target_history_states[index]
                ),
                "source_history": tuple(
                    int(value)
                    for value in result.source_history_states[index]
                ),
                "condition_history": tuple(
                    int(value)
                    for value in result.condition_history_states[index]
                ),
                "local_conditional_transfer_entropy_bits": float(
                    result.local_conditional_transfer_entropy_bits[index]
                ),
            }
        )
    return pd.DataFrame(rows)

conditional_transfer_entropy_circular_shift_test

conditional_transfer_entropy_circular_shift_test(source: Sequence[int] | ndarray, target: Sequence[int] | ndarray, condition: Sequence[int] | ndarray, *, target_history: int, source_history: int, condition_history: int, source_lag: int, condition_lag: int, shifts: Sequence[int]) -> ConditionalTransferEntropyCircularShiftTestResult

Test conditional TE using analyst-declared source-only circular shifts.

Only the source sequence is shifted. Target and conditioning sequences stay fixed. The returned p-value is the plus-one upper-tail Monte Carlo value. This is a predictive-information null under a declared circular-shift construction, not a causal-identification test.

Source code in src/eyetrajectoriespy/conditional_transfer_entropy.py
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
def conditional_transfer_entropy_circular_shift_test(
    source: Sequence[int] | np.ndarray,
    target: Sequence[int] | np.ndarray,
    condition: Sequence[int] | np.ndarray,
    *,
    target_history: int,
    source_history: int,
    condition_history: int,
    source_lag: int,
    condition_lag: int,
    shifts: Sequence[int],
) -> ConditionalTransferEntropyCircularShiftTestResult:
    """Test conditional TE using analyst-declared source-only circular shifts.

    Only the source sequence is shifted. Target and conditioning sequences stay
    fixed. The returned p-value is the plus-one upper-tail Monte Carlo value.
    This is a predictive-information null under a declared circular-shift
    construction, not a causal-identification test.
    """

    source_states = _discrete_states(source, "source")
    target_states = _discrete_states(target, "target")
    condition_states = _discrete_states(condition, "condition")
    if not (
        source_states.shape == target_states.shape == condition_states.shape
    ):
        raise ValueError(
            "source, target, and condition must have the same length"
        )
    resolved_shifts = _resolve_shifts(
        shifts,
        n_observations=source_states.size,
    )

    observed = conditional_transfer_entropy(
        source_states,
        target_states,
        condition_states,
        target_history=target_history,
        source_history=source_history,
        condition_history=condition_history,
        source_lag=source_lag,
        condition_lag=condition_lag,
    )
    surrogate = np.asarray(
        [
            conditional_transfer_entropy(
                np.roll(source_states, shift),
                target_states,
                condition_states,
                target_history=target_history,
                source_history=source_history,
                condition_history=condition_history,
                source_lag=source_lag,
                condition_lag=condition_lag,
            ).conditional_transfer_entropy_bits
            for shift in resolved_shifts
        ],
        dtype=float,
    )
    surrogate_mean = float(np.mean(surrogate))
    upper_tail = float(
        (
            1
            + np.count_nonzero(
                surrogate
                >= observed.conditional_transfer_entropy_bits
            )
        )
        / (surrogate.size + 1)
    )

    return ConditionalTransferEntropyCircularShiftTestResult(
        observed=observed,
        shifts=resolved_shifts,
        surrogate_conditional_transfer_entropy_bits=surrogate,
        surrogate_mean_bits=surrogate_mean,
        surrogate_centered_conditional_transfer_entropy_bits=float(
            observed.conditional_transfer_entropy_bits - surrogate_mean
        ),
        upper_tail_p_value=upper_tail,
        p_value_resolution=float(1.0 / (surrogate.size + 1)),
        provenance={
            "operation": (
                "conditional_transfer_entropy_circular_shift_test"
            ),
            "null": (
                "analyst_declared_source_only_circular_shifts"
            ),
            "tail": "upper",
            "p_value": "plus_one_monte_carlo",
            "shifts_samples": resolved_shifts.tolist(),
            "shifted_process": "source",
            "fixed_processes": ["target", "condition"],
            "observed_contract": dict(observed.provenance),
            "automatic_shift_generation": False,
            "causal_identification_claimed": False,
        },
    )

plot_conditional_transfer_entropy_circular_shift_test

plot_conditional_transfer_entropy_circular_shift_test(result: ConditionalTransferEntropyCircularShiftTestResult, *, ax=None, bins: int | str = 'auto')

Plot the source-shift null distribution and observed conditional TE.

Source code in src/eyetrajectoriespy/conditional_transfer_entropy.py
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
def plot_conditional_transfer_entropy_circular_shift_test(
    result: ConditionalTransferEntropyCircularShiftTestResult,
    *,
    ax=None,
    bins: int | str = "auto",
):
    """Plot the source-shift null distribution and observed conditional TE."""

    if not isinstance(
        result,
        ConditionalTransferEntropyCircularShiftTestResult,
    ):
        raise TypeError(
            "result must be a "
            "ConditionalTransferEntropyCircularShiftTestResult"
        )
    if ax is None:
        _, ax = plt.subplots()
    ax.hist(
        result.surrogate_conditional_transfer_entropy_bits,
        bins=bins,
    )
    ax.axvline(
        result.observed.conditional_transfer_entropy_bits,
        linestyle="--",
    )
    ax.set_xlabel("Conditional transfer entropy (bits)")
    ax.set_ylabel("Source-only circular-shift surrogates")
    ax.set_title("Conditional-TE circular-shift test")
    return ax

conditional_transfer_entropy_reporting_text

conditional_transfer_entropy_reporting_text(result: ConditionalTransferEntropyResult) -> str

Return compact manuscript-oriented text for conditional TE.

Source code in src/eyetrajectoriespy/conditional_transfer_entropy.py
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
def conditional_transfer_entropy_reporting_text(
    result: ConditionalTransferEntropyResult,
) -> str:
    """Return compact manuscript-oriented text for conditional TE."""

    if not isinstance(result, ConditionalTransferEntropyResult):
        raise TypeError(
            "result must be a ConditionalTransferEntropyResult"
        )
    return (
        "Discrete empirical conditional transfer entropy from source to target "
        f"given the declared conditioning process was "
        f"{result.conditional_transfer_entropy_bits:.6g} bits using target "
        f"history k={result.target_history}, source history "
        f"l={result.source_history}, conditioning history "
        f"m={result.condition_history}, source lag d={result.source_lag}, "
        f"and conditioning lag c={result.condition_lag} sample(s), based on "
        f"{result.n_effective} effective transitions. Full joint-history "
        f"support contained {result.n_joint_histories} observed states, with "
        f"{result.singleton_joint_history_fraction:.3f} occurring once "
        f"(minimum/mean/maximum cell count "
        f"{result.min_joint_history_count}/"
        f"{result.mean_joint_history_count:.3g}/"
        f"{result.max_joint_history_count}). "
        "This measures incremental directed predictive information after "
        "conditioning on the explicitly supplied process; it does not "
        "establish causal influence or guarantee adjustment for unmeasured "
        "common drivers."
    )

conditional_transfer_entropy_circular_shift_reporting_text

conditional_transfer_entropy_circular_shift_reporting_text(result: ConditionalTransferEntropyCircularShiftTestResult) -> str

Return compact reporting text for a source-shift conditional-TE test.

Source code in src/eyetrajectoriespy/conditional_transfer_entropy.py
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
def conditional_transfer_entropy_circular_shift_reporting_text(
    result: ConditionalTransferEntropyCircularShiftTestResult,
) -> str:
    """Return compact reporting text for a source-shift conditional-TE test."""

    if not isinstance(
        result,
        ConditionalTransferEntropyCircularShiftTestResult,
    ):
        raise TypeError(
            "result must be a "
            "ConditionalTransferEntropyCircularShiftTestResult"
        )

    return (
        "Observed conditional transfer entropy was "
        f"{result.observed.conditional_transfer_entropy_bits:.6g} bits; "
        f"the mean across {result.shifts.size} analyst-declared source-only "
        f"circular shifts was {result.surrogate_mean_bits:.6g} bits, giving "
        "a surrogate-centered difference of "
        f"{result.surrogate_centered_conditional_transfer_entropy_bits:.6g} "
        "bits. The plus-one upper-tail Monte Carlo p-value was "
        f"{result.upper_tail_p_value:.6g} (minimum attainable resolution "
        f"{result.p_value_resolution:.6g}). Target and conditioning processes "
        "were held fixed while only the source was shifted. The null concerns "
        "additional directed predictive information under the declared "
        "conditioning and wrap-around assumptions; it does not establish "
        "causal influence."
    )

eyetrajectoriespy.conditional_transfer_entropy_local_frame

conditional_transfer_entropy_local_frame(result: ConditionalTransferEntropyResult) -> pd.DataFrame

Return local conditional-TE contributions and exact histories.

Source code in src/eyetrajectoriespy/conditional_transfer_entropy.py
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
def conditional_transfer_entropy_local_frame(
    result: ConditionalTransferEntropyResult,
) -> pd.DataFrame:
    """Return local conditional-TE contributions and exact histories."""

    if not isinstance(result, ConditionalTransferEntropyResult):
        raise TypeError(
            "result must be a ConditionalTransferEntropyResult"
        )

    rows = []
    for index, time_index in enumerate(result.time_indices):
        rows.append(
            {
                "time_index": int(time_index),
                "target_state": int(
                    result.target_states[time_index]
                ),
                "target_history": tuple(
                    int(value)
                    for value in result.target_history_states[index]
                ),
                "source_history": tuple(
                    int(value)
                    for value in result.source_history_states[index]
                ),
                "condition_history": tuple(
                    int(value)
                    for value in result.condition_history_states[index]
                ),
                "local_conditional_transfer_entropy_bits": float(
                    result.local_conditional_transfer_entropy_bits[index]
                ),
            }
        )
    return pd.DataFrame(rows)

eyetrajectoriespy.conditional_transfer_entropy_circular_shift_test

conditional_transfer_entropy_circular_shift_test(source: Sequence[int] | ndarray, target: Sequence[int] | ndarray, condition: Sequence[int] | ndarray, *, target_history: int, source_history: int, condition_history: int, source_lag: int, condition_lag: int, shifts: Sequence[int]) -> ConditionalTransferEntropyCircularShiftTestResult

Test conditional TE using analyst-declared source-only circular shifts.

Only the source sequence is shifted. Target and conditioning sequences stay fixed. The returned p-value is the plus-one upper-tail Monte Carlo value. This is a predictive-information null under a declared circular-shift construction, not a causal-identification test.

Source code in src/eyetrajectoriespy/conditional_transfer_entropy.py
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
def conditional_transfer_entropy_circular_shift_test(
    source: Sequence[int] | np.ndarray,
    target: Sequence[int] | np.ndarray,
    condition: Sequence[int] | np.ndarray,
    *,
    target_history: int,
    source_history: int,
    condition_history: int,
    source_lag: int,
    condition_lag: int,
    shifts: Sequence[int],
) -> ConditionalTransferEntropyCircularShiftTestResult:
    """Test conditional TE using analyst-declared source-only circular shifts.

    Only the source sequence is shifted. Target and conditioning sequences stay
    fixed. The returned p-value is the plus-one upper-tail Monte Carlo value.
    This is a predictive-information null under a declared circular-shift
    construction, not a causal-identification test.
    """

    source_states = _discrete_states(source, "source")
    target_states = _discrete_states(target, "target")
    condition_states = _discrete_states(condition, "condition")
    if not (
        source_states.shape == target_states.shape == condition_states.shape
    ):
        raise ValueError(
            "source, target, and condition must have the same length"
        )
    resolved_shifts = _resolve_shifts(
        shifts,
        n_observations=source_states.size,
    )

    observed = conditional_transfer_entropy(
        source_states,
        target_states,
        condition_states,
        target_history=target_history,
        source_history=source_history,
        condition_history=condition_history,
        source_lag=source_lag,
        condition_lag=condition_lag,
    )
    surrogate = np.asarray(
        [
            conditional_transfer_entropy(
                np.roll(source_states, shift),
                target_states,
                condition_states,
                target_history=target_history,
                source_history=source_history,
                condition_history=condition_history,
                source_lag=source_lag,
                condition_lag=condition_lag,
            ).conditional_transfer_entropy_bits
            for shift in resolved_shifts
        ],
        dtype=float,
    )
    surrogate_mean = float(np.mean(surrogate))
    upper_tail = float(
        (
            1
            + np.count_nonzero(
                surrogate
                >= observed.conditional_transfer_entropy_bits
            )
        )
        / (surrogate.size + 1)
    )

    return ConditionalTransferEntropyCircularShiftTestResult(
        observed=observed,
        shifts=resolved_shifts,
        surrogate_conditional_transfer_entropy_bits=surrogate,
        surrogate_mean_bits=surrogate_mean,
        surrogate_centered_conditional_transfer_entropy_bits=float(
            observed.conditional_transfer_entropy_bits - surrogate_mean
        ),
        upper_tail_p_value=upper_tail,
        p_value_resolution=float(1.0 / (surrogate.size + 1)),
        provenance={
            "operation": (
                "conditional_transfer_entropy_circular_shift_test"
            ),
            "null": (
                "analyst_declared_source_only_circular_shifts"
            ),
            "tail": "upper",
            "p_value": "plus_one_monte_carlo",
            "shifts_samples": resolved_shifts.tolist(),
            "shifted_process": "source",
            "fixed_processes": ["target", "condition"],
            "observed_contract": dict(observed.provenance),
            "automatic_shift_generation": False,
            "causal_identification_claimed": False,
        },
    )

eyetrajectoriespy.plot_conditional_transfer_entropy_circular_shift_test

plot_conditional_transfer_entropy_circular_shift_test(result: ConditionalTransferEntropyCircularShiftTestResult, *, ax=None, bins: int | str = 'auto')

Plot the source-shift null distribution and observed conditional TE.

Source code in src/eyetrajectoriespy/conditional_transfer_entropy.py
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
def plot_conditional_transfer_entropy_circular_shift_test(
    result: ConditionalTransferEntropyCircularShiftTestResult,
    *,
    ax=None,
    bins: int | str = "auto",
):
    """Plot the source-shift null distribution and observed conditional TE."""

    if not isinstance(
        result,
        ConditionalTransferEntropyCircularShiftTestResult,
    ):
        raise TypeError(
            "result must be a "
            "ConditionalTransferEntropyCircularShiftTestResult"
        )
    if ax is None:
        _, ax = plt.subplots()
    ax.hist(
        result.surrogate_conditional_transfer_entropy_bits,
        bins=bins,
    )
    ax.axvline(
        result.observed.conditional_transfer_entropy_bits,
        linestyle="--",
    )
    ax.set_xlabel("Conditional transfer entropy (bits)")
    ax.set_ylabel("Source-only circular-shift surrogates")
    ax.set_title("Conditional-TE circular-shift test")
    return ax

eyetrajectoriespy.conditional_transfer_entropy_reporting_text

conditional_transfer_entropy_reporting_text(result: ConditionalTransferEntropyResult) -> str

Return compact manuscript-oriented text for conditional TE.

Source code in src/eyetrajectoriespy/conditional_transfer_entropy.py
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
def conditional_transfer_entropy_reporting_text(
    result: ConditionalTransferEntropyResult,
) -> str:
    """Return compact manuscript-oriented text for conditional TE."""

    if not isinstance(result, ConditionalTransferEntropyResult):
        raise TypeError(
            "result must be a ConditionalTransferEntropyResult"
        )
    return (
        "Discrete empirical conditional transfer entropy from source to target "
        f"given the declared conditioning process was "
        f"{result.conditional_transfer_entropy_bits:.6g} bits using target "
        f"history k={result.target_history}, source history "
        f"l={result.source_history}, conditioning history "
        f"m={result.condition_history}, source lag d={result.source_lag}, "
        f"and conditioning lag c={result.condition_lag} sample(s), based on "
        f"{result.n_effective} effective transitions. Full joint-history "
        f"support contained {result.n_joint_histories} observed states, with "
        f"{result.singleton_joint_history_fraction:.3f} occurring once "
        f"(minimum/mean/maximum cell count "
        f"{result.min_joint_history_count}/"
        f"{result.mean_joint_history_count:.3g}/"
        f"{result.max_joint_history_count}). "
        "This measures incremental directed predictive information after "
        "conditioning on the explicitly supplied process; it does not "
        "establish causal influence or guarantee adjustment for unmeasured "
        "common drivers."
    )

eyetrajectoriespy.conditional_transfer_entropy_circular_shift_reporting_text

conditional_transfer_entropy_circular_shift_reporting_text(result: ConditionalTransferEntropyCircularShiftTestResult) -> str

Return compact reporting text for a source-shift conditional-TE test.

Source code in src/eyetrajectoriespy/conditional_transfer_entropy.py
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
def conditional_transfer_entropy_circular_shift_reporting_text(
    result: ConditionalTransferEntropyCircularShiftTestResult,
) -> str:
    """Return compact reporting text for a source-shift conditional-TE test."""

    if not isinstance(
        result,
        ConditionalTransferEntropyCircularShiftTestResult,
    ):
        raise TypeError(
            "result must be a "
            "ConditionalTransferEntropyCircularShiftTestResult"
        )

    return (
        "Observed conditional transfer entropy was "
        f"{result.observed.conditional_transfer_entropy_bits:.6g} bits; "
        f"the mean across {result.shifts.size} analyst-declared source-only "
        f"circular shifts was {result.surrogate_mean_bits:.6g} bits, giving "
        "a surrogate-centered difference of "
        f"{result.surrogate_centered_conditional_transfer_entropy_bits:.6g} "
        "bits. The plus-one upper-tail Monte Carlo p-value was "
        f"{result.upper_tail_p_value:.6g} (minimum attainable resolution "
        f"{result.p_value_resolution:.6g}). Target and conditioning processes "
        "were held fixed while only the source was shifted. The null concerns "
        "additional directed predictive information under the declared "
        "conditioning and wrap-around assumptions; it does not establish "
        "causal influence."
    )

Functional mixed-effects simultaneous inference

eyetrajectoriespy.FunctionalMixedEffectsBootstrapResult dataclass

Participant-cluster bootstrap for functional mixed-effects coefficients.

Source code in src/eyetrajectoriespy/types.py
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
@dataclass(frozen=True)
class FunctionalMixedEffectsBootstrapResult:
    """Participant-cluster bootstrap for functional mixed-effects coefficients."""

    reference: FunctionalMixedEffectsResult
    bootstrap_fixed_basis_coefficients: np.ndarray
    bootstrap_coefficient_functions: np.ndarray
    sampled_participant_indices: np.ndarray
    bootstrap_mean: np.ndarray
    bootstrap_standard_errors: np.ndarray
    random_state: int | None
    covariance_conditioning: str
    provenance: Mapping[str, Any] = field(default_factory=dict)

    @property
    def n_bootstrap(self) -> int:
        return self.bootstrap_coefficient_functions.shape[0]

    @property
    def n_participants(self) -> int:
        return self.sampled_participant_indices.shape[1]

eyetrajectoriespy.FunctionalMixedEffectsBandResult dataclass

Observed-grid simultaneous bands for mixed-effects coefficient functions.

Source code in src/eyetrajectoriespy/types.py
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
@dataclass(frozen=True)
class FunctionalMixedEffectsBandResult:
    """Observed-grid simultaneous bands for mixed-effects coefficient functions."""

    reference: FunctionalMixedEffectsResult
    lower: np.ndarray
    upper: np.ndarray
    pointwise_standard_errors: np.ndarray
    critical_values: np.ndarray
    max_statistics: np.ndarray
    confidence_level: float
    simultaneous_scope: str
    bootstrap: (
        FunctionalMixedEffectsBootstrapResult
        | FunctionalMixedEffectsFullRefitBootstrapResult
    )
    provenance: Mapping[str, Any] = field(default_factory=dict)

    @property
    def n_coefficients(self) -> int:
        return self.lower.shape[0]

    @property
    def n_time(self) -> int:
        return self.lower.shape[1]

eyetrajectoriespy.bootstrap_functional_mixed_effects_coefficients

bootstrap_functional_mixed_effects_coefficients(result: FunctionalMixedEffectsResult, *, n_bootstrap: int = 1000, random_state: int | None = 0) -> FunctionalMixedEffectsBootstrapResult

Bootstrap mixed-effects coefficient functions by participant clusters.

Whole participant trial bundles are sampled with replacement. For every resample, the fixed B-spline coefficients are re-estimated by GLS while the reference random-effect covariance and residual variance are held fixed.

This targets participant-level sampling variability in the fixed coefficient functions conditional on the fitted covariance model and declared bases. It is not a full variance-component-refitting bootstrap.

Source code in src/eyetrajectoriespy/functional_mixed_effects_inference.py
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
def bootstrap_functional_mixed_effects_coefficients(
    result: FunctionalMixedEffectsResult,
    *,
    n_bootstrap: int = 1000,
    random_state: int | None = 0,
) -> FunctionalMixedEffectsBootstrapResult:
    """Bootstrap mixed-effects coefficient functions by participant clusters.

    Whole participant trial bundles are sampled with replacement. For every
    resample, the fixed B-spline coefficients are re-estimated by GLS while the
    reference random-effect covariance and residual variance are held fixed.

    This targets participant-level sampling variability in the fixed coefficient
    functions conditional on the fitted covariance model and declared bases. It
    is not a full variance-component-refitting bootstrap.
    """

    _validate_reference(result)
    if isinstance(n_bootstrap, bool) or not isinstance(
        n_bootstrap, (int, np.integer)
    ):
        raise TypeError("n_bootstrap must be an integer")
    n_bootstrap = int(n_bootstrap)
    if n_bootstrap < 100:
        raise ValueError("n_bootstrap must be at least 100")
    if random_state is not None and (
        isinstance(random_state, bool)
        or not isinstance(random_state, (int, np.integer))
    ):
        raise TypeError("random_state must be an integer or None")

    information, scores, reconstruction_difference = (
        _participant_gls_contributions(result)
    )
    rng = np.random.default_rng(
        None if random_state is None else int(random_state)
    )
    sampled_participant_indices = rng.integers(
        0,
        result.n_participants,
        size=(n_bootstrap, result.n_participants),
    )
    bootstrap_fixed_basis_coefficients = np.empty(
        (
            n_bootstrap,
            result.n_coefficients,
            result.fixed_basis_size,
        ),
        dtype=float,
    )
    bootstrap_coefficient_functions = np.empty(
        (
            n_bootstrap,
            result.n_coefficients,
            result.time.size,
        ),
        dtype=float,
    )

    n_fixed_parameters = (
        result.n_coefficients * result.fixed_basis_size
    )
    for bootstrap_index, participant_sample in enumerate(
        sampled_participant_indices
    ):
        information_star = np.sum(
            information[participant_sample],
            axis=0,
        )
        score_star = np.sum(
            scores[participant_sample],
            axis=0,
        )
        if np.linalg.matrix_rank(information_star) < n_fixed_parameters:
            raise RuntimeError(
                "participant-cluster bootstrap replicate "
                f"{bootstrap_index + 1} produced a rank-deficient fixed-effect "
                "information matrix; no replicate was silently discarded"
            )
        try:
            fixed_star = np.linalg.solve(
                information_star,
                score_star,
            )
        except np.linalg.LinAlgError as exc:
            raise RuntimeError(
                "participant-cluster bootstrap replicate "
                f"{bootstrap_index + 1} could not solve the fixed-effect "
                "GLS system; no replicate was silently discarded"
            ) from exc

        basis_coefficients = fixed_star.reshape(
            result.n_coefficients,
            result.fixed_basis_size,
        )
        bootstrap_fixed_basis_coefficients[
            bootstrap_index
        ] = basis_coefficients
        bootstrap_coefficient_functions[
            bootstrap_index
        ] = basis_coefficients @ result.fixed_basis.T

    bootstrap_mean = np.mean(
        bootstrap_coefficient_functions,
        axis=0,
    )
    bootstrap_standard_errors = np.std(
        bootstrap_coefficient_functions,
        axis=0,
        ddof=1,
    )

    return FunctionalMixedEffectsBootstrapResult(
        reference=result,
        bootstrap_fixed_basis_coefficients=(
            bootstrap_fixed_basis_coefficients
        ),
        bootstrap_coefficient_functions=(
            bootstrap_coefficient_functions
        ),
        sampled_participant_indices=sampled_participant_indices,
        bootstrap_mean=bootstrap_mean,
        bootstrap_standard_errors=bootstrap_standard_errors,
        random_state=(
            None if random_state is None else int(random_state)
        ),
        covariance_conditioning=(
            "reference_participant_and_trial_random_effect_covariances_and_residual_covariance_fixed"
            if result.trial_random_effect is not None
            else "reference_participant_and_residual_covariance_fixed"
        ),
        provenance={
            **dict(result.provenance),
            "functional_mixed_effects_bootstrap": {
                "method": (
                    "participant_cluster_case_bootstrap_fixed_covariance_gls"
                ),
                "n_bootstrap": n_bootstrap,
                "random_state": (
                    None
                    if random_state is None
                    else int(random_state)
                ),
                "resampling_unit": "participant",
                "whole_trial_bundles_resampled": True,
                "curves_resampled_independently": False,
                "participant_draws_with_replacement": True,
                "fixed_effects_reestimated_each_replicate": True,
                "variance_components_refit": False,
                "random_effect_covariance_conditioned_on_reference": True,
                "trial_random_effect_covariance_conditioned_on_reference": (
                    result.trial_random_effect is not None
                ),
                "reference_trial_random_effect": result.trial_random_effect,
                "reference_trial_random_basis_size": (
                    result.trial_random_basis_size
                ),
                "residual_variance_conditioned_on_reference": True,
                "residual_correlation_conditioned_on_reference": True,
                "reference_residual_correlation": (
                    result.residual_correlation
                ),
                "reference_residual_correlation_parameter": (
                    result.residual_correlation_parameter
                ),
                "reference_residual_correlation_parameter_name": (
                    result.residual_correlation_parameter_name
                ),
                "fixed_basis_refit": False,
                "random_basis_refit": False,
                "basis_selection_repeated": False,
                "reference_boundary_fit": bool(result.boundary_fit),
                "reference_random_slope_predictor": (
                    result.random_slope_predictor
                ),
                "reference_random_effect_dimension": (
                    result.random_effect_dimension
                ),
                "random_intercept_slope_covariance_conditioned_on_reference": (
                    result.random_slope_predictor is not None
                ),
                "reference_gls_max_abs_difference": float(
                    reconstruction_difference[0]
                ),
                "failed_replicate_policy": "raise",
                "simultaneous_band_calibration": False,
            },
        },
    )

eyetrajectoriespy.functional_mixed_effects_simultaneous_bands

functional_mixed_effects_simultaneous_bands(bootstrap: FunctionalMixedEffectsBootstrapResult | FunctionalMixedEffectsFullRefitBootstrapResult, *, confidence_level: float = 0.95, simultaneous_scope: str = 'coefficient') -> FunctionalMixedEffectsBandResult

Calibrate observed-grid simultaneous mixed-effects coefficient bands.

Source code in src/eyetrajectoriespy/functional_mixed_effects_inference.py
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
def functional_mixed_effects_simultaneous_bands(
    bootstrap: (
        FunctionalMixedEffectsBootstrapResult
        | FunctionalMixedEffectsFullRefitBootstrapResult
    ),
    *,
    confidence_level: float = 0.95,
    simultaneous_scope: str = "coefficient",
) -> FunctionalMixedEffectsBandResult:
    """Calibrate observed-grid simultaneous mixed-effects coefficient bands."""

    if not isinstance(
        bootstrap,
        (
            FunctionalMixedEffectsBootstrapResult,
            FunctionalMixedEffectsFullRefitBootstrapResult,
        ),
    ):
        raise TypeError(
            "bootstrap must be a FunctionalMixedEffectsBootstrapResult or "
            "FunctionalMixedEffectsFullRefitBootstrapResult"
        )
    if not 0 < confidence_level < 1:
        raise ValueError("confidence_level must lie in (0, 1)")
    if simultaneous_scope not in {"coefficient", "family"}:
        raise ValueError(
            "simultaneous_scope must be 'coefficient' or 'family'"
        )

    reference = bootstrap.reference
    standard_errors = np.asarray(
        bootstrap.bootstrap_standard_errors,
        dtype=float,
    )
    if not np.all(np.isfinite(standard_errors)):
        raise RuntimeError(
            "bootstrap pointwise standard errors contain non-finite values"
        )
    positive = standard_errors > np.finfo(float).eps
    if not np.all(positive):
        raise RuntimeError(
            "bootstrap pointwise standard errors contain zero-width cells; "
            "simultaneous studentization is undefined"
        )

    deviations = (
        bootstrap.bootstrap_coefficient_functions
        - bootstrap.bootstrap_mean[None, :, :]
    )
    standardized = deviations / standard_errors[None, :, :]
    absolute = np.abs(standardized)

    if simultaneous_scope == "coefficient":
        max_statistics = np.max(absolute, axis=2)
        critical_values = np.quantile(
            max_statistics,
            confidence_level,
            axis=0,
            method="higher",
        )
    else:
        family_max = np.max(absolute, axis=(1, 2))
        critical = float(
            np.quantile(
                family_max,
                confidence_level,
                method="higher",
            )
        )
        max_statistics = family_max[:, None]
        critical_values = np.full(
            reference.n_coefficients,
            critical,
            dtype=float,
        )

    lower = (
        reference.coefficient_functions
        - critical_values[:, None] * standard_errors
    )
    upper = (
        reference.coefficient_functions
        + critical_values[:, None] * standard_errors
    )

    return FunctionalMixedEffectsBandResult(
        reference=reference,
        lower=lower,
        upper=upper,
        pointwise_standard_errors=standard_errors.copy(),
        critical_values=np.asarray(
            critical_values,
            dtype=float,
        ),
        max_statistics=np.asarray(
            max_statistics,
            dtype=float,
        ),
        confidence_level=float(confidence_level),
        simultaneous_scope=simultaneous_scope,
        bootstrap=bootstrap,
        provenance={
            **dict(bootstrap.provenance),
            "functional_mixed_effects_simultaneous_bands": {
                "method": (
                    "participant_cluster_bootstrap_studentized_supremum"
                ),
                "confidence_level": float(confidence_level),
                "simultaneous_scope": simultaneous_scope,
                "simultaneous_domain": (
                    "observed_time_grid_per_coefficient"
                    if simultaneous_scope == "coefficient"
                    else "coefficient_by_observed_time_grid"
                ),
                "bootstrap_centering_for_calibration": (
                    "bootstrap_mean"
                ),
                "band_center": "reference_estimate",
                "bias_correction": False,
                "pointwise_scale": "participant_cluster_bootstrap_sd",
                "continuous_between_grid_points": False,
                "variance_components_refit": isinstance(
                    bootstrap,
                    FunctionalMixedEffectsFullRefitBootstrapResult,
                ),
                "failed_replicate_policy": "raise",
            },
        },
    )

Random functional slope inspection

eyetrajectoriespy.functional_random_effect_frame

functional_random_effect_frame(result: FunctionalMixedEffectsResult, *, effect: str = 'intercept') -> pd.DataFrame

Return participant BLUP functional random effects in long form.

Source code in src/eyetrajectoriespy/functional_mixed_effects.py
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
def functional_random_effect_frame(
    result: FunctionalMixedEffectsResult,
    *,
    effect: str = "intercept",
) -> pd.DataFrame:
    """Return participant BLUP functional random effects in long form."""

    if not isinstance(result, FunctionalMixedEffectsResult):
        raise TypeError("result must be a FunctionalMixedEffectsResult")
    if effect not in {"intercept", "slope"}:
        raise ValueError("effect must be 'intercept' or 'slope'")

    if effect == "intercept":
        functions = result.random_intercept_functions
        predictor = None
    else:
        if result.random_slope_functions is None:
            raise ValueError(
                "result does not contain a participant random functional slope"
            )
        functions = result.random_slope_functions
        predictor = result.random_slope_predictor

    rows: list[dict[str, float | str | None]] = []
    for participant_index, participant_id in enumerate(result.participant_ids):
        for time_index, time_value in enumerate(result.time):
            rows.append(
                {
                    "participant_id": participant_id,
                    "time": float(time_value),
                    "dimension": result.dimension_name,
                    "effect": effect,
                    "random_slope_predictor": predictor,
                    "estimate": float(
                        functions[participant_index, time_index]
                    ),
                }
            )
    return pd.DataFrame(rows)

eyetrajectoriespy.plot_functional_random_effects

plot_functional_random_effects(result: FunctionalMixedEffectsResult, *, effect: str = 'intercept', max_participants: int | None = None, alpha: float = 0.35, ax=None)

Plot participant BLUP random-intercept or random-slope functions.

Source code in src/eyetrajectoriespy/plotting.py
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
def plot_functional_random_effects(
    result: FunctionalMixedEffectsResult,
    *,
    effect: str = "intercept",
    max_participants: int | None = None,
    alpha: float = 0.35,
    ax=None,
):
    """Plot participant BLUP random-intercept or random-slope functions."""

    if not isinstance(result, FunctionalMixedEffectsResult):
        raise TypeError("result must be a FunctionalMixedEffectsResult")
    if effect not in {"intercept", "slope"}:
        raise ValueError("effect must be 'intercept' or 'slope'")
    if max_participants is not None:
        if isinstance(max_participants, bool) or not isinstance(
            max_participants,
            (int, np.integer),
        ):
            raise TypeError("max_participants must be an integer or None")
        if max_participants < 1:
            raise ValueError("max_participants must be positive")
    if not 0 < alpha <= 1:
        raise ValueError("alpha must lie in (0, 1]")

    if effect == "intercept":
        functions = result.random_intercept_functions
        title = "Participant functional random intercepts"
    else:
        if result.random_slope_functions is None:
            raise ValueError(
                "result does not contain a participant random functional slope"
            )
        functions = result.random_slope_functions
        title = (
            "Participant functional random slopes: "
            f"{result.random_slope_predictor}"
        )

    n_participants = result.n_participants
    n_plot = (
        n_participants
        if max_participants is None
        else min(n_participants, int(max_participants))
    )
    if ax is None:
        _, ax = plt.subplots()

    for participant_index in range(n_plot):
        ax.plot(
            result.time,
            functions[participant_index],
            alpha=alpha,
            label=(
                result.participant_ids[participant_index]
                if n_plot <= 12
                else None
            ),
        )
    ax.axhline(0.0, linestyle="--")
    ax.set_xlabel(f"Time ({result.time_unit})")
    ax.set_ylabel(f"Random {effect}: {result.dimension_name}")
    ax.set_title(title)
    if n_plot <= 12:
        ax.legend()
    return ax

Trial functional random-effect inspection

eyetrajectoriespy.functional_trial_random_effect_frame

functional_trial_random_effect_frame(result: FunctionalMixedEffectsResult) -> pd.DataFrame

Return one row per nested trial and time point for trial BLUPs.

Source code in src/eyetrajectoriespy/functional_mixed_effects_nested.py
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
def functional_trial_random_effect_frame(
    result: FunctionalMixedEffectsResult,
) -> pd.DataFrame:
    """Return one row per nested trial and time point for trial BLUPs."""

    if not isinstance(result, FunctionalMixedEffectsResult):
        raise TypeError("result must be a FunctionalMixedEffectsResult")
    if (
        result.trial_random_effect != "functional_intercept"
        or result.trial_random_effect_functions is None
    ):
        raise ValueError(
            "result does not contain a trial functional random intercept"
        )

    rows: list[dict[str, object]] = []
    for curve_index, curve_id in enumerate(result.source_curve_ids):
        participant_id = result.curve_participant_ids[curve_index]
        source_trial_id = result.curve_trial_ids[curve_index]
        trial_id = result.trial_ids[curve_index]
        for time_index, time_value in enumerate(result.time):
            rows.append(
                {
                    "curve_index": curve_index,
                    "curve_id": curve_id,
                    "participant_id": participant_id,
                    "source_trial_id": source_trial_id,
                    "trial_id": trial_id,
                    "time": float(time_value),
                    "effect": "trial_functional_intercept",
                    "value": float(
                        result.trial_random_effect_functions[
                            curve_index,
                            time_index,
                        ]
                    ),
                }
            )
    return pd.DataFrame(rows)

eyetrajectoriespy.plot_functional_trial_random_effects

plot_functional_trial_random_effects(result: FunctionalMixedEffectsResult, *, participant_id: str | None = None, max_trials: int = 12, ax=None)

Plot retained trial-level functional BLUPs without hidden averaging.

Source code in src/eyetrajectoriespy/functional_mixed_effects_nested.py
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
def plot_functional_trial_random_effects(
    result: FunctionalMixedEffectsResult,
    *,
    participant_id: str | None = None,
    max_trials: int = 12,
    ax=None,
):
    """Plot retained trial-level functional BLUPs without hidden averaging."""

    import matplotlib.pyplot as plt

    frame = functional_trial_random_effect_frame(result)
    if isinstance(max_trials, bool) or not isinstance(max_trials, int):
        raise TypeError("max_trials must be an integer")
    if max_trials < 1:
        raise ValueError("max_trials must be positive")

    if participant_id is not None:
        participant_id = str(participant_id)
        frame = frame.loc[
            frame["participant_id"].astype(str) == participant_id
        ]
        if frame.empty:
            raise KeyError(f"Unknown participant_id {participant_id!r}")

    trial_ids = tuple(pd.unique(frame["trial_id"]))
    selected = trial_ids[:max_trials]
    frame = frame.loc[frame["trial_id"].isin(selected)]

    if ax is None:
        _, ax = plt.subplots()
    for trial_id, trial_frame in frame.groupby("trial_id", sort=False):
        ax.plot(
            trial_frame["time"],
            trial_frame["value"],
            label=str(trial_id),
        )
    ax.axhline(0.0, linewidth=1.0)
    ax.set_xlabel(f"Time ({result.time_unit})")
    ax.set_ylabel("Trial functional random intercept")
    title = "Trial-level functional random effects"
    if participant_id is not None:
        title += f": {participant_id}"
    ax.set_title(title)
    if len(selected) <= 12:
        ax.legend()
    return ax

Functional mixed-effects full-refit bootstrap

eyetrajectoriespy.FunctionalMixedEffectsFullRefitBootstrapResult dataclass

Whole-participant bootstrap with complete mixed-model refitting.

Source code in src/eyetrajectoriespy/types.py
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
@dataclass(frozen=True)
class FunctionalMixedEffectsFullRefitBootstrapResult:
    """Whole-participant bootstrap with complete mixed-model refitting."""

    reference: FunctionalMixedEffectsResult
    bootstrap_fixed_basis_coefficients: np.ndarray
    bootstrap_coefficient_functions: np.ndarray
    sampled_participant_indices: np.ndarray
    sampled_source_participant_ids: tuple[tuple[str, ...], ...]
    bootstrap_participant_ids: tuple[tuple[str, ...], ...]
    bootstrap_mean: np.ndarray
    bootstrap_standard_errors: np.ndarray
    random_effect_covariances: np.ndarray
    random_intercept_covariances: np.ndarray
    random_slope_covariances: np.ndarray | None
    random_intercept_slope_covariances: np.ndarray | None
    random_effect_covariance_eigenvalues: np.ndarray
    random_effect_covariance_condition_numbers: np.ndarray
    random_effect_boundary_flags: np.ndarray
    random_effect_singular_flags: np.ndarray
    random_slope_boundary_flags: np.ndarray
    residual_variances: np.ndarray
    log_likelihoods: np.ndarray
    convergence_flags: np.ndarray
    backend_warnings: tuple[tuple[str, ...], ...]
    random_state: int | None
    provenance: Mapping[str, Any] = field(default_factory=dict)
    trial_random_effect_covariances: np.ndarray | None = None
    trial_random_effect_covariance_eigenvalues: np.ndarray | None = None
    trial_random_effect_covariance_condition_numbers: np.ndarray | None = None
    trial_random_effect_boundary_flags: np.ndarray | None = None
    trial_random_effect_singular_flags: np.ndarray | None = None
    bootstrap_trial_audit: tuple[
        tuple[tuple[str, str, str, str], ...],
        ...,
    ] = ()
    residual_correlation_parameters: np.ndarray | None = None
    residual_correlation_boundary_flags: np.ndarray | None = None
    residual_correlation_independence_flags: np.ndarray | None = None
    residual_correlation_condition_numbers: np.ndarray | None = None

    @property
    def n_bootstrap(self) -> int:
        return self.bootstrap_coefficient_functions.shape[0]

    @property
    def n_participants(self) -> int:
        return self.sampled_participant_indices.shape[1]

    @property
    def variance_components_refit(self) -> bool:
        return True

eyetrajectoriespy.bootstrap_functional_mixed_effects_full_refit

bootstrap_functional_mixed_effects_full_refit(result: FunctionalMixedEffectsResult, *, n_bootstrap: int = 1000, random_state: int | None = 0) -> FunctionalMixedEffectsFullRefitBootstrapResult

Bootstrap whole participants and refit all mixed-model parameters.

Each sampled participant occurrence receives a distinct bootstrap group identity, even when the same source participant is drawn multiple times. Every bootstrap replicate refits fixed coefficients, the random-effect covariance, and residual variance under the original declared model specification.

Basis sizes, knots implied by the unchanged common grid, preprocessing, response dimension, predictors, random-slope structure, optimizer, REML/ML choice, and convergence policy are held fixed.

Source code in src/eyetrajectoriespy/functional_mixed_effects_full_refit.py
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
def bootstrap_functional_mixed_effects_full_refit(
    result: FunctionalMixedEffectsResult,
    *,
    n_bootstrap: int = 1000,
    random_state: int | None = 0,
) -> FunctionalMixedEffectsFullRefitBootstrapResult:
    """Bootstrap whole participants and refit all mixed-model parameters.

    Each sampled participant occurrence receives a distinct bootstrap group
    identity, even when the same source participant is drawn multiple times.
    Every bootstrap replicate refits fixed coefficients, the random-effect
    covariance, and residual variance under the original declared model
    specification.

    Basis sizes, knots implied by the unchanged common grid, preprocessing,
    response dimension, predictors, random-slope structure, optimizer, REML/ML
    choice, and convergence policy are held fixed.
    """

    _validate_full_refit_reference(result)
    n_bootstrap, random_state = _validate_bootstrap_controls(
        n_bootstrap=n_bootstrap,
        random_state=random_state,
    )
    rng = np.random.default_rng(random_state)
    sampled_participant_indices = rng.integers(
        0,
        result.n_participants,
        size=(n_bootstrap, result.n_participants),
    )

    coefficient_functions = np.empty(
        (
            n_bootstrap,
            result.n_coefficients,
            result.time.size,
        ),
        dtype=float,
    )
    fixed_basis_coefficients = np.empty(
        (
            n_bootstrap,
            result.n_coefficients,
            result.fixed_basis_size,
        ),
        dtype=float,
    )
    random_covariances = np.empty(
        (
            n_bootstrap,
            result.random_effect_dimension,
            result.random_effect_dimension,
        ),
        dtype=float,
    )
    intercept_covariances = np.empty(
        (
            n_bootstrap,
            result.random_basis_size,
            result.random_basis_size,
        ),
        dtype=float,
    )
    if result.random_slope_predictor is None:
        slope_covariances = None
        cross_covariances = None
    else:
        slope_covariances = np.empty_like(intercept_covariances)
        cross_covariances = np.empty_like(intercept_covariances)

    covariance_eigenvalues = np.empty(
        (n_bootstrap, result.random_effect_dimension),
        dtype=float,
    )
    covariance_condition_numbers = np.empty(n_bootstrap, dtype=float)
    boundary_flags = np.empty(n_bootstrap, dtype=bool)
    singular_flags = np.empty(n_bootstrap, dtype=bool)
    slope_boundary_flags = np.empty(n_bootstrap, dtype=bool)
    residual_variances = np.empty(n_bootstrap, dtype=float)
    log_likelihoods = np.empty(n_bootstrap, dtype=float)
    convergence_flags = np.empty(n_bootstrap, dtype=bool)
    warning_records: list[tuple[str, ...]] = []
    sampled_source_ids: list[tuple[str, ...]] = []
    sampled_bootstrap_ids: list[tuple[str, ...]] = []
    bootstrap_trial_audit: list[
        tuple[tuple[str, str, str, str], ...]
    ] = []
    if result.residual_correlation == "iid":
        residual_correlation_parameters = None
        residual_correlation_boundary_flags = None
        residual_correlation_independence_flags = None
        residual_correlation_condition_numbers = None
    else:
        residual_correlation_parameters = np.empty(
            n_bootstrap,
            dtype=float,
        )
        residual_correlation_boundary_flags = np.empty(
            n_bootstrap,
            dtype=bool,
        )
        residual_correlation_independence_flags = np.empty(
            n_bootstrap,
            dtype=bool,
        )
        residual_correlation_condition_numbers = np.empty(
            n_bootstrap,
            dtype=float,
        )
    if result.trial_random_effect is None:
        trial_covariances = None
        trial_covariance_eigenvalues = None
        trial_covariance_condition_numbers = None
        trial_boundary_flags = None
        trial_singular_flags = None
    else:
        trial_dimension = result.trial_random_basis_size
        trial_covariances = np.empty(
            (n_bootstrap, trial_dimension, trial_dimension),
            dtype=float,
        )
        trial_covariance_eigenvalues = np.empty(
            (n_bootstrap, trial_dimension),
            dtype=float,
        )
        trial_covariance_condition_numbers = np.empty(
            n_bootstrap,
            dtype=float,
        )
        trial_boundary_flags = np.empty(n_bootstrap, dtype=bool)
        trial_singular_flags = np.empty(n_bootstrap, dtype=bool)

    for bootstrap_index, participant_sample in enumerate(
        sampled_participant_indices
    ):
        (
            trajectories_star,
            design_star,
            source_ids,
            bootstrap_ids,
            trial_audit,
        ) = _bootstrap_dataset(
            result,
            participant_sample=participant_sample,
            bootstrap_index=bootstrap_index,
        )
        sampled_source_ids.append(source_ids)
        sampled_bootstrap_ids.append(bootstrap_ids)
        bootstrap_trial_audit.append(trial_audit)

        try:
            fit_star = fit_functional_mixed_effects_regression(
                trajectories_star,
                design_star,
                predictors=result.predictor_names,
                participant_column=result.participant_column,
                dimension=result.dimension_name,
                fixed_basis_size=result.fixed_basis_size,
                random_basis_size=result.random_basis_size,
                random_slope_predictor=result.random_slope_predictor,
                trial_column=result.trial_column,
                trial_random_effect=result.trial_random_effect,
                trial_random_basis_size=result.trial_random_basis_size,
                residual_correlation=result.residual_correlation,
                spline_degree=result.spline_degree,
                reml=result.reml,
                method=result.method,
                maxiter=result.maxiter,
            )
        except Exception as exc:
            raise RuntimeError(
                "full-refit participant bootstrap replicate "
                f"{bootstrap_index + 1} failed; no replicate was silently "
                "discarded or redrawn"
            ) from exc

        coefficient_functions[bootstrap_index] = (
            fit_star.coefficient_functions
        )
        fixed_basis_coefficients[bootstrap_index] = (
            fit_star.fixed_basis_coefficients
        )
        random_covariances[bootstrap_index] = (
            fit_star.random_effect_covariance
        )
        intercept_covariances[bootstrap_index] = (
            fit_star.random_intercept_covariance
        )
        if slope_covariances is not None:
            if (
                fit_star.random_slope_covariance is None
                or fit_star.random_intercept_slope_covariance is None
            ):
                raise RuntimeError(
                    "full-refit bootstrap lost the declared random-slope "
                    "covariance structure"
                )
            slope_covariances[bootstrap_index] = (
                fit_star.random_slope_covariance
            )
            cross_covariances[bootstrap_index] = (
                fit_star.random_intercept_slope_covariance
            )

        if trial_covariances is not None:
            if (
                fit_star.trial_random_effect_covariance is None
                or fit_star.trial_random_effect_covariance_eigenvalues is None
                or fit_star.trial_random_effect_covariance_condition_number
                is None
            ):
                raise RuntimeError(
                    "full-refit bootstrap lost the declared trial random-"
                    "effect covariance structure"
                )
            trial_covariances[bootstrap_index] = (
                fit_star.trial_random_effect_covariance
            )
            trial_covariance_eigenvalues[bootstrap_index] = (
                fit_star.trial_random_effect_covariance_eigenvalues
            )
            trial_covariance_condition_numbers[bootstrap_index] = (
                fit_star.trial_random_effect_covariance_condition_number
            )
            trial_boundary_flags[bootstrap_index] = (
                fit_star.trial_random_effect_boundary_fit
            )
            trial_singular_flags[bootstrap_index] = (
                fit_star.trial_random_effect_singular
            )

        if residual_correlation_parameters is not None:
            if (
                fit_star.residual_correlation
                != result.residual_correlation
                or fit_star.residual_correlation_parameter is None
                or fit_star.residual_correlation_condition_number is None
            ):
                raise RuntimeError(
                    "full-refit bootstrap lost the declared residual "
                    "correlation structure"
                )
            residual_correlation_parameters[bootstrap_index] = float(
                fit_star.residual_correlation_parameter
            )
            residual_correlation_boundary_flags[bootstrap_index] = bool(
                fit_star.residual_correlation_boundary_fit
            )
            residual_correlation_independence_flags[bootstrap_index] = bool(
                fit_star.residual_correlation_independence_limit_fit
            )
            residual_correlation_condition_numbers[bootstrap_index] = float(
                fit_star.residual_correlation_condition_number
            )

        covariance_eigenvalues[bootstrap_index] = (
            fit_star.random_effect_covariance_eigenvalues
        )
        covariance_condition_numbers[bootstrap_index] = (
            fit_star.random_effect_covariance_condition_number
        )
        boundary_flags[bootstrap_index] = fit_star.boundary_fit
        singular_flags[bootstrap_index] = fit_star.random_effect_singular
        slope_boundary_flags[bootstrap_index] = (
            fit_star.random_slope_boundary_fit
        )
        residual_variances[bootstrap_index] = fit_star.residual_variance
        log_likelihoods[bootstrap_index] = fit_star.log_likelihood
        convergence_flags[bootstrap_index] = fit_star.converged
        warning_records.append(tuple(fit_star.backend_warnings))

    bootstrap_mean = np.mean(coefficient_functions, axis=0)
    bootstrap_standard_errors = np.std(
        coefficient_functions,
        axis=0,
        ddof=1,
    )

    return FunctionalMixedEffectsFullRefitBootstrapResult(
        reference=result,
        bootstrap_fixed_basis_coefficients=fixed_basis_coefficients,
        bootstrap_coefficient_functions=coefficient_functions,
        sampled_participant_indices=sampled_participant_indices,
        sampled_source_participant_ids=tuple(sampled_source_ids),
        bootstrap_participant_ids=tuple(sampled_bootstrap_ids),
        bootstrap_mean=bootstrap_mean,
        bootstrap_standard_errors=bootstrap_standard_errors,
        random_effect_covariances=random_covariances,
        random_intercept_covariances=intercept_covariances,
        random_slope_covariances=slope_covariances,
        random_intercept_slope_covariances=cross_covariances,
        random_effect_covariance_eigenvalues=covariance_eigenvalues,
        random_effect_covariance_condition_numbers=(
            covariance_condition_numbers
        ),
        random_effect_boundary_flags=boundary_flags,
        random_effect_singular_flags=singular_flags,
        random_slope_boundary_flags=slope_boundary_flags,
        residual_variances=residual_variances,
        log_likelihoods=log_likelihoods,
        convergence_flags=convergence_flags,
        backend_warnings=tuple(warning_records),
        random_state=random_state,
        trial_random_effect_covariances=trial_covariances,
        trial_random_effect_covariance_eigenvalues=(
            trial_covariance_eigenvalues
        ),
        trial_random_effect_covariance_condition_numbers=(
            trial_covariance_condition_numbers
        ),
        trial_random_effect_boundary_flags=trial_boundary_flags,
        trial_random_effect_singular_flags=trial_singular_flags,
        bootstrap_trial_audit=tuple(bootstrap_trial_audit),
        residual_correlation_parameters=residual_correlation_parameters,
        residual_correlation_boundary_flags=(
            residual_correlation_boundary_flags
        ),
        residual_correlation_independence_flags=(
            residual_correlation_independence_flags
        ),
        residual_correlation_condition_numbers=(
            residual_correlation_condition_numbers
        ),
        provenance={
            **dict(result.provenance),
            "functional_mixed_effects_full_refit_bootstrap": {
                "method": "whole_participant_case_bootstrap_full_declared_mixed_model_refit",
                "n_bootstrap": n_bootstrap,
                "random_state": random_state,
                "resampling_unit": "participant",
                "participant_draws_with_replacement": True,
                "whole_trial_bundles_resampled": True,
                "duplicate_source_draws_receive_distinct_group_ids": True,
                "source_participant_id_retained": True,
                "bootstrap_participant_id_retained": True,
                "fixed_effects_refit": True,
                "random_effect_covariance_refit": True,
                "trial_random_effect_covariance_refit": (
                    result.trial_random_effect is not None
                ),
                "trial_random_effect_structure_reselected": False,
                "source_trial_id_retained": (
                    result.trial_random_effect is not None
                ),
                "bootstrap_trial_id_retained": (
                    result.trial_random_effect is not None
                ),
                "duplicate_source_trials_receive_distinct_bootstrap_trial_ids": (
                    result.trial_random_effect is not None
                ),
                "residual_variance_refit": True,
                "residual_correlation_refit": (
                    result.residual_correlation != "iid"
                ),
                "residual_correlation_family_reselected": False,
                "reference_residual_correlation": (
                    result.residual_correlation
                ),
                "reference_residual_correlation_parameter_name": (
                    result.residual_correlation_parameter_name
                ),
                "variance_components_refit": True,
                "fixed_basis_size_reselected": False,
                "random_basis_size_reselected": False,
                "knots_reselected_from_data": False,
                "preprocessing_repeated": False,
                "response_dimension_reselected": False,
                "predictors_reselected": False,
                "random_slope_structure_reselected": False,
                "optimizer_reselected": False,
                "reml_ml_choice_reselected": False,
                "failed_replicate_policy": "raise",
                "successful_replicates_conditioned_on": False,
            },
        },
    )

eyetrajectoriespy.functional_mixed_effects_full_refit_audit_frame

functional_mixed_effects_full_refit_audit_frame(bootstrap: FunctionalMixedEffectsFullRefitBootstrapResult) -> pd.DataFrame

Return one row per bootstrap participant draw with source/group IDs.

Source code in src/eyetrajectoriespy/functional_mixed_effects_full_refit.py
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
def functional_mixed_effects_full_refit_audit_frame(
    bootstrap: FunctionalMixedEffectsFullRefitBootstrapResult,
) -> pd.DataFrame:
    """Return one row per bootstrap participant draw with source/group IDs."""

    if not isinstance(
        bootstrap,
        FunctionalMixedEffectsFullRefitBootstrapResult,
    ):
        raise TypeError(
            "bootstrap must be a FunctionalMixedEffectsFullRefitBootstrapResult"
        )
    rows: list[dict[str, int | str]] = []
    for bootstrap_index in range(bootstrap.n_bootstrap):
        for draw_index in range(bootstrap.n_participants):
            rows.append(
                {
                    "bootstrap_replicate": bootstrap_index,
                    "draw_index": draw_index,
                    "source_participant_index": int(
                        bootstrap.sampled_participant_indices[
                            bootstrap_index,
                            draw_index,
                        ]
                    ),
                    "source_participant_id": (
                        bootstrap.sampled_source_participant_ids[
                            bootstrap_index
                        ][draw_index]
                    ),
                    "bootstrap_participant_id": (
                        bootstrap.bootstrap_participant_ids[
                            bootstrap_index
                        ][draw_index]
                    ),
                }
            )
    return pd.DataFrame(rows)

eyetrajectoriespy.functional_mixed_effects_full_refit_trial_audit_frame

functional_mixed_effects_full_refit_trial_audit_frame(bootstrap: FunctionalMixedEffectsFullRefitBootstrapResult) -> pd.DataFrame

Return source/bootstrap participant and trial identities for every draw.

Source code in src/eyetrajectoriespy/functional_mixed_effects_full_refit.py
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
def functional_mixed_effects_full_refit_trial_audit_frame(
    bootstrap: FunctionalMixedEffectsFullRefitBootstrapResult,
) -> pd.DataFrame:
    """Return source/bootstrap participant and trial identities for every draw."""

    if not isinstance(
        bootstrap,
        FunctionalMixedEffectsFullRefitBootstrapResult,
    ):
        raise TypeError(
            "bootstrap must be a FunctionalMixedEffectsFullRefitBootstrapResult"
        )
    if not bootstrap.bootstrap_trial_audit:
        return pd.DataFrame(
            columns=[
                "bootstrap_replicate",
                "source_participant_id",
                "bootstrap_participant_id",
                "source_trial_id",
                "bootstrap_trial_id",
            ]
        )

    rows: list[dict[str, int | str]] = []
    for bootstrap_index, records in enumerate(
        bootstrap.bootstrap_trial_audit
    ):
        for (
            source_participant_id,
            bootstrap_participant_id,
            source_trial_id,
            bootstrap_trial_id,
        ) in records:
            rows.append(
                {
                    "bootstrap_replicate": bootstrap_index,
                    "source_participant_id": source_participant_id,
                    "bootstrap_participant_id": bootstrap_participant_id,
                    "source_trial_id": source_trial_id,
                    "bootstrap_trial_id": bootstrap_trial_id,
                }
            )
    return pd.DataFrame(rows)

eyetrajectoriespy.functional_mixed_effects_variance_bootstrap_frame

functional_mixed_effects_variance_bootstrap_frame(bootstrap: FunctionalMixedEffectsFullRefitBootstrapResult) -> pd.DataFrame

Summarize refitted variance-component diagnostics by replicate.

Source code in src/eyetrajectoriespy/functional_mixed_effects_full_refit.py
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
def functional_mixed_effects_variance_bootstrap_frame(
    bootstrap: FunctionalMixedEffectsFullRefitBootstrapResult,
) -> pd.DataFrame:
    """Summarize refitted variance-component diagnostics by replicate."""

    if not isinstance(
        bootstrap,
        FunctionalMixedEffectsFullRefitBootstrapResult,
    ):
        raise TypeError(
            "bootstrap must be a FunctionalMixedEffectsFullRefitBootstrapResult"
        )

    rows: list[dict[str, float | int | bool]] = []
    for bootstrap_index in range(bootstrap.n_bootstrap):
        eigenvalues = bootstrap.random_effect_covariance_eigenvalues[
            bootstrap_index
        ]
        row: dict[str, float | int | bool] = {
            "bootstrap_replicate": bootstrap_index,
            "residual_variance": float(
                bootstrap.residual_variances[bootstrap_index]
            ),
            "log_likelihood": float(
                bootstrap.log_likelihoods[bootstrap_index]
            ),
            "covariance_min_eigenvalue": float(np.min(eigenvalues)),
            "covariance_max_eigenvalue": float(np.max(eigenvalues)),
            "covariance_trace": float(
                np.trace(
                    bootstrap.random_effect_covariances[
                        bootstrap_index
                    ]
                )
            ),
            "covariance_condition_number": float(
                bootstrap.random_effect_covariance_condition_numbers[
                    bootstrap_index
                ]
            ),
            "boundary_fit": bool(
                bootstrap.random_effect_boundary_flags[
                    bootstrap_index
                ]
            ),
            "singular_fit": bool(
                bootstrap.random_effect_singular_flags[
                    bootstrap_index
                ]
            ),
            "random_slope_boundary_fit": bool(
                bootstrap.random_slope_boundary_flags[
                    bootstrap_index
                ]
            ),
            "converged": bool(
                bootstrap.convergence_flags[bootstrap_index]
            ),
            "backend_warning_count": len(
                bootstrap.backend_warnings[bootstrap_index]
            ),
            "random_intercept_covariance_trace": float(
                np.trace(
                    bootstrap.random_intercept_covariances[
                        bootstrap_index
                    ]
                )
            ),
        }
        if bootstrap.residual_correlation_parameters is not None:
            row["residual_correlation_parameter"] = float(
                bootstrap.residual_correlation_parameters[
                    bootstrap_index
                ]
            )
            row["residual_correlation_condition_number"] = float(
                bootstrap.residual_correlation_condition_numbers[
                    bootstrap_index
                ]
            )
            row["residual_correlation_boundary_fit"] = bool(
                bootstrap.residual_correlation_boundary_flags[
                    bootstrap_index
                ]
            )
            row["residual_correlation_independence_limit_fit"] = bool(
                bootstrap.residual_correlation_independence_flags[
                    bootstrap_index
                ]
            )
        if bootstrap.trial_random_effect_covariances is not None:
            trial_eigenvalues = (
                bootstrap.trial_random_effect_covariance_eigenvalues[
                    bootstrap_index
                ]
            )
            row["trial_covariance_trace"] = float(
                np.trace(
                    bootstrap.trial_random_effect_covariances[
                        bootstrap_index
                    ]
                )
            )
            row["trial_covariance_min_eigenvalue"] = float(
                np.min(trial_eigenvalues)
            )
            row["trial_covariance_max_eigenvalue"] = float(
                np.max(trial_eigenvalues)
            )
            row["trial_covariance_condition_number"] = float(
                bootstrap.trial_random_effect_covariance_condition_numbers[
                    bootstrap_index
                ]
            )
            row["trial_boundary_fit"] = bool(
                bootstrap.trial_random_effect_boundary_flags[
                    bootstrap_index
                ]
            )
            row["trial_singular_fit"] = bool(
                bootstrap.trial_random_effect_singular_flags[
                    bootstrap_index
                ]
            )
        if bootstrap.random_slope_covariances is not None:
            row["random_slope_covariance_trace"] = float(
                np.trace(
                    bootstrap.random_slope_covariances[
                        bootstrap_index
                    ]
                )
            )
            row["intercept_slope_cross_covariance_frobenius"] = float(
                np.linalg.norm(
                    bootstrap.random_intercept_slope_covariances[
                        bootstrap_index
                    ],
                    ord="fro",
                )
            )
        rows.append(row)
    return pd.DataFrame(rows)

eyetrajectoriespy.compare_functional_mixed_effects_bootstraps

compare_functional_mixed_effects_bootstraps(fixed_covariance_bootstrap: FunctionalMixedEffectsBootstrapResult, full_refit_bootstrap: FunctionalMixedEffectsFullRefitBootstrapResult, *, confidence_level: float = 0.95, simultaneous_scope: str = 'coefficient') -> pd.DataFrame

Compare simultaneous band widths from conditional and full-refit bootstraps.

Source code in src/eyetrajectoriespy/functional_mixed_effects_full_refit.py
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
def compare_functional_mixed_effects_bootstraps(
    fixed_covariance_bootstrap: FunctionalMixedEffectsBootstrapResult,
    full_refit_bootstrap: FunctionalMixedEffectsFullRefitBootstrapResult,
    *,
    confidence_level: float = 0.95,
    simultaneous_scope: str = "coefficient",
) -> pd.DataFrame:
    """Compare simultaneous band widths from conditional and full-refit bootstraps."""

    if not isinstance(
        fixed_covariance_bootstrap,
        FunctionalMixedEffectsBootstrapResult,
    ):
        raise TypeError(
            "fixed_covariance_bootstrap must be a "
            "FunctionalMixedEffectsBootstrapResult"
        )
    if not isinstance(
        full_refit_bootstrap,
        FunctionalMixedEffectsFullRefitBootstrapResult,
    ):
        raise TypeError(
            "full_refit_bootstrap must be a "
            "FunctionalMixedEffectsFullRefitBootstrapResult"
        )
    if fixed_covariance_bootstrap.reference is not full_refit_bootstrap.reference:
        raise ValueError(
            "both bootstrap objects must share the exact reference fit"
        )

    fixed_band = functional_mixed_effects_simultaneous_bands(
        fixed_covariance_bootstrap,
        confidence_level=confidence_level,
        simultaneous_scope=simultaneous_scope,
    )
    full_band = functional_mixed_effects_simultaneous_bands(
        full_refit_bootstrap,
        confidence_level=confidence_level,
        simultaneous_scope=simultaneous_scope,
    )
    fixed_width = fixed_band.upper - fixed_band.lower
    full_width = full_band.upper - full_band.lower

    rows: list[dict[str, float | str]] = []
    reference = fixed_covariance_bootstrap.reference
    for coefficient_index, coefficient_name in enumerate(
        reference.coefficient_names
    ):
        for time_index, time_value in enumerate(reference.time):
            conditional_width = float(
                fixed_width[coefficient_index, time_index]
            )
            refit_width = float(
                full_width[coefficient_index, time_index]
            )
            rows.append(
                {
                    "coefficient": coefficient_name,
                    "time": float(time_value),
                    "fixed_covariance_band_width": conditional_width,
                    "full_refit_band_width": refit_width,
                    "full_refit_to_fixed_covariance_width_ratio": (
                        float(refit_width / conditional_width)
                        if conditional_width > 0
                        else float("inf")
                    ),
                    "confidence_level": float(confidence_level),
                    "simultaneous_scope": simultaneous_scope,
                }
            )
    return pd.DataFrame(rows)

eyetrajectoriespy.plot_functional_mixed_effects_bootstrap_comparison

plot_functional_mixed_effects_bootstrap_comparison(comparison: DataFrame, *, coefficient: str, ax=None)

Plot full-refit/fixed-covariance simultaneous-band width ratios.

Source code in src/eyetrajectoriespy/plotting.py
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
def plot_functional_mixed_effects_bootstrap_comparison(
    comparison: pd.DataFrame,
    *,
    coefficient: str,
    ax=None,
):
    """Plot full-refit/fixed-covariance simultaneous-band width ratios."""

    if not isinstance(comparison, pd.DataFrame):
        raise TypeError("comparison must be a pandas DataFrame")
    required = {
        "coefficient",
        "time",
        "full_refit_to_fixed_covariance_width_ratio",
    }
    missing = sorted(required.difference(comparison.columns))
    if missing:
        raise ValueError(
            "comparison is missing required columns: "
            + ", ".join(missing)
        )
    if not isinstance(coefficient, str) or not coefficient:
        raise TypeError("coefficient must be a non-empty string")
    selected = comparison.loc[
        comparison["coefficient"] == coefficient
    ].sort_values("time")
    if selected.empty:
        raise KeyError(f"Unknown coefficient {coefficient!r}")
    ratio = selected[
        "full_refit_to_fixed_covariance_width_ratio"
    ].to_numpy(dtype=float)
    if not np.all(np.isfinite(ratio)):
        raise ValueError(
            "comparison contains non-finite band-width ratios"
        )
    if ax is None:
        _, ax = plt.subplots()
    ax.plot(
        selected["time"].to_numpy(dtype=float),
        ratio,
    )
    ax.axhline(1.0, linestyle="--")
    ax.set_xlabel("Time")
    ax.set_ylabel("Full-refit / fixed-covariance band width")
    ax.set_title(
        "Mixed-effects bootstrap sensitivity: "
        f"{coefficient}"
    )
    return ax