Skip to content

1.1 module-scoped API

Published 1.1.0 provides specialized sparse/irregular capabilities as stable module-scoped APIs. These names are intentionally not inserted into the frozen 1.0 root namespace. Later 1.2 workflow orchestration remains a separately qualified release candidate and is not part of the published 1.1 package.

Use the import paths shown below. The frozen 1.0 root API remains documented in Public API; the rationale and integration decisions are recorded in the 1.1 integration audit.

Conditional sparse PACE score uncertainty

eyetrajectoriespy.sparse_score_uncertainty.SparseFPCAScoreUncertaintyResult dataclass

Conditional Gaussian covariance for native sparse PACE scores.

Notes

These covariances condition on the fitted mean, covariance surface, eigensystem, measurement-error variance, and declared score-system regularization. They are not full sampling uncertainty for sparse FPCA.

Source code in src/eyetrajectoriespy/sparse_score_uncertainty.py
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
@dataclass(frozen=True)
class SparseFPCAScoreUncertaintyResult:
    """Conditional Gaussian covariance for native sparse PACE scores.

    Notes
    -----
    These covariances condition on the fitted mean, covariance surface,
    eigensystem, measurement-error variance, and declared score-system
    regularization. They are not full sampling uncertainty for sparse FPCA.
    """

    reference: SparseFPCAResult
    curve_ids: tuple[str, ...]
    covariance: np.ndarray
    standard_errors: np.ndarray
    diagnostics: pd.DataFrame
    n_components: int
    method: str = _METHOD
    provenance: Mapping[str, Any] = field(default_factory=dict)

    @property
    def correlations(self) -> np.ndarray:
        """Return per-curve conditional score correlation matrices."""

        out = np.full_like(self.covariance, np.nan, dtype=float)
        for index in range(len(self.curve_ids)):
            covariance = self.covariance[index]
            standard_errors = self.standard_errors[index]
            if not np.all(np.isfinite(covariance)):
                continue
            denominator = np.outer(standard_errors, standard_errors)
            np.divide(
                covariance,
                denominator,
                out=out[index],
                where=denominator > 0,
            )
            zero = standard_errors == 0
            for component in np.flatnonzero(zero):
                out[index, component, component] = 1.0
            finite_diag = np.isfinite(standard_errors) & ~zero
            diag = np.flatnonzero(finite_diag)
            out[index, diag, diag] = 1.0
        return out

correlations property

correlations: ndarray

Return per-curve conditional score correlation matrices.

eyetrajectoriespy.sparse_score_uncertainty.sparse_fpca_score_uncertainty

sparse_fpca_score_uncertainty(
    fit: SparseFPCAResult,
    trajectories: IrregularTrajectorySet,
    *,
    condition_limit: float | None = None,
    failure_action: str = "error",
) -> SparseFPCAScoreUncertaintyResult

Compute conditional covariance of native sparse PACE scores.

For retained score vector xi_i and native observation times T_i, this evaluates

Lambda - Lambda Phi_i.T Sigma_i^{-1} Phi_i Lambda

using the same full fitted covariance-plus-noise system as native PACE scoring. The calculation is conditional on the fitted population objects; population-estimation uncertainty is deliberately excluded.

Source code in src/eyetrajectoriespy/sparse_score_uncertainty.py
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
def sparse_fpca_score_uncertainty(
    fit: SparseFPCAResult,
    trajectories: IrregularTrajectorySet,
    *,
    condition_limit: float | None = None,
    failure_action: str = "error",
) -> SparseFPCAScoreUncertaintyResult:
    """Compute conditional covariance of native sparse PACE scores.

    For retained score vector ``xi_i`` and native observation times ``T_i``,
    this evaluates

    ``Lambda - Lambda Phi_i.T Sigma_i^{-1} Phi_i Lambda``

    using the same full fitted covariance-plus-noise system as native PACE
    scoring. The calculation is conditional on the fitted population objects;
    population-estimation uncertainty is deliberately excluded.
    """

    sparse = _native_sparse_provenance(fit)
    _validate_alignment(fit, trajectories)
    if failure_action not in {"error", "retain_nan"}:
        raise ValueError("failure_action must be 'error' or 'retain_nan'")

    grid, fitted_covariance, eigenvalues, eigenfunctions, noise_variance = (
        _required_population_arrays(fit)
    )
    n_components = int(fit.n_components)
    retained_values = eigenvalues[:n_components]
    retained_functions = eigenfunctions[:n_components]

    score_ridge = float(sparse.get("score_ridge", 0.0))
    if not np.isfinite(score_ridge) or score_ridge < 0:
        raise ValueError("fit score_ridge must be finite and non-negative")

    fitted_condition_limit = float(sparse.get("score_condition_limit", 1e12))
    if condition_limit is None:
        selected_condition_limit = fitted_condition_limit
        condition_limit_source = "fitted_score_condition_limit"
    else:
        selected_condition_limit = float(condition_limit)
        condition_limit_source = "explicit_override"
    if not np.isfinite(selected_condition_limit) or selected_condition_limit <= 1:
        raise ValueError("condition_limit must be finite and greater than 1")

    min_score_samples = int(sparse.get("min_score_samples", 2))
    if min_score_samples < 1:
        raise ValueError("fit min_score_samples must be positive")

    times = _effective_times(fit, trajectories, sparse)
    fitted_sample_counts = sparse.get("sample_counts")
    if fitted_sample_counts is not None:
        expected_counts = tuple(int(value) for value in fitted_sample_counts)
        actual_counts = tuple(int(time.size) for time in times)
        if expected_counts != actual_counts:
            raise ValueError(
                "trajectories do not reproduce the effective sample counts used by the fit"
            )

    covariance_out = np.full(
        (len(fit.curve_ids), n_components, n_components),
        np.nan,
        dtype=float,
    )
    standard_errors = np.full(
        (len(fit.curve_ids), n_components),
        np.nan,
        dtype=float,
    )
    prior_covariance = np.diag(retained_values)
    posterior_psd_tolerance = 1e-10 * max(
        1.0,
        float(np.max(np.abs(retained_values))),
    )

    rows: list[dict[str, Any]] = []
    first_failure: tuple[str, str, dict[str, Any]] | None = None

    for index, (curve_id, time) in enumerate(
        zip(fit.curve_ids, times, strict=True)
    ):
        status_code = "ok"
        solve_status = "not_attempted"
        condition_number = np.nan
        minimum_eigenvalue = np.nan
        maximum_eigenvalue = np.nan
        posterior_minimum_eigenvalue = np.nan
        solve_relative_residual = np.nan
        posterior_psd_repair_applied = False

        if time.size < min_score_samples:
            status_code = "curve_too_sparse_for_score_system"
        else:
            covariance_i = evaluate_fitted_covariance(
                grid,
                fitted_covariance,
                time,
            )
            sigma_i = covariance_i + (
                noise_variance + score_ridge
            ) * np.eye(time.size)
            sigma_i = 0.5 * (sigma_i + sigma_i.T)
            sigma_eigenvalues = np.linalg.eigvalsh(sigma_i)
            minimum_eigenvalue = float(np.min(sigma_eigenvalues))
            maximum_eigenvalue = float(np.max(sigma_eigenvalues))
            condition_number = (
                np.inf
                if minimum_eigenvalue <= 0
                else maximum_eigenvalue / minimum_eigenvalue
            )

            if minimum_eigenvalue <= 0:
                status_code = "score_covariance_not_positive_definite"
            elif condition_number > selected_condition_limit:
                status_code = "score_covariance_ill_conditioned"
            else:
                phi_i = np.column_stack(
                    [
                        evaluate_fitted_function(
                            grid,
                            retained_functions[component],
                            time,
                        )
                        for component in range(n_components)
                    ]
                )
                rhs = phi_i @ prior_covariance
                solved = np.linalg.solve(sigma_i, rhs)
                denominator = max(1.0, float(np.linalg.norm(rhs)))
                solve_relative_residual = float(
                    np.linalg.norm(sigma_i @ solved - rhs) / denominator
                )
                posterior = prior_covariance - rhs.T @ solved
                posterior = 0.5 * (posterior + posterior.T)
                posterior_eigenvalues = np.linalg.eigvalsh(posterior)
                posterior_minimum_eigenvalue = float(
                    np.min(posterior_eigenvalues)
                )
                if posterior_minimum_eigenvalue < -posterior_psd_tolerance:
                    status_code = "conditional_score_covariance_not_psd"
                else:
                    if posterior_minimum_eigenvalue < 0:
                        values, vectors = np.linalg.eigh(posterior)
                        values = np.maximum(values, 0.0)
                        posterior = (vectors * values) @ vectors.T
                        posterior = 0.5 * (posterior + posterior.T)
                        posterior_psd_repair_applied = True
                    covariance_out[index] = posterior
                    standard_errors[index] = np.sqrt(
                        np.maximum(np.diag(posterior), 0.0)
                    )
                    solve_status = "solved"

        row = {
            "curve_id": str(curve_id),
            "n_samples": int(time.size),
            "status_code": status_code,
            "solve_status": solve_status,
            "condition_number": float(condition_number),
            "minimum_eigenvalue": float(minimum_eigenvalue),
            "maximum_eigenvalue": float(maximum_eigenvalue),
            "posterior_minimum_eigenvalue": float(
                posterior_minimum_eigenvalue
            ),
            "posterior_psd_repair_applied": bool(
                posterior_psd_repair_applied
            ),
            "solve_relative_residual": float(solve_relative_residual),
            "score_ridge": score_ridge,
            "condition_limit": selected_condition_limit,
        }
        rows.append(row)
        if status_code != "ok" and first_failure is None:
            first_failure = (status_code, str(curve_id), dict(row))

    diagnostics = pd.DataFrame(rows)
    if first_failure is not None and failure_action == "error":
        code, curve_id, details = first_failure
        raise SparseNativeError(
            code,
            f"conditional PACE uncertainty system failed for curve {curve_id!r}",
            details={
                **details,
                "all_curve_diagnostics": diagnostics.to_dict(orient="records"),
            },
        )

    provenance = {
        "method": _METHOD,
        "covariance_source": "full_fitted_covariance_plus_noise_and_declared_score_ridge",
        "fitted_covariance_source": "SparseFPCAResult.covariance",
        "fitted_noise_source": "SparseFPCAResult.noise_variance",
        "eigensystem_source": "SparseFPCAResult.eigenvalues_and_eigenfunctions",
        "native_observation_times": True,
        "raw_sparse_trajectory_interpolation_performed": False,
        "score_ridge": score_ridge,
        "condition_limit": selected_condition_limit,
        "condition_limit_source": condition_limit_source,
        "failure_action": failure_action,
        "posterior_psd_tolerance": posterior_psd_tolerance,
        "population_estimation_uncertainty_included": False,
        "mean_estimation_uncertainty_included": False,
        "covariance_estimation_uncertainty_included": False,
        "eigensystem_estimation_uncertainty_included": False,
        "noise_variance_estimation_uncertainty_included": False,
        "bandwidth_uncertainty_included": False,
        "interpretation": (
            "conditional on fitted mean/covariance/eigensystem and measurement-error "
            "variance; excludes population-estimation uncertainty"
        ),
    }
    return SparseFPCAScoreUncertaintyResult(
        reference=fit,
        curve_ids=tuple(fit.curve_ids),
        covariance=covariance_out,
        standard_errors=standard_errors,
        diagnostics=diagnostics,
        n_components=n_components,
        provenance=provenance,
    )

eyetrajectoriespy.sparse_score_uncertainty.sparse_fpca_score_uncertainty_frame

sparse_fpca_score_uncertainty_frame(
    result: SparseFPCAScoreUncertaintyResult,
) -> pd.DataFrame

Return one tidy row per curve and retained score component.

Source code in src/eyetrajectoriespy/sparse_score_uncertainty.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
def sparse_fpca_score_uncertainty_frame(
    result: SparseFPCAScoreUncertaintyResult,
) -> pd.DataFrame:
    """Return one tidy row per curve and retained score component."""

    if not isinstance(result, SparseFPCAScoreUncertaintyResult):
        raise TypeError("result must be a SparseFPCAScoreUncertaintyResult")
    diagnostic = result.diagnostics.set_index("curve_id", drop=False)
    rows: list[dict[str, Any]] = []
    for curve_index, curve_id in enumerate(result.curve_ids):
        curve_diagnostic = diagnostic.loc[str(curve_id)]
        for component in range(result.n_components):
            rows.append(
                {
                    "curve_id": str(curve_id),
                    "component": component + 1,
                    "conditional_variance": float(
                        result.covariance[curve_index, component, component]
                    ),
                    "conditional_standard_error": float(
                        result.standard_errors[curve_index, component]
                    ),
                    "status_code": str(curve_diagnostic["status_code"]),
                    "condition_number": float(
                        curve_diagnostic["condition_number"]
                    ),
                    "method": result.method,
                }
            )
    return pd.DataFrame(rows)

eyetrajectoriespy.sparse_score_uncertainty_reporting.sparse_fpca_score_uncertainty_reporting_text

sparse_fpca_score_uncertainty_reporting_text(
    result: SparseFPCAScoreUncertaintyResult,
) -> str

Return manuscript-ready scope wording for conditional score uncertainty.

Source code in src/eyetrajectoriespy/sparse_score_uncertainty_reporting.py
10
11
12
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
def sparse_fpca_score_uncertainty_reporting_text(
    result: SparseFPCAScoreUncertaintyResult,
) -> str:
    """Return manuscript-ready scope wording for conditional score uncertainty."""

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

    diagnostics = result.diagnostics
    if "status_code" not in diagnostics.columns:
        raise ValueError("result diagnostics must contain status_code")
    failed = int(np.count_nonzero(diagnostics["status_code"].to_numpy() != "ok"))
    n_curves = len(result.curve_ids)
    score_ridge = float(result.provenance.get("score_ridge", 0.0))

    text = (
        f"Conditional PACE score uncertainty was computed for {n_curves} curves "
        f"and {result.n_components} retained component(s) using the full fitted "
        "covariance evaluated at native observation times. The covariance is "
        "conditional on the fitted mean, covariance surface, eigensystem, and "
        "measurement-error variance and therefore excludes population-estimation "
        "uncertainty, including uncertainty from estimating smoothing bandwidths "
        "and the noise variance."
    )
    if score_ridge > 0:
        text += (
            f" The fitted PACE score ridge ({score_ridge:g}) was inherited as "
            "numerical regularization of the score system and was not interpreted "
            "as additional measurement-error variance."
        )
    else:
        text += " No score ridge was added to the conditional system."

    if failed:
        text += (
            f" {failed} of {n_curves} curve-level uncertainty systems failed the "
            "declared numerical diagnostics and were retained explicitly rather "
            "than silently dropped."
        )
    else:
        text += " All curve-level conditional uncertainty systems passed the declared numerical diagnostics."
    return text

Audited sparse-FPCA bandwidth selection

eyetrajectoriespy.sparse_bandwidth_selection.SparseFPCABandwidthSelectionResult dataclass

Auditable cross-validation result for sparse-FPCA bandwidth tuples.

Source code in src/eyetrajectoriespy/sparse_bandwidth_selection.py
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
@dataclass(frozen=True)
class SparseFPCABandwidthSelectionResult:
    """Auditable cross-validation result for sparse-FPCA bandwidth tuples."""

    assignments: pd.DataFrame
    fold_results: pd.DataFrame
    curve_losses: pd.DataFrame
    candidate_summary: pd.DataFrame
    candidates: pd.DataFrame
    selected_bandwidths: Mapping[str, float | None] | None
    criterion: str
    resampling_unit: str
    n_splits: int
    group_column: str | None
    min_valid_folds: int
    status_code: str
    provenance: Mapping[str, Any] = field(default_factory=dict)

eyetrajectoriespy.sparse_bandwidth_selection.select_sparse_fpca_bandwidths

select_sparse_fpca_bandwidths(
    trajectories: IrregularTrajectorySet,
    *,
    dimension: str,
    evaluation_grid: ndarray,
    mean_bandwidths: Sequence[float],
    covariance_bandwidths: Sequence[float],
    noise_bandwidth: float | None = None,
    noise_support: tuple[float, float] | None = None,
    analysis_support_action: str = "error",
    noise_variance_method: str = "diagonal_difference",
    measurement_error_variance: float | None = None,
    n_splits: int = 5,
    resampling_unit: str = "curve",
    group_column: str | None = None,
    shuffle: bool = True,
    random_state: int | None = 0,
    min_valid_folds: int | None = None,
    failure_action: str = "retain",
    predictive_condition_limit: float = 1000000000000.0,
    psd_action: str = "error",
    psd_tolerance: float = 1e-08,
    positive_eigen_tolerance: float = 1e-10,
    mean_smoother: str = "local_linear",
    covariance_smoother: str = "local_linear",
    kernel: str = "epanechnikov",
    mean_min_local_points: int = 3,
    covariance_min_local_pairs: int = 6,
    noise_min_local_points: int = 3,
) -> SparseFPCABandwidthSelectionResult

Select sparse-FPCA mean/covariance bandwidths by held-out likelihood.

Each candidate is fitted from scratch inside every training fold. The loss for a held-out curve is its per-observation Gaussian negative log predictive density under the training-only fitted mean, full latent covariance, and measurement-error variance. Fold loss is the mean of the held-out curve losses, so curves rather than individual observations are the primary validation unit.

noise_bandwidth is not tuned in this first qualified tranche. When diagonal-difference noise estimation is requested it must be supplied once and is held fixed across all mean/covariance candidates and folds.

This selector never changes :func:fit_sparse_fpca defaults and never invokes automatic tuning from inside the fitter. The selected numeric bandwidths must be passed explicitly to a subsequent fit.

Source code in src/eyetrajectoriespy/sparse_bandwidth_selection.py
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
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
def select_sparse_fpca_bandwidths(
    trajectories: IrregularTrajectorySet,
    *,
    dimension: str,
    evaluation_grid: np.ndarray,
    mean_bandwidths: Sequence[float],
    covariance_bandwidths: Sequence[float],
    noise_bandwidth: float | None = None,
    noise_support: tuple[float, float] | None = None,
    analysis_support_action: str = "error",
    noise_variance_method: str = "diagonal_difference",
    measurement_error_variance: float | None = None,
    n_splits: int = 5,
    resampling_unit: str = "curve",
    group_column: str | None = None,
    shuffle: bool = True,
    random_state: int | None = 0,
    min_valid_folds: int | None = None,
    failure_action: str = "retain",
    predictive_condition_limit: float = 1e12,
    psd_action: str = "error",
    psd_tolerance: float = 1e-8,
    positive_eigen_tolerance: float = 1e-10,
    mean_smoother: str = "local_linear",
    covariance_smoother: str = "local_linear",
    kernel: str = "epanechnikov",
    mean_min_local_points: int = 3,
    covariance_min_local_pairs: int = 6,
    noise_min_local_points: int = 3,
) -> SparseFPCABandwidthSelectionResult:
    """Select sparse-FPCA mean/covariance bandwidths by held-out likelihood.

    Each candidate is fitted from scratch inside every training fold. The loss
    for a held-out curve is its per-observation Gaussian negative log predictive
    density under the training-only fitted mean, full latent covariance, and
    measurement-error variance. Fold loss is the mean of the held-out curve
    losses, so curves rather than individual observations are the primary
    validation unit.

    ``noise_bandwidth`` is not tuned in this first qualified tranche. When
    diagonal-difference noise estimation is requested it must be supplied once
    and is held fixed across all mean/covariance candidates and folds.

    This selector never changes :func:`fit_sparse_fpca` defaults and never
    invokes automatic tuning from inside the fitter. The selected numeric
    bandwidths must be passed explicitly to a subsequent fit.
    """

    mean_grid = _positive_grid(mean_bandwidths, name="mean_bandwidths")
    covariance_grid = _positive_grid(
        covariance_bandwidths,
        name="covariance_bandwidths",
    )
    grid, required_folds, resolved_noise_bandwidth = _validate_selector_inputs(
        trajectories,
        dimension=dimension,
        evaluation_grid=evaluation_grid,
        noise_variance_method=noise_variance_method,
        measurement_error_variance=measurement_error_variance,
        noise_bandwidth=noise_bandwidth,
        noise_support=noise_support,
        analysis_support_action=analysis_support_action,
        n_splits=n_splits,
        resampling_unit=resampling_unit,
        group_column=group_column,
        min_valid_folds=min_valid_folds,
        predictive_condition_limit=predictive_condition_limit,
        failure_action=failure_action,
    )
    dimension_index = trajectories.dimension_names.index(dimension)
    splits, assignments, groups = _make_folds(
        trajectories,
        n_splits=n_splits,
        resampling_unit=resampling_unit,
        group_column=group_column,
        shuffle=shuffle,
        random_state=random_state,
    )
    candidates = _candidate_table(
        mean_grid,
        covariance_grid,
        resolved_noise_bandwidth,
    )

    fold_rows: list[dict[str, Any]] = []
    curve_rows: list[dict[str, Any]] = []

    for candidate in candidates.to_dict(orient="records"):
        candidate_id = str(candidate["candidate_id"])
        mean_bandwidth = float(candidate["mean_bandwidth"])
        covariance_bandwidth = float(candidate["covariance_bandwidth"])

        for fold, (train_idx, validation_idx) in enumerate(splits):
            train = trajectories.subset(train_idx)
            validation = trajectories.subset(validation_idx)
            raw_train_observations = int(np.sum(train.sample_counts))
            raw_validation_observations = int(np.sum(validation.sample_counts))
            effective_train_observations = _effective_observation_count(
                train,
                grid,
                action=analysis_support_action,
            )
            effective_validation_observations = _effective_observation_count(
                validation,
                grid,
                action=analysis_support_action,
            )
            base_row: dict[str, Any] = {
                "candidate_id": candidate_id,
                "fold": int(fold),
                "mean_bandwidth": mean_bandwidth,
                "covariance_bandwidth": covariance_bandwidth,
                "noise_bandwidth": resolved_noise_bandwidth,
                "n_train_curves": int(train.n_curves),
                "n_validation_curves": int(validation.n_curves),
                "n_train_observations_raw": raw_train_observations,
                "n_validation_observations_raw": raw_validation_observations,
                "n_train_observations_effective": effective_train_observations,
                "n_validation_observations_effective": effective_validation_observations,
            }
            try:
                fit = fit_sparse_fpca(
                    train,
                    dimension=dimension,
                    n_components=1,
                    evaluation_grid=grid,
                    mean_bandwidth=mean_bandwidth,
                    covariance_bandwidth=covariance_bandwidth,
                    noise_bandwidth=resolved_noise_bandwidth,
                    noise_support=noise_support,
                    analysis_support_action=analysis_support_action,
                    mean_smoother=mean_smoother,
                    covariance_smoother=covariance_smoother,
                    kernel=kernel,
                    noise_variance_method=noise_variance_method,
                    measurement_error_variance=measurement_error_variance,
                    psd_action=psd_action,
                    psd_tolerance=psd_tolerance,
                    positive_eigen_tolerance=positive_eigen_tolerance,
                    score_failure_action="retain_nan",
                    mean_min_local_points=mean_min_local_points,
                    covariance_min_local_pairs=covariance_min_local_pairs,
                    noise_min_local_points=noise_min_local_points,
                )
            except (SparseNativeError, ValueError, np.linalg.LinAlgError) as exc:
                code, message = _failure_details(exc)
                fold_rows.append(
                    {
                        **base_row,
                        "status_code": "fit_failure",
                        "failure_code": code,
                        "failure_message": message,
                        "mean_curve_gaussian_nll": np.nan,
                        "median_curve_gaussian_nll": np.nan,
                        "n_evaluated_curves": 0,
                        "n_evaluated_observations": 0,
                        "fitted_noise_variance": np.nan,
                    }
                )
                if failure_action == "error":
                    raise
                continue

            validation_losses: list[float] = []
            validation_observations = 0
            validation_failed = False
            first_validation_failure: tuple[str, str] | None = None
            for curve_id, time, values in zip(
                validation.curve_ids,
                validation.time,
                validation.values,
                strict=True,
            ):
                observed = np.asarray(values[:, dimension_index], dtype=float)
                raw_curve_observations = int(len(time))
                try:
                    effective_time, effective_observed, outside = (
                        _effective_validation_curve(
                            time,
                            observed,
                            grid,
                            action=analysis_support_action,
                        )
                    )
                    loss, condition, minimum, maximum = _curve_gaussian_nll(
                        fit,
                        effective_time,
                        effective_observed,
                        predictive_condition_limit=predictive_condition_limit,
                    )
                    validation_losses.append(loss)
                    validation_observations += int(effective_time.size)
                    curve_rows.append(
                        {
                            "candidate_id": candidate_id,
                            "fold": int(fold),
                            "curve_id": str(curve_id),
                            "status_code": "ok",
                            "failure_code": None,
                            "failure_message": None,
                            "gaussian_nll_per_observation": float(loss),
                            "n_observations_raw": raw_curve_observations,
                            "n_evaluated_observations": int(effective_time.size),
                            "outside_support_observations": int(outside),
                            "predictive_condition_number": float(condition),
                            "predictive_minimum_eigenvalue": float(minimum),
                            "predictive_maximum_eigenvalue": float(maximum),
                        }
                    )
                except (SparseNativeError, ValueError, np.linalg.LinAlgError) as exc:
                    validation_failed = True
                    code, message = _failure_details(exc)
                    if first_validation_failure is None:
                        first_validation_failure = (code, message)
                    curve_rows.append(
                        {
                            "candidate_id": candidate_id,
                            "fold": int(fold),
                            "curve_id": str(curve_id),
                            "status_code": "validation_failure",
                            "failure_code": code,
                            "failure_message": message,
                            "gaussian_nll_per_observation": np.nan,
                            "n_observations_raw": raw_curve_observations,
                            "n_evaluated_observations": 0,
                            "outside_support_observations": np.nan,
                            "predictive_condition_number": np.nan,
                            "predictive_minimum_eigenvalue": np.nan,
                            "predictive_maximum_eigenvalue": np.nan,
                        }
                    )
                    if failure_action == "error":
                        raise

            if validation_failed:
                code, message = first_validation_failure or (
                    "validation_failure",
                    "held-out validation failed",
                )
                fold_rows.append(
                    {
                        **base_row,
                        "status_code": "validation_failure",
                        "failure_code": code,
                        "failure_message": message,
                        "mean_curve_gaussian_nll": np.nan,
                        "median_curve_gaussian_nll": np.nan,
                        "n_evaluated_curves": int(len(validation_losses)),
                        "n_evaluated_observations": int(validation_observations),
                        "fitted_noise_variance": float(fit.noise_variance),
                    }
                )
                continue

            losses = np.asarray(validation_losses, dtype=float)
            fold_rows.append(
                {
                    **base_row,
                    "status_code": "ok",
                    "failure_code": None,
                    "failure_message": None,
                    "mean_curve_gaussian_nll": float(np.mean(losses)),
                    "median_curve_gaussian_nll": float(np.median(losses)),
                    "n_evaluated_curves": int(losses.size),
                    "n_evaluated_observations": int(validation_observations),
                    "fitted_noise_variance": float(fit.noise_variance),
                }
            )

    fold_results = pd.DataFrame(fold_rows)
    curve_losses = pd.DataFrame(curve_rows)
    summary = _summarise_candidates(
        candidates,
        fold_results,
        min_valid_folds=required_folds,
    )
    selected = _select_minimum_candidate(summary)
    status_code = "ok" if selected is not None else "no_eligible_candidate"

    provenance = {
        "method": _METHOD,
        "criterion": _CRITERION,
        "criterion_formula": (
            "curve mean of 0.5*(logdet(Sigma_i)+r_i^T Sigma_i^-1 r_i+"
            "m_i*log(2*pi))/m_i; fold mean weights held-out curves equally"
        ),
        "validation_distribution": (
            "Gaussian observation model from training-only fitted mean, full latent "
            "covariance, and measurement-error variance"
        ),
        "fit_inside_fold": True,
        "validation_observations_used_for_training": False,
        "validation_observations_used_for_pace_scoring": False,
        "full_fitted_covariance_used_for_validation": True,
        "score_ridge_included_in_validation_covariance": False,
        "resampling_unit": resampling_unit,
        "group_column": group_column if resampling_unit == "group" else None,
        "group_leakage_prevented": resampling_unit == "group",
        "n_splits": int(n_splits),
        "shuffle": bool(shuffle) if resampling_unit == "curve" else False,
        "random_state": (
            random_state if resampling_unit == "curve" and shuffle else None
        ),
        "min_valid_folds": int(required_folds),
        "failure_action": failure_action,
        "predictive_condition_limit": float(predictive_condition_limit),
        "candidate_order": "sorted_unique_mean_x_covariance_cartesian_product",
        "selection_rule": "minimum_mean_fold_loss",
        "tie_break_rule": (
            "minimum loss, then larger mean/covariance bandwidths "
            "lexicographically, then candidate_id"
        ),
        "one_se_rule_implemented": False,
        "one_se_reason": (
            "no predeclared scalar simplicity ordering for a multidimensional "
            "bandwidth tuple"
        ),
        "noise_bandwidth_tuned": False,
        "noise_bandwidth_policy": (
            "fixed_declared_across_candidates_and_folds"
            if resolved_noise_bandwidth is not None
            else "not_applicable_fixed_measurement_error_variance"
        ),
        "automatic_fit_bandwidth_selection_performed": False,
        "selected_values_must_be_passed_explicitly_to_fit_sparse_fpca": True,
        "evaluation_grid": grid.tolist(),
        "analysis_support_action": analysis_support_action,
        "noise_variance_method": noise_variance_method,
        "noise_bandwidth": resolved_noise_bandwidth,
        "noise_support": None if noise_support is None else list(noise_support),
        "measurement_error_variance": (
            None
            if measurement_error_variance is None
            else float(measurement_error_variance)
        ),
        "psd_action": psd_action,
        "psd_tolerance": float(psd_tolerance),
        "positive_eigen_tolerance": float(positive_eigen_tolerance),
        "mean_smoother": mean_smoother,
        "covariance_smoother": covariance_smoother,
        "kernel": kernel,
        "mean_min_local_points": int(mean_min_local_points),
        "covariance_min_local_pairs": int(covariance_min_local_pairs),
        "noise_min_local_points": int(noise_min_local_points),
        "candidate_count": int(len(candidates)),
        "fold_assignment_count": int(len(assignments)),
        "training_population_fit_n_components": 1,
        "training_score_failure_action": "retain_nan",
    }
    if groups is not None:
        provenance["group_count"] = int(len(pd.unique(groups)))

    return SparseFPCABandwidthSelectionResult(
        assignments=assignments,
        fold_results=fold_results,
        curve_losses=curve_losses,
        candidate_summary=summary,
        candidates=candidates,
        selected_bandwidths=selected,
        criterion=_CRITERION,
        resampling_unit=resampling_unit,
        n_splits=n_splits,
        group_column=group_column if resampling_unit == "group" else None,
        min_valid_folds=required_folds,
        status_code=status_code,
        provenance=provenance,
    )

eyetrajectoriespy.sparse_bandwidth_selection.sparse_fpca_bandwidth_selection_reporting_text

sparse_fpca_bandwidth_selection_reporting_text(
    result: SparseFPCABandwidthSelectionResult,
) -> str

Return compact manuscript-ready wording for an audited selector result.

Source code in src/eyetrajectoriespy/sparse_bandwidth_selection.py
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
def sparse_fpca_bandwidth_selection_reporting_text(
    result: SparseFPCABandwidthSelectionResult,
) -> str:
    """Return compact manuscript-ready wording for an audited selector result."""

    if not isinstance(result, SparseFPCABandwidthSelectionResult):
        raise TypeError("result must be a SparseFPCABandwidthSelectionResult")
    n_candidates = len(result.candidates)
    failures = int(np.count_nonzero(result.fold_results["status_code"] != "ok"))
    unit = (
        f"grouped by {result.group_column!r}"
        if result.resampling_unit == "group"
        else "at the curve level"
    )
    prefix = (
        f"Sparse-FPCA mean/covariance bandwidths were evaluated using "
        f"{result.n_splits}-fold cross-validation {unit}. Population mean, covariance, "
        "and measurement-noise objects were refitted using training curves only. The "
        "prespecified criterion was mean held-out-curve Gaussian negative log predictive "
        "density, using the full fitted latent covariance plus measurement-error "
        "variance; held-out observations were not used for population fitting or PACE "
        "score estimation. "
        f"The audit evaluated {n_candidates} bandwidth candidate(s) and retained "
        f"{failures} failed candidate-fold evaluation(s)."
    )
    if result.provenance.get("noise_bandwidth") is not None:
        prefix += (
            f" The diagonal-difference noise bandwidth "
            f"{float(result.provenance['noise_bandwidth']):g} was declared in advance "
            "and held fixed rather than tuned by this criterion."
        )
    if result.selected_bandwidths is None:
        return prefix + " No candidate satisfied the declared valid-fold requirement."
    selected = result.selected_bandwidths
    return (
        prefix
        + " The minimum mean fold loss selected mean bandwidth "
        + f"{selected['mean_bandwidth']:g} and covariance bandwidth "
        + f"{selected['covariance_bandwidth']:g}. The selected numeric values were "
        "not applied automatically to the fitter."
    )

The result intentionally retains several audit tables rather than exposing one catch-all frame.

Observation-process diagnostics

eyetrajectoriespy.observation_process.ObservationProcessData dataclass

Validated explicit denominator for observation-process diagnostics.

Source code in src/eyetrajectoriespy/observation_process.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
62
63
64
65
66
67
68
69
@dataclass(frozen=True)
class ObservationProcessData:
    """Validated explicit denominator for observation-process diagnostics."""

    frame: pd.DataFrame
    curve_column: str
    time_column: str
    observed_column: str
    group_column: str | None
    candidate_predictors: tuple[str, ...]
    predictor_sources: Mapping[str, str]
    coordinate_columns: tuple[str, ...]
    time_unit: str = "unknown"
    coordinate_system: str = "unknown"
    provenance: Mapping[str, Any] = field(default_factory=dict)

    @property
    def n_candidates(self) -> int:
        return int(len(self.frame))

    @property
    def n_observed(self) -> int:
        return int(self.frame[self.observed_column].sum())

    @property
    def n_missing(self) -> int:
        return self.n_candidates - self.n_observed

    @property
    def observed_fraction(self) -> float:
        return float(self.n_observed / self.n_candidates)

    @property
    def curve_ids(self) -> tuple[str, ...]:
        return tuple(self.frame[self.curve_column].drop_duplicates().astype(str))

eyetrajectoriespy.observation_process.ObservationProcessDiagnosticResult dataclass

Auditable descriptive diagnostics for an explicit observation process.

Source code in src/eyetrajectoriespy/observation_process.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 ObservationProcessDiagnosticResult:
    """Auditable descriptive diagnostics for an explicit observation process."""

    process: ObservationProcessData
    candidate_frame: pd.DataFrame
    global_summary: pd.DataFrame
    curve_summary: pd.DataFrame
    group_summary: pd.DataFrame
    time_summary: pd.DataFrame
    support_summary: pd.DataFrame
    associations: pd.DataFrame
    profiles: pd.DataFrame
    failures: pd.DataFrame
    risk_set: str
    time_basis: str | None
    association_bins: int | tuple[float, ...] | None
    time_bins: int | tuple[float, ...] | None
    low_support_threshold: float
    scope: str = _SCOPE
    provenance: Mapping[str, Any] = field(default_factory=dict)

eyetrajectoriespy.observation_process.observation_process_data

observation_process_data(
    frame: DataFrame,
    *,
    curve_column: str,
    time_column: str,
    observed_column: str,
    group_column: str | None = None,
    candidate_predictors: Sequence[str] = (),
    predictor_sources: Mapping[str, str] | None = None,
    coordinate_columns: Sequence[str] = (),
    time_unit: str = "unknown",
    coordinate_system: str = "unknown",
    provenance: Mapping[str, Any] | None = None,
) -> ObservationProcessData

Validate one explicit row per candidate/scheduled sample.

Candidate predictors must be available on every candidate row. Raw gaze coordinates, when supplied, must be finite on retained rows and genuinely missing (NaN) on unretained rows. Complete external state belongs in candidate_predictors instead of being presented as recovered gaze.

Source code in src/eyetrajectoriespy/observation_process.py
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
def observation_process_data(
    frame: pd.DataFrame,
    *,
    curve_column: str,
    time_column: str,
    observed_column: str,
    group_column: str | None = None,
    candidate_predictors: Sequence[str] = (),
    predictor_sources: Mapping[str, str] | None = None,
    coordinate_columns: Sequence[str] = (),
    time_unit: str = "unknown",
    coordinate_system: str = "unknown",
    provenance: Mapping[str, Any] | None = None,
) -> ObservationProcessData:
    """Validate one explicit row per candidate/scheduled sample.

    Candidate predictors must be available on every candidate row. Raw gaze
    coordinates, when supplied, must be finite on retained rows and genuinely
    missing (NaN) on unretained rows. Complete external state belongs in
    ``candidate_predictors`` instead of being presented as recovered gaze.
    """

    if not isinstance(frame, pd.DataFrame):
        raise TypeError("frame must be a pandas DataFrame")
    if frame.empty:
        raise ValueError("frame must contain at least one candidate sample")

    predictors = tuple(dict.fromkeys(map(str, candidate_predictors)))
    coordinates = tuple(map(str, coordinate_columns))
    if len(coordinates) not in {0, 2}:
        raise ValueError("coordinate_columns must be empty or contain exactly two columns")

    required = [curve_column, time_column, observed_column, *predictors, *coordinates]
    if group_column is not None:
        required.append(group_column)
    _require_columns(frame, required)

    reserved = {curve_column, time_column, observed_column}
    if len(reserved) != 3:
        raise ValueError("curve_column, time_column, and observed_column must be distinct")
    if group_column is not None and group_column in reserved:
        raise ValueError("group_column must be distinct from core denominator columns")

    data = frame.copy(deep=True)
    if data[curve_column].isna().any():
        raise ValueError("curve identifiers must not be missing")
    data[curve_column] = data[curve_column].astype(str)
    if group_column is not None:
        if data[group_column].isna().any():
            raise ValueError("group identifiers must not be missing")
        data[group_column] = data[group_column].astype(str)

    try:
        candidate_time = pd.to_numeric(data[time_column], errors="raise").to_numpy(dtype=float)
    except (TypeError, ValueError) as exc:
        raise ValueError("candidate times must be numeric") from exc
    if not np.all(np.isfinite(candidate_time)):
        raise ValueError("candidate times must be finite")
    data[time_column] = candidate_time
    data[observed_column] = _validate_binary(data[observed_column], name=observed_column)

    duplicates = data.duplicated([curve_column, time_column], keep=False)
    if duplicates.any():
        examples = data.loc[duplicates, [curve_column, time_column]].head(5).to_dict("records")
        raise ValueError(
            "candidate rows must be unique within curve/time; "
            f"duplicate examples: {examples}"
        )
    for curve_id, group in data.groupby(curve_column, sort=False):
        times = group[time_column].to_numpy(dtype=float)
        if times.size > 1 and not np.all(np.diff(times) > 0):
            raise ValueError(
                "candidate times must be strictly increasing in supplied row order "
                f"within curve {curve_id!r}"
            )

    source_map = dict(predictor_sources or {})
    unknown_sources = sorted(set(source_map) - set(predictors))
    if unknown_sources:
        raise ValueError(
            "predictor_sources contains names absent from candidate_predictors: "
            f"{unknown_sources}"
        )
    resolved_sources: dict[str, str] = {}
    for predictor in predictors:
        source = source_map.get(predictor, "candidate_time")
        if source not in _ALLOWED_SOURCES:
            raise ValueError(
                f"predictor source for {predictor!r} must be one of "
                f"{sorted(_ALLOWED_SOURCES)}"
            )
        if data[predictor].isna().any():
            raise ValueError(
                f"candidate-time predictor {predictor!r} is unavailable on some "
                "candidate rows; do not fill contemporaneous missing gaze implicitly"
            )
        resolved_sources[predictor] = source

    observed = data[observed_column].to_numpy(dtype=int).astype(bool)
    for coordinate in coordinates:
        numeric = pd.to_numeric(data[coordinate], errors="coerce").to_numpy(dtype=float)
        if np.any(~np.isfinite(numeric[observed])):
            raise ValueError(
                f"coordinate column {coordinate!r} must be finite on observed rows"
            )
        if np.any(~np.isnan(numeric[~observed])):
            raise ValueError(
                f"coordinate column {coordinate!r} contains values on unobserved rows; "
                "label complete external state as a candidate predictor instead"
            )
        data[coordinate] = numeric

    data = data.sort_values([curve_column, time_column], kind="stable").reset_index(drop=True)
    merged_provenance = dict(provenance or {})
    merged_provenance.update(
        {
            "denominator_source": "explicit_candidate_rows",
            "denominator_reconstructed_from_retained_timestamps": False,
            "nominal_sampling_rate_used_to_infer_missing_rows": False,
            "current_missing_gaze_imputed": False,
            "candidate_predictor_sources": dict(resolved_sources),
        }
    )
    return ObservationProcessData(
        frame=data,
        curve_column=str(curve_column),
        time_column=str(time_column),
        observed_column=str(observed_column),
        group_column=None if group_column is None else str(group_column),
        candidate_predictors=predictors,
        predictor_sources=resolved_sources,
        coordinate_columns=coordinates,
        time_unit=str(time_unit),
        coordinate_system=str(coordinate_system),
        provenance=merged_provenance,
    )

eyetrajectoriespy.observation_process.diagnose_observation_process

diagnose_observation_process(
    process: ObservationProcessData,
    *,
    predictors: Sequence[str] | None = None,
    history_predictors: Sequence[str] = (),
    eccentricity_reference: tuple[float, float]
    | None = None,
    risk_set: str = "all_candidates",
    time_basis: str | None = "linear",
    time_bins: int | Sequence[float] | None = None,
    association_bins: int | Sequence[float] | None = 4,
    low_support_threshold: float = 0.1,
) -> ObservationProcessDiagnosticResult

Describe dependence and support in an explicit observation process.

No iid sample-level standard errors or p-values are reported. Association summaries are descriptive and do not identify MAR/MNAR mechanisms.

Source code in src/eyetrajectoriespy/observation_process.py
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
def diagnose_observation_process(
    process: ObservationProcessData,
    *,
    predictors: Sequence[str] | None = None,
    history_predictors: Sequence[str] = (),
    eccentricity_reference: tuple[float, float] | None = None,
    risk_set: str = "all_candidates",
    time_basis: str | None = "linear",
    time_bins: int | Sequence[float] | None = None,
    association_bins: int | Sequence[float] | None = 4,
    low_support_threshold: float = 0.10,
) -> ObservationProcessDiagnosticResult:
    """Describe dependence and support in an explicit observation process.

    No iid sample-level standard errors or p-values are reported. Association
    summaries are descriptive and do not identify MAR/MNAR mechanisms.
    """

    if not isinstance(process, ObservationProcessData):
        raise TypeError("process must be an ObservationProcessData")
    if time_basis not in {None, "linear"}:
        raise ValueError("time_basis must be None or 'linear' in the A3 tranche")
    low_support_threshold = float(low_support_threshold)
    if not np.isfinite(low_support_threshold) or not 0.0 <= low_support_threshold <= 1.0:
        raise ValueError("low_support_threshold must be finite and within [0, 1]")

    requested_predictors = (
        process.candidate_predictors
        if predictors is None
        else tuple(dict.fromkeys(map(str, predictors)))
    )
    unknown = sorted(set(requested_predictors) - set(process.candidate_predictors))
    if unknown:
        raise ValueError(
            "predictors must have been declared as complete candidate-time predictors: "
            f"{unknown}"
        )
    requested_history = tuple(dict.fromkeys(map(str, history_predictors)))
    unknown_history = sorted(set(requested_history) - _HISTORY_NAMES)
    if unknown_history:
        raise ValueError(f"unknown history predictor(s): {unknown_history}")

    if eccentricity_reference is not None:
        reference = np.asarray(eccentricity_reference, dtype=float)
        if reference.shape != (2,) or not np.all(np.isfinite(reference)):
            raise ValueError("eccentricity_reference must contain two finite coordinates")
        resolved_reference = (float(reference[0]), float(reference[1]))
    else:
        resolved_reference = None

    candidate_frame = _derive_history(process, eccentricity_reference=resolved_reference)
    risk_mask = _risk_mask(process, candidate_frame, risk_set)
    evaluated = candidate_frame.loc[risk_mask].copy()
    if evaluated.empty:
        raise ValueError("selected risk_set contains no candidate rows")
    observed = evaluated[process.observed_column].to_numpy(dtype=int)

    curve_summary = _summarize_curves(process)
    group_summary = _summarize_groups(process)
    global_summary = pd.DataFrame(
        [
            {
                "candidate_count": process.n_candidates,
                "observed_count": process.n_observed,
                "missing_count": process.n_missing,
                "observed_fraction": process.observed_fraction,
                "missing_fraction": 1.0 - process.observed_fraction,
                "curve_count": len(process.curve_ids),
                "group_count": (
                    int(process.frame[process.group_column].nunique())
                    if process.group_column is not None
                    else np.nan
                ),
                "risk_set": risk_set,
                "risk_candidate_count": int(len(evaluated)),
                "risk_observed_count": int(observed.sum()),
                "risk_missing_count": int(len(evaluated) - observed.sum()),
            }
        ]
    )

    resolved_time_edges = _resolve_edges(
        process.frame[process.time_column].to_numpy(dtype=float),
        time_bins,
        name="time_bins",
    )
    support_summary = _regional_support(
        process,
        resolved_time_edges,
        low_support_threshold=low_support_threshold,
    )
    if resolved_time_edges is None:
        time_summary = pd.DataFrame(
            columns=[
                "predictor",
                "source",
                "profile_type",
                "level",
                "lower",
                "upper",
                "candidate_count",
                "observed_count",
                "observed_fraction",
            ]
        )
    else:
        time_summary = _binned_summary(
            evaluated[process.time_column].to_numpy(dtype=float),
            observed,
            resolved_time_edges,
            predictor=process.time_column,
            source="candidate_time",
        )

    failures: list[dict[str, Any]] = []
    association_rows: list[dict[str, Any]] = []
    profile_frames: list[pd.DataFrame] = []
    if time_basis == "linear":
        association_rows.append(
            _numeric_association(
                evaluated[process.time_column].to_numpy(dtype=float),
                observed,
                predictor=process.time_column,
                source="candidate_time",
            )
        )

    for predictor in requested_predictors:
        series = evaluated[predictor]
        source = process.predictor_sources[predictor]
        if is_numeric_dtype(series):
            values = pd.to_numeric(series, errors="coerce").to_numpy(dtype=float)
            association_rows.append(
                _numeric_association(values, observed, predictor=predictor, source=source)
            )
            try:
                edges = _resolve_edges(values, association_bins, name="association_bins")
            except ValueError as exc:
                failures.append(
                    {
                        "item": predictor,
                        "source": source,
                        "status_code": "profile_unavailable",
                        "message": str(exc),
                    }
                )
                edges = None
            if edges is not None:
                profile_frames.append(
                    _binned_summary(
                        values,
                        observed,
                        edges,
                        predictor=predictor,
                        source=source,
                    )
                )
        else:
            association_rows.append(
                _categorical_association(series, observed, predictor=predictor, source=source)
            )
            profile_frames.append(
                _categorical_profile(series, observed, predictor=predictor, source=source)
            )

    history_requires_coordinates = {
        "previous_observed_x",
        "previous_observed_y",
        "previous_observed_speed",
        "previous_observed_eccentricity",
    }
    for predictor in requested_history:
        if predictor in history_requires_coordinates and not process.coordinate_columns:
            failures.append(
                {
                    "item": predictor,
                    "source": "history",
                    "status_code": "history_coordinates_unavailable",
                    "message": "raw observed coordinate columns were not supplied",
                }
            )
            continue
        if predictor == "previous_observed_eccentricity" and resolved_reference is None:
            failures.append(
                {
                    "item": predictor,
                    "source": "history",
                    "status_code": "reference_definition_required",
                    "message": "eccentricity_reference is required for this history predictor",
                }
            )
            continue
        values = evaluated[predictor].to_numpy(dtype=float)
        finite = np.isfinite(values)
        if not np.any(finite):
            failures.append(
                {
                    "item": predictor,
                    "source": "history",
                    "status_code": "history_unavailable",
                    "message": "no candidate rows have sufficient prior observed history",
                }
            )
            continue
        history_values = values[finite]
        history_observed = observed[finite]
        row = _numeric_association(
            history_values,
            history_observed,
            predictor=predictor,
            source="history",
        )
        row["n_excluded_insufficient_history"] = int((~finite).sum())
        association_rows.append(row)
        try:
            edges = _resolve_edges(
                history_values,
                association_bins,
                name="association_bins",
            )
        except ValueError as exc:
            failures.append(
                {
                    "item": predictor,
                    "source": "history",
                    "status_code": "profile_unavailable",
                    "message": str(exc),
                }
            )
            edges = None
        if edges is not None:
            profile_frames.append(
                _binned_summary(
                    history_values,
                    history_observed,
                    edges,
                    predictor=predictor,
                    source="history",
                )
            )

    associations = pd.DataFrame(association_rows)
    profiles = (
        pd.concat(profile_frames, ignore_index=True)
        if profile_frames
        else pd.DataFrame(
            columns=[
                "predictor",
                "source",
                "profile_type",
                "level",
                "lower",
                "upper",
                "candidate_count",
                "observed_count",
                "observed_fraction",
            ]
        )
    )
    failure_frame = pd.DataFrame(failures) if failures else _empty_failure_frame()

    provenance = {
        "scope": _SCOPE,
        "denominator_source": "explicit_candidate_rows",
        "time_basis": time_basis,
        "time_bins": None if resolved_time_edges is None else list(resolved_time_edges),
        "association_bins": (
            association_bins
            if isinstance(association_bins, int) or association_bins is None
            else list(map(float, association_bins))
        ),
        "risk_set": risk_set,
        "low_support_threshold": low_support_threshold,
        "time_summary_uses_selected_risk_set": True,
        "support_summary_uses_full_candidate_denominator": True,
        "predictor_sources": dict(process.predictor_sources),
        "history_predictors": list(requested_history),
        "history_uses_past_information_only": True,
        "eccentricity_reference": (
            None if resolved_reference is None else list(resolved_reference)
        ),
        "iid_sample_level_inference_performed": False,
        "p_values_reported": False,
        "missing_mechanism_classification_performed": False,
        "inverse_probability_weighting_performed": False,
        "inverse_intensity_correction_performed": False,
        "current_missing_gaze_imputed": False,
        "sparse_estimator_modified": False,
    }
    return ObservationProcessDiagnosticResult(
        process=process,
        candidate_frame=candidate_frame,
        global_summary=global_summary,
        curve_summary=curve_summary,
        group_summary=group_summary,
        time_summary=time_summary,
        support_summary=support_summary,
        associations=associations,
        profiles=profiles,
        failures=failure_frame,
        risk_set=risk_set,
        time_basis=time_basis,
        association_bins=(
            association_bins
            if isinstance(association_bins, int) or association_bins is None
            else tuple(map(float, association_bins))
        ),
        time_bins=(
            time_bins
            if isinstance(time_bins, int) or time_bins is None
            else tuple(map(float, time_bins))
        ),
        low_support_threshold=low_support_threshold,
        provenance=provenance,
    )

eyetrajectoriespy.observation_process.observation_process_frame

observation_process_frame(
    result: ObservationProcessDiagnosticResult,
    *,
    table: str = "associations",
) -> pd.DataFrame

Return one audited diagnostic table as a defensive copy.

Source code in src/eyetrajectoriespy/observation_process.py
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
def observation_process_frame(
    result: ObservationProcessDiagnosticResult,
    *,
    table: str = "associations",
) -> pd.DataFrame:
    """Return one audited diagnostic table as a defensive copy."""

    if not isinstance(result, ObservationProcessDiagnosticResult):
        raise TypeError("result must be an ObservationProcessDiagnosticResult")
    tables = {
        "global": result.global_summary,
        "curves": result.curve_summary,
        "groups": result.group_summary,
        "time": result.time_summary,
        "support": result.support_summary,
        "associations": result.associations,
        "profiles": result.profiles,
        "failures": result.failures,
        "candidates": result.candidate_frame,
    }
    if table not in tables:
        raise ValueError(f"table must be one of {sorted(tables)}")
    return tables[table].copy(deep=True)

eyetrajectoriespy.observation_process.observation_process_reporting_text

observation_process_reporting_text(
    result: ObservationProcessDiagnosticResult,
) -> str

Return conservative manuscript-ready descriptive reporting text.

Source code in src/eyetrajectoriespy/observation_process.py
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
def observation_process_reporting_text(
    result: ObservationProcessDiagnosticResult,
) -> str:
    """Return conservative manuscript-ready descriptive reporting text."""

    if not isinstance(result, ObservationProcessDiagnosticResult):
        raise TypeError("result must be an ObservationProcessDiagnosticResult")
    summary = result.global_summary.iloc[0]
    ok = (
        result.associations.loc[result.associations["status_code"] == "ok", "predictor"]
        .astype(str)
        .tolist()
        if not result.associations.empty and "status_code" in result.associations
        else []
    )
    failed = int(len(result.failures))
    group_phrase = (
        f", across {int(summary['group_count'])} declared groups"
        if np.isfinite(summary["group_count"])
        else ""
    )
    predictor_phrase = ", ".join(ok) if ok else "no estimable requested predictors"
    return (
        "Observation-process diagnostics used an explicit denominator of "
        f"{int(summary['candidate_count'])} candidate samples across "
        f"{int(summary['curve_count'])} curves{group_phrase}; "
        f"{int(summary['missing_count'])} samples "
        f"({100.0 * float(summary['missing_fraction']):.1f}%) were not retained. "
        f"Descriptive associations were estimable for {predictor_phrase}. "
        f"{failed} diagnostic item(s) were retained with explicit failure/status records. "
        "The analysis reports descriptive observation-process associations only: it "
        "does not provide iid sample-level inference, classify the process as MAR/MNAR, "
        "impute current missing gaze, or apply inverse-probability/intensity correction."
    )

These APIs are descriptive diagnostics. They do not identify MAR/MNAR mechanisms or apply inverse-intensity correction.

Conditional sparse joint-PACE score uncertainty

eyetrajectoriespy.sparse_multivariate_score_uncertainty.SparseMFPCAScoreUncertaintyResult dataclass

Conditional Gaussian covariance for native sparse joint-PACE scores.

Notes

These covariances condition on the fitted joint covariance, eigensystem, measurement-error covariance, analysis support, and score-system ridge. They are not full sampling uncertainty for sparse multivariate FPCA.

Source code in src/eyetrajectoriespy/sparse_multivariate_score_uncertainty.py
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
@dataclass(frozen=True)
class SparseMFPCAScoreUncertaintyResult:
    """Conditional Gaussian covariance for native sparse joint-PACE scores.

    Notes
    -----
    These covariances condition on the fitted joint covariance, eigensystem,
    measurement-error covariance, analysis support, and score-system ridge.
    They are not full sampling uncertainty for sparse multivariate FPCA.
    """

    reference: SparseMFPCAResult
    curve_ids: tuple[str, ...]
    covariance: np.ndarray
    standard_errors: np.ndarray
    diagnostics: pd.DataFrame
    n_components: int
    method: str = _METHOD
    provenance: Mapping[str, Any] = field(default_factory=dict)

    @property
    def correlations(self) -> np.ndarray:
        """Return per-curve conditional score correlation matrices."""

        out = np.full_like(self.covariance, np.nan, dtype=float)
        for index in range(len(self.curve_ids)):
            covariance = self.covariance[index]
            standard_errors = self.standard_errors[index]
            if not np.all(np.isfinite(covariance)):
                continue
            denominator = np.outer(standard_errors, standard_errors)
            np.divide(
                covariance,
                denominator,
                out=out[index],
                where=denominator > 0,
            )
            zero = standard_errors == 0
            for component in np.flatnonzero(zero):
                out[index, component, component] = 1.0
            finite_diag = np.isfinite(standard_errors) & ~zero
            diagonal = np.flatnonzero(finite_diag)
            out[index, diagonal, diagonal] = 1.0
        return out

correlations property

correlations: ndarray

Return per-curve conditional score correlation matrices.

eyetrajectoriespy.sparse_multivariate_score_uncertainty.sparse_mfpca_score_uncertainty

sparse_mfpca_score_uncertainty(
    fit: SparseMFPCAResult,
    trajectories: IrregularTrajectorySet,
    *,
    condition_limit: float | None = None,
    failure_action: str = "error",
) -> SparseMFPCAScoreUncertaintyResult

Compute conditional covariance of native sparse joint-PACE scores.

For the retained joint score vector xi_i this evaluates

Lambda - Lambda Phi_i.T Sigma_i^{-1} Phi_i Lambda

using exactly the full fitted joint latent covariance, two-channel measurement-error covariance, native-time observation order, and score ridge used by joint_pace_scores. Population-estimation uncertainty is deliberately excluded.

Source code in src/eyetrajectoriespy/sparse_multivariate_score_uncertainty.py
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
def sparse_mfpca_score_uncertainty(
    fit: SparseMFPCAResult,
    trajectories: IrregularTrajectorySet,
    *,
    condition_limit: float | None = None,
    failure_action: str = "error",
) -> SparseMFPCAScoreUncertaintyResult:
    """Compute conditional covariance of native sparse joint-PACE scores.

    For the retained joint score vector ``xi_i`` this evaluates

    ``Lambda - Lambda Phi_i.T Sigma_i^{-1} Phi_i Lambda``

    using exactly the full fitted joint latent covariance, two-channel
    measurement-error covariance, native-time observation order, and score
    ridge used by ``joint_pace_scores``. Population-estimation uncertainty is
    deliberately excluded.
    """

    sparse = _native_joint_provenance(fit)
    x_index, y_index = _validate_alignment(fit, trajectories)
    if failure_action not in {"error", "retain_nan"}:
        raise ValueError("failure_action must be 'error' or 'retain_nan'")

    (
        grid,
        covariance_blocks,
        retained_values,
        retained_functions,
        measurement_error,
    ) = _required_population_arrays(fit)
    n_components = int(fit.n_components)

    score_ridge = float(sparse.get("score_ridge", 0.0))
    if not np.isfinite(score_ridge) or score_ridge < 0:
        raise ValueError("fit score_ridge must be finite and non-negative")
    fitted_condition_limit = float(
        sparse.get("score_condition_limit", sparse.get("condition_limit", 1e12))
    )
    if condition_limit is None:
        selected_condition_limit = fitted_condition_limit
        condition_limit_source = "fitted_score_condition_limit"
    else:
        selected_condition_limit = float(condition_limit)
        condition_limit_source = "explicit_override"
    if not np.isfinite(selected_condition_limit) or selected_condition_limit <= 1:
        raise ValueError("condition_limit must be finite and greater than 1")

    min_time_points = int(sparse.get("min_score_time_points", 2))
    if min_time_points < 1:
        raise ValueError("fit min_score_time_points must be positive")
    times = _effective_times(
        fit,
        trajectories,
        sparse,
        x_index=x_index,
        y_index=y_index,
    )
    fit_statuses = _fit_score_statuses(fit)

    covariance_out = np.full(
        (len(fit.curve_ids), n_components, n_components),
        np.nan,
        dtype=float,
    )
    standard_errors = np.full(
        (len(fit.curve_ids), n_components),
        np.nan,
        dtype=float,
    )
    prior_covariance = np.diag(retained_values)
    posterior_psd_tolerance = 1e-10 * max(
        1.0,
        float(np.max(np.abs(retained_values))),
    )
    rows: list[dict[str, Any]] = []
    first_failure: tuple[str, str, dict[str, Any]] | None = None

    for index, (curve_id, time) in enumerate(
        zip(fit.curve_ids, times, strict=True)
    ):
        status_code = "ok"
        solve_status = "not_attempted"
        condition_number = np.nan
        minimum_eigenvalue = np.nan
        maximum_eigenvalue = np.nan
        posterior_minimum_eigenvalue = np.nan
        solve_relative_residual = np.nan
        posterior_psd_repair_applied = False

        if time.size < min_time_points:
            status_code = "curve_too_sparse_for_joint_score_system"
        else:
            try:
                native_covariance = evaluate_planar_covariance(
                    grid,
                    covariance_blocks,
                    time,
                    order="time_major",
                )
                sigma = build_joint_score_covariance(
                    native_covariance,
                    measurement_error,
                    score_ridge=score_ridge,
                )
                sigma_eigenvalues = np.linalg.eigvalsh(sigma)
                minimum_eigenvalue = float(np.min(sigma_eigenvalues))
                maximum_eigenvalue = float(np.max(sigma_eigenvalues))
                condition_number = (
                    np.inf
                    if minimum_eigenvalue <= 0
                    else maximum_eigenvalue / minimum_eigenvalue
                )
                if minimum_eigenvalue <= 0:
                    status_code = "joint_score_covariance_not_positive_definite"
                elif condition_number > selected_condition_limit:
                    status_code = "joint_score_covariance_ill_conditioned"
                else:
                    phi_i = stack_planar_eigenfunctions(
                        grid,
                        retained_functions,
                        time,
                        n_components=n_components,
                    )
                    rhs = phi_i @ prior_covariance
                    solved = np.linalg.solve(sigma, rhs)
                    denominator = max(1.0, float(np.linalg.norm(rhs)))
                    solve_relative_residual = float(
                        np.linalg.norm(sigma @ solved - rhs) / denominator
                    )
                    posterior = prior_covariance - rhs.T @ solved
                    posterior = 0.5 * (posterior + posterior.T)
                    posterior_eigenvalues = np.linalg.eigvalsh(posterior)
                    posterior_minimum_eigenvalue = float(
                        np.min(posterior_eigenvalues)
                    )
                    if posterior_minimum_eigenvalue < -posterior_psd_tolerance:
                        status_code = "conditional_joint_score_covariance_not_psd"
                    else:
                        if posterior_minimum_eigenvalue < 0:
                            values, vectors = np.linalg.eigh(posterior)
                            values = np.maximum(values, 0.0)
                            posterior = (vectors * values) @ vectors.T
                            posterior = 0.5 * (posterior + posterior.T)
                            posterior_psd_repair_applied = True
                        covariance_out[index] = posterior
                        standard_errors[index] = np.sqrt(
                            np.maximum(np.diag(posterior), 0.0)
                        )
                        solve_status = "solved"
            except SparseNativeError as exc:
                status_code = exc.code
            except np.linalg.LinAlgError:
                status_code = "joint_score_covariance_solve_failed"

        row = {
            "curve_id": str(curve_id),
            "n_time_points": int(time.size),
            "n_planar_observations": int(2 * time.size),
            "status_code": status_code,
            "fit_score_status": fit_statuses.get(str(curve_id), "unknown"),
            "solve_status": solve_status,
            "condition_number": float(condition_number),
            "minimum_eigenvalue": float(minimum_eigenvalue),
            "maximum_eigenvalue": float(maximum_eigenvalue),
            "posterior_minimum_eigenvalue": float(
                posterior_minimum_eigenvalue
            ),
            "posterior_psd_repair_applied": bool(
                posterior_psd_repair_applied
            ),
            "solve_relative_residual": float(solve_relative_residual),
            "score_ridge": score_ridge,
            "condition_limit": selected_condition_limit,
            "observation_order": "time_major_interleaved_xy",
        }
        rows.append(row)
        if status_code != "ok" and first_failure is None:
            first_failure = (status_code, str(curve_id), dict(row))

    diagnostics = pd.DataFrame(rows)
    if first_failure is not None and failure_action == "error":
        code, curve_id, details = first_failure
        raise SparseNativeError(
            code,
            f"conditional joint-PACE uncertainty failed for curve {curve_id!r}",
            details={
                **details,
                "all_curve_diagnostics": diagnostics.to_dict(orient="records"),
            },
        )

    provenance = {
        "method": _METHOD,
        "covariance_source": (
            "full_fitted_joint_covariance_plus_measurement_error_and_declared_score_ridge"
        ),
        "fitted_covariance_source": (
            "SparseMFPCAResult.covariance_cxx/cxy/cyx/cyy"
        ),
        "fitted_measurement_error_source": (
            "SparseMFPCAResult.measurement_error_covariance"
        ),
        "eigensystem_source": (
            "SparseMFPCAResult.eigenvalues_and_eigenfunctions"
        ),
        "operator_storage_order": "channel_major",
        "score_observation_order": "time_major_interleaved_xy",
        "ordering_permutation_applied": True,
        "native_observation_times": True,
        "raw_sparse_trajectory_interpolation_performed": False,
        "rank_k_covariance_used_for_uncertainty": False,
        "score_ridge": score_ridge,
        "condition_limit": selected_condition_limit,
        "condition_limit_source": condition_limit_source,
        "failure_action": failure_action,
        "posterior_psd_tolerance": posterior_psd_tolerance,
        "population_estimation_uncertainty_included": False,
        "mean_estimation_uncertainty_included": False,
        "covariance_estimation_uncertainty_included": False,
        "eigensystem_estimation_uncertainty_included": False,
        "measurement_error_specification_uncertainty_included": False,
        "bandwidth_uncertainty_included": False,
        "asynchronous_channel_grids_supported": False,
        "interpretation": (
            "conditional on fitted joint covariance/eigensystem, measurement-error "
            "covariance, and score regularization; excludes population-estimation "
            "and bandwidth uncertainty"
        ),
    }
    return SparseMFPCAScoreUncertaintyResult(
        reference=fit,
        curve_ids=tuple(fit.curve_ids),
        covariance=covariance_out,
        standard_errors=standard_errors,
        diagnostics=diagnostics,
        n_components=n_components,
        provenance=provenance,
    )

eyetrajectoriespy.sparse_multivariate_score_uncertainty.sparse_mfpca_score_uncertainty_frame

sparse_mfpca_score_uncertainty_frame(
    result: SparseMFPCAScoreUncertaintyResult,
) -> pd.DataFrame

Return one tidy row per curve and retained joint score component.

Source code in src/eyetrajectoriespy/sparse_multivariate_score_uncertainty.py
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
def sparse_mfpca_score_uncertainty_frame(
    result: SparseMFPCAScoreUncertaintyResult,
) -> pd.DataFrame:
    """Return one tidy row per curve and retained joint score component."""

    if not isinstance(result, SparseMFPCAScoreUncertaintyResult):
        raise TypeError("result must be a SparseMFPCAScoreUncertaintyResult")
    rows: list[dict[str, Any]] = []
    diagnostics = result.diagnostics.set_index("curve_id", drop=False)
    for curve_index, curve_id in enumerate(result.curve_ids):
        diagnostic = diagnostics.loc[str(curve_id)]
        for component in range(result.n_components):
            variance = result.covariance[curve_index, component, component]
            rows.append(
                {
                    "curve_id": str(curve_id),
                    "component": component + 1,
                    "score": float(
                        result.reference.scores[curve_index, component]
                    ),
                    "conditional_variance": float(variance),
                    "conditional_se": float(
                        result.standard_errors[curve_index, component]
                    ),
                    "status_code": str(diagnostic["status_code"]),
                    "fit_score_status": str(diagnostic["fit_score_status"]),
                    "condition_number": float(
                        diagnostic["condition_number"]
                    ),
                    "method": result.method,
                }
            )
    return pd.DataFrame(rows)

eyetrajectoriespy.sparse_multivariate_score_uncertainty.sparse_mfpca_score_uncertainty_reporting_text

sparse_mfpca_score_uncertainty_reporting_text(
    result: SparseMFPCAScoreUncertaintyResult,
    *,
    digits: int = 3,
) -> str

Generate conservative manuscript-ready conditional-uncertainty text.

Source code in src/eyetrajectoriespy/sparse_multivariate_score_uncertainty.py
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
def sparse_mfpca_score_uncertainty_reporting_text(
    result: SparseMFPCAScoreUncertaintyResult,
    *,
    digits: int = 3,
) -> str:
    """Generate conservative manuscript-ready conditional-uncertainty text."""

    if not isinstance(result, SparseMFPCAScoreUncertaintyResult):
        raise TypeError("result must be a SparseMFPCAScoreUncertaintyResult")
    if isinstance(digits, bool) or not isinstance(digits, int) or digits < 0:
        raise ValueError("digits must be a non-negative integer")
    status = result.diagnostics["status_code"].astype(str).to_numpy()
    solved = int(np.count_nonzero(status == "ok"))
    failed = int(status.size - solved)
    finite_se = result.standard_errors[np.isfinite(result.standard_errors)]
    median_se = float(np.median(finite_se)) if finite_se.size else np.nan
    return (
        "Conditional joint-PACE score uncertainty was computed for "
        f"{len(result.curve_ids)} curve(s) and {result.n_components} retained "
        "joint component(s) using the full fitted planar covariance, declared "
        "measurement-error covariance, native observation times, and the fitted "
        f"score ridge. {solved} curve(s) were solved and {failed} failure(s) "
        "were retained under the declared failure policy; the median finite "
        f"conditional score SE was {median_se:.{digits}f}. These covariances "
        "condition on the fitted joint population objects and do not include "
        "uncertainty from estimating the mean, covariance, eigensystem, "
        "measurement-error specification, or smoothing bandwidths."
    )

Audited sparse-MFPCA bandwidth selection

eyetrajectoriespy.sparse_multivariate_bandwidth_selection.SparseMFPCABandwidthSelectionResult dataclass

Auditable CV result for sparse-MFPCA mean/covariance bandwidths.

Source code in src/eyetrajectoriespy/sparse_multivariate_bandwidth_selection.py
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
@dataclass(frozen=True)
class SparseMFPCABandwidthSelectionResult:
    """Auditable CV result for sparse-MFPCA mean/covariance bandwidths."""

    assignments: pd.DataFrame
    fold_results: pd.DataFrame
    curve_losses: pd.DataFrame
    candidate_summary: pd.DataFrame
    candidates: pd.DataFrame
    selected_bandwidths: Mapping[str, float | str] | None
    criterion: str
    resampling_unit: str
    n_splits: int
    group_column: str | None
    min_valid_folds: int
    status_code: str
    provenance: Mapping[str, Any] = field(default_factory=dict)

eyetrajectoriespy.sparse_multivariate_bandwidth_selection.select_sparse_mfpca_bandwidths

select_sparse_mfpca_bandwidths(
    trajectories: IrregularTrajectorySet,
    *,
    dimensions: tuple[str, str] = ("x", "y"),
    evaluation_grid: ndarray,
    mean_bandwidths: Sequence[float],
    covariance_bandwidths: Sequence[float],
    measurement_error: str,
    measurement_error_variance: tuple[float, float]
    | None = None,
    measurement_error_covariance: ndarray | None = None,
    analysis_support_action: str = "error",
    n_splits: int = 5,
    resampling_unit: str = "curve",
    group_column: str | None = None,
    shuffle: bool = True,
    random_state: int | None = 0,
    min_valid_folds: int | None = None,
    failure_action: str = "retain",
    predictive_condition_limit: float = 1000000000000.0,
    psd_action: str = "error",
    psd_tolerance: float = 1e-08,
    mean_min_local_points: int = 3,
    covariance_min_local_pairs: int = 6,
) -> SparseMFPCABandwidthSelectionResult

Select native sparse-MFPCA mean/covariance bandwidths by CV likelihood.

Each candidate refits the direct sparse planar population mean and Cxx/Cxy/Cyy covariance surfaces from training curves only. Held-out loss is the Gaussian negative log predictive density from the full fitted joint covariance plus the analyst-declared 2x2 measurement-error covariance, normalized by the number of scalar planar observations (2*m_i). Fold loss is the equal-weight mean across held-out curves.

Joint-PACE score estimation is not part of the criterion. The selector does not tune measurement error, rank, score ridge, PSD policy, evaluation grid, or support policy and never applies selected bandwidths automatically to :func:fit_sparse_mfpca.

Source code in src/eyetrajectoriespy/sparse_multivariate_bandwidth_selection.py
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
def select_sparse_mfpca_bandwidths(
    trajectories: IrregularTrajectorySet,
    *,
    dimensions: tuple[str, str] = ("x", "y"),
    evaluation_grid: np.ndarray,
    mean_bandwidths: Sequence[float],
    covariance_bandwidths: Sequence[float],
    measurement_error: str,
    measurement_error_variance: tuple[float, float] | None = None,
    measurement_error_covariance: np.ndarray | None = None,
    analysis_support_action: str = "error",
    n_splits: int = 5,
    resampling_unit: str = "curve",
    group_column: str | None = None,
    shuffle: bool = True,
    random_state: int | None = 0,
    min_valid_folds: int | None = None,
    failure_action: str = "retain",
    predictive_condition_limit: float = 1e12,
    psd_action: str = "error",
    psd_tolerance: float = 1e-8,
    mean_min_local_points: int = 3,
    covariance_min_local_pairs: int = 6,
) -> SparseMFPCABandwidthSelectionResult:
    """Select native sparse-MFPCA mean/covariance bandwidths by CV likelihood.

    Each candidate refits the direct sparse planar population mean and
    Cxx/Cxy/Cyy covariance surfaces from training curves only. Held-out loss is
    the Gaussian negative log predictive density from the full fitted joint
    covariance plus the analyst-declared 2x2 measurement-error covariance,
    normalized by the number of scalar planar observations (2*m_i). Fold loss
    is the equal-weight mean across held-out curves.

    Joint-PACE score estimation is not part of the criterion. The selector does
    not tune measurement error, rank, score ridge, PSD policy, evaluation grid,
    or support policy and never applies selected bandwidths automatically to
    :func:`fit_sparse_mfpca`.
    """

    mean_grid = _positive_grid(mean_bandwidths, name="mean_bandwidths")
    covariance_grid = _positive_grid(
        covariance_bandwidths,
        name="covariance_bandwidths",
    )
    (
        grid,
        (x_index, y_index),
        resolved_error,
        required_folds,
    ) = _validate_selector_inputs(
        trajectories,
        dimensions=dimensions,
        evaluation_grid=evaluation_grid,
        measurement_error=measurement_error,
        measurement_error_variance=measurement_error_variance,
        measurement_error_covariance=measurement_error_covariance,
        analysis_support_action=analysis_support_action,
        n_splits=n_splits,
        resampling_unit=resampling_unit,
        group_column=group_column,
        min_valid_folds=min_valid_folds,
        predictive_condition_limit=predictive_condition_limit,
        failure_action=failure_action,
    )
    splits, assignments, groups = _make_folds(
        trajectories,
        n_splits=n_splits,
        resampling_unit=resampling_unit,
        group_column=group_column,
        shuffle=shuffle,
        random_state=random_state,
    )
    candidates = _candidate_table(mean_grid, covariance_grid)

    fold_rows: list[dict[str, Any]] = []
    curve_rows: list[dict[str, Any]] = []
    for candidate in candidates.to_dict(orient="records"):
        candidate_id = str(candidate["candidate_id"])
        mean_bandwidth = float(candidate["mean_bandwidth"])
        covariance_bandwidth = float(candidate["covariance_bandwidth"])
        for fold, (train_idx, validation_idx) in enumerate(splits):
            train = trajectories.subset(train_idx)
            validation = trajectories.subset(validation_idx)
            raw_train_time_points = int(np.sum(train.sample_counts))
            raw_validation_time_points = int(np.sum(validation.sample_counts))
            effective_train_time_points = _effective_time_point_count(
                train,
                grid,
                action=analysis_support_action,
            )
            effective_validation_time_points = _effective_time_point_count(
                validation,
                grid,
                action=analysis_support_action,
            )
            base_row: dict[str, Any] = {
                "candidate_id": candidate_id,
                "fold": int(fold),
                "mean_bandwidth": mean_bandwidth,
                "covariance_bandwidth": covariance_bandwidth,
                "n_train_curves": int(train.n_curves),
                "n_validation_curves": int(validation.n_curves),
                "n_train_time_points_raw": raw_train_time_points,
                "n_validation_time_points_raw": raw_validation_time_points,
                "n_train_time_points_effective": effective_train_time_points,
                "n_validation_time_points_effective": effective_validation_time_points,
                "n_train_planar_observations_raw": 2 * raw_train_time_points,
                "n_validation_planar_observations_raw": 2 * raw_validation_time_points,
                "n_train_planar_observations_effective": 2
                * effective_train_time_points,
                "n_validation_planar_observations_effective": 2
                * effective_validation_time_points,
            }
            try:
                population = estimate_sparse_planar_mean_covariance(
                    train,
                    dimensions=dimensions,
                    evaluation_grid=grid,
                    mean_bandwidth=mean_bandwidth,
                    covariance_bandwidth=covariance_bandwidth,
                    analysis_support_action=analysis_support_action,
                    psd_action=psd_action,
                    psd_tolerance=psd_tolerance,
                    mean_min_local_points=mean_min_local_points,
                    covariance_min_local_pairs=covariance_min_local_pairs,
                )
            except (SparseNativeError, ValueError, np.linalg.LinAlgError) as exc:
                code, message = _failure_details(exc)
                fold_rows.append(
                    {
                        **base_row,
                        "status_code": "fit_failure",
                        "failure_code": code,
                        "failure_message": message,
                        _CRITERION: np.nan,
                        "median_curve_planar_gaussian_nll": np.nan,
                        "n_evaluated_curves": 0,
                        "n_evaluated_time_points": 0,
                        "n_evaluated_planar_observations": 0,
                        "psd_applied_action": None,
                        "psd_relative_operator_correction": np.nan,
                    }
                )
                if failure_action == "error":
                    raise
                continue

            validation_losses: list[float] = []
            validation_time_points = 0
            validation_failed = False
            first_validation_failure: tuple[str, str] | None = None
            for curve_id, time, values in zip(
                validation.curve_ids,
                validation.time,
                validation.values,
                strict=True,
            ):
                raw_time_points = int(len(time))
                try:
                    effective_time, planar, outside = _effective_validation_curve(
                        time,
                        values,
                        x_index=x_index,
                        y_index=y_index,
                        grid=grid,
                        action=analysis_support_action,
                    )
                    loss, condition, minimum, maximum = _curve_planar_gaussian_nll(
                        population,
                        effective_time,
                        planar,
                        measurement_error_covariance=resolved_error,
                        predictive_condition_limit=predictive_condition_limit,
                    )
                    validation_losses.append(loss)
                    validation_time_points += int(effective_time.size)
                    curve_rows.append(
                        {
                            "candidate_id": candidate_id,
                            "fold": int(fold),
                            "curve_id": str(curve_id),
                            "status_code": "ok",
                            "failure_code": None,
                            "failure_message": None,
                            "planar_gaussian_nll_per_scalar_observation": float(loss),
                            "n_time_points_raw": raw_time_points,
                            "n_time_points_evaluated": int(effective_time.size),
                            "n_planar_observations_evaluated": int(2 * effective_time.size),
                            "outside_support_time_points": int(outside),
                            "predictive_condition_number": float(condition),
                            "predictive_minimum_eigenvalue": float(minimum),
                            "predictive_maximum_eigenvalue": float(maximum),
                            "observation_order": "time_major_interleaved_xy",
                        }
                    )
                except (SparseNativeError, ValueError, np.linalg.LinAlgError) as exc:
                    validation_failed = True
                    code, message = _failure_details(exc)
                    if first_validation_failure is None:
                        first_validation_failure = (code, message)
                    curve_rows.append(
                        {
                            "candidate_id": candidate_id,
                            "fold": int(fold),
                            "curve_id": str(curve_id),
                            "status_code": "validation_failure",
                            "failure_code": code,
                            "failure_message": message,
                            "planar_gaussian_nll_per_scalar_observation": np.nan,
                            "n_time_points_raw": raw_time_points,
                            "n_time_points_evaluated": 0,
                            "n_planar_observations_evaluated": 0,
                            "outside_support_time_points": np.nan,
                            "predictive_condition_number": np.nan,
                            "predictive_minimum_eigenvalue": np.nan,
                            "predictive_maximum_eigenvalue": np.nan,
                            "observation_order": "time_major_interleaved_xy",
                        }
                    )
                    if failure_action == "error":
                        raise

            audit = population.operator_audit
            if validation_failed:
                code, message = first_validation_failure or (
                    "validation_failure",
                    "held-out planar validation failed",
                )
                fold_rows.append(
                    {
                        **base_row,
                        "status_code": "validation_failure",
                        "failure_code": code,
                        "failure_message": message,
                        _CRITERION: np.nan,
                        "median_curve_planar_gaussian_nll": np.nan,
                        "n_evaluated_curves": int(len(validation_losses)),
                        "n_evaluated_time_points": int(validation_time_points),
                        "n_evaluated_planar_observations": int(
                            2 * validation_time_points
                        ),
                        "psd_applied_action": audit.applied_action,
                        "psd_relative_operator_correction": float(
                            audit.relative_operator_correction_frobenius_norm
                        ),
                    }
                )
                continue

            losses = np.asarray(validation_losses, dtype=float)
            fold_rows.append(
                {
                    **base_row,
                    "status_code": "ok",
                    "failure_code": None,
                    "failure_message": None,
                    _CRITERION: float(np.mean(losses)),
                    "median_curve_planar_gaussian_nll": float(np.median(losses)),
                    "n_evaluated_curves": int(losses.size),
                    "n_evaluated_time_points": int(validation_time_points),
                    "n_evaluated_planar_observations": int(
                        2 * validation_time_points
                    ),
                    "psd_applied_action": audit.applied_action,
                    "psd_relative_operator_correction": float(
                        audit.relative_operator_correction_frobenius_norm
                    ),
                }
            )

    fold_results = pd.DataFrame(fold_rows)
    curve_losses = pd.DataFrame(curve_rows)
    summary = _summarise_candidates(
        candidates,
        fold_results,
        min_valid_folds=required_folds,
    )
    selected = _select_minimum_candidate(summary)
    status_code = "ok" if selected is not None else "no_eligible_candidate"

    provenance: dict[str, Any] = {
        "method": _METHOD,
        "criterion": _CRITERION,
        "criterion_formula": (
            "0.5*(logdet(Sigma_i)+r_i^T Sigma_i^-1 r_i+"
            "2*m_i*log(2*pi))/(2*m_i); fold mean weights held-out curves equally"
        ),
        "predictive_covariance": "full_fitted_joint_covariance_plus_declared_measurement_error",
        "population_refit_inside_fold": True,
        "joint_pace_scoring_performed": False,
        "validation_observations_used_for_population_fitting": False,
        "validation_observations_used_for_score_fitting": False,
        "full_fitted_joint_covariance_used_for_validation": True,
        "rank_k_covariance_used_for_validation": False,
        "score_ridge_included_in_validation_covariance": False,
        "raw_sparse_trajectory_interpolation_performed": False,
        "score_observation_order": "time_major_interleaved_xy",
        "operator_storage_order": "channel_major",
        "measurement_error_mode": measurement_error,
        "measurement_error_covariance": resolved_error.copy(),
        "measurement_error_tuned": False,
        "measurement_error_policy": "fixed_declared_across_candidates_and_folds",
        "resampling_unit": resampling_unit,
        "group_column": group_column if resampling_unit == "group" else None,
        "group_leakage_prevented": resampling_unit == "group",
        "n_splits": int(n_splits),
        "shuffle": bool(shuffle) if resampling_unit == "curve" else False,
        "random_state": (
            random_state if resampling_unit == "curve" and shuffle else None
        ),
        "min_valid_folds": int(required_folds),
        "failure_action": failure_action,
        "predictive_condition_limit": float(predictive_condition_limit),
        "candidate_order": "sorted_unique_mean_x_covariance_cartesian_product",
        "selection_rule": "minimum_mean_fold_loss",
        "tie_break_rule": (
            "minimum loss, then larger mean/covariance bandwidths "
            "lexicographically, then candidate_id"
        ),
        "one_se_rule_implemented": False,
        "one_se_reason": (
            "no predeclared scalar simplicity ordering for a multidimensional "
            "bandwidth tuple"
        ),
        "automatic_fit_bandwidth_selection_performed": False,
        "selected_values_must_be_passed_explicitly_to_fit_sparse_mfpca": True,
        "rank_tuned": False,
        "score_ridge_tuned": False,
        "evaluation_grid_tuned": False,
        "psd_policy_tuned": False,
        "support_policy_tuned": False,
        "evaluation_grid": grid.tolist(),
        "analysis_support_action": analysis_support_action,
        "psd_action": psd_action,
        "psd_tolerance": float(psd_tolerance),
        "mean_min_local_points": int(mean_min_local_points),
        "covariance_min_local_pairs": int(covariance_min_local_pairs),
        "candidate_count": int(len(candidates)),
        "fold_assignment_count": int(len(assignments)),
    }
    if groups is not None:
        provenance["group_count"] = int(len(pd.unique(groups)))

    return SparseMFPCABandwidthSelectionResult(
        assignments=assignments,
        fold_results=fold_results,
        curve_losses=curve_losses,
        candidate_summary=summary,
        candidates=candidates,
        selected_bandwidths=selected,
        criterion=_CRITERION,
        resampling_unit=resampling_unit,
        n_splits=int(n_splits),
        group_column=group_column if resampling_unit == "group" else None,
        min_valid_folds=int(required_folds),
        status_code=status_code,
        provenance=provenance,
    )

eyetrajectoriespy.sparse_multivariate_bandwidth_selection.sparse_mfpca_bandwidth_selection_reporting_text

sparse_mfpca_bandwidth_selection_reporting_text(
    result: SparseMFPCABandwidthSelectionResult,
    *,
    digits: int = 3,
) -> str

Return conservative audit-oriented reporting text for the selector.

Source code in src/eyetrajectoriespy/sparse_multivariate_bandwidth_selection.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
def sparse_mfpca_bandwidth_selection_reporting_text(
    result: SparseMFPCABandwidthSelectionResult,
    *,
    digits: int = 3,
) -> str:
    """Return conservative audit-oriented reporting text for the selector."""

    if not isinstance(result, SparseMFPCABandwidthSelectionResult):
        raise TypeError("result must be a SparseMFPCABandwidthSelectionResult")
    if isinstance(digits, bool) or not isinstance(digits, int) or digits < 0:
        raise ValueError("digits must be a non-negative integer")

    eligible = int(np.count_nonzero(result.candidate_summary["eligible"]))
    failed_folds = int(
        np.count_nonzero(result.fold_results["status_code"].to_numpy() != "ok")
    )
    selected = result.selected_bandwidths
    if selected is None:
        selection = "No candidate met the declared valid-fold requirement."
    else:
        selected_id = str(selected["candidate_id"])
        row = result.candidate_summary[
            result.candidate_summary["candidate_id"] == selected_id
        ].iloc[0]
        selection = (
            f"The minimum eligible mean fold loss selected {selected_id} "
            f"(mean bandwidth={float(selected['mean_bandwidth']):.{digits}f}, "
            f"covariance bandwidth={float(selected['covariance_bandwidth']):.{digits}f}, "
            f"mean loss={float(row['mean_loss']):.{digits}f})."
        )

    unit_text = (
        "curves"
        if result.resampling_unit == "curve"
        else f"groups from metadata column {result.group_column!r}"
    )
    return (
        f"Sparse planar MFPCA bandwidth selection evaluated "
        f"{len(result.candidates)} declared mean/covariance candidate(s) using "
        f"{result.n_splits}-fold cross-validation over {unit_text}. Population "
        f"mean and full joint latent covariance surfaces were refitted from "
        f"training curves only. Held-out curves were scored by Gaussian negative "
        f"log predictive density from the full fitted joint covariance plus the "
        f"fixed declared 2x2 measurement-error covariance, normalised per scalar "
        f"planar observation and then averaged equally across curves. Joint-PACE "
        f"scores were not used for selection. {eligible} candidate(s) were "
        f"eligible and {failed_folds} candidate-fold failure(s) were retained. "
        f"{selection} Selected values are not applied automatically to "
        f"fit_sparse_mfpca(); they must be supplied explicitly."
    )

The measurement-error covariance is declared separately and is not silently tuned by this selector.

Native sparse participant/trial multilevel FPCA

eyetrajectoriespy.sparse_multilevel.SparseMultilevelFPCAResult dataclass

Native sparse two-level participant/trial functional decomposition.

Source code in src/eyetrajectoriespy/sparse_multilevel.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
@dataclass(frozen=True)
class SparseMultilevelFPCAResult:
    """Native sparse two-level participant/trial functional decomposition."""

    evaluation_grid: np.ndarray
    dimension: str
    participant_column: str
    curve_ids: tuple[str, ...]
    participant_ids: tuple[str, ...]
    grand_mean: np.ndarray
    smoothed_total_covariance: np.ndarray
    smoothed_between_covariance: np.ndarray
    smoothed_within_covariance: np.ndarray
    total_covariance: np.ndarray
    between_covariance: np.ndarray
    within_covariance: np.ndarray
    participant_eigenvalues: np.ndarray
    participant_eigenfunctions: np.ndarray
    trial_eigenvalues: np.ndarray
    trial_eigenfunctions: np.ndarray
    participant_scores: pd.DataFrame
    trial_scores: pd.DataFrame
    score_diagnostics: pd.DataFrame
    noise_variance: float
    quadrature_weights: np.ndarray
    mean_support_counts: np.ndarray
    total_covariance_support_counts: np.ndarray
    between_covariance_support_counts: np.ndarray
    covariance_diagnostics: Mapping[str, Any]
    support_diagnostics: Mapping[str, Any]
    noise_raw_diagonal: np.ndarray | None = None
    noise_diagonal_difference: np.ndarray | None = None
    provenance: Mapping[str, Any] = field(default_factory=dict)

eyetrajectoriespy.sparse_multilevel.fit_sparse_multilevel_fpca

fit_sparse_multilevel_fpca(
    trajectories: IrregularTrajectorySet,
    *,
    dimension: str,
    participant_column: str,
    participant_components: int,
    trial_components: int,
    evaluation_grid: ndarray,
    mean_bandwidth: float,
    total_covariance_bandwidth: float,
    between_covariance_bandwidth: float,
    noise_bandwidth: float | None = None,
    noise_support: tuple[float, float] | None = None,
    analysis_support_action: str = "error",
    weighting: str = "observation",
    mean_smoother: str = "local_linear",
    covariance_smoother: str = "local_linear",
    kernel: str = "epanechnikov",
    noise_variance_method: str = "diagonal_difference",
    measurement_error_variance: float | None = None,
    psd_action: str = "error",
    psd_tolerance: float = 1e-08,
    positive_eigen_tolerance: float = 1e-10,
    score_ridge: float = 0.0,
    score_condition_limit: float = 1000000000000.0,
    min_participant_score_samples: int = 2,
    score_failure_action: str = "error",
    mean_min_local_points: int = 3,
    total_covariance_min_local_pairs: int = 6,
    between_covariance_min_local_pairs: int = 6,
    noise_min_local_points: int = 3,
) -> SparseMultilevelFPCAResult

Fit native sparse participant/trial multilevel FPCA.

The estimator targets an exchangeable two-level hierarchy. Trial-condition or visit-specific fixed functional effects are not estimated; users must stratify or remove such effects upstream when scientifically required.

Raw sparse trials remain on native grids. evaluation_grid is used only for fitted population functions/operators. Population quantities are later evaluated at native times for joint participant/trial BLUP scores.

The first tranche uses observation/raw-pair weighting. Participants with more retained observations or covariance pairs can therefore contribute more to local smoothers; this is recorded explicitly and is not described as equal-participant weighting.

Source code in src/eyetrajectoriespy/sparse_multilevel.py
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
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
def fit_sparse_multilevel_fpca(
    trajectories: IrregularTrajectorySet,
    *,
    dimension: str,
    participant_column: str,
    participant_components: int,
    trial_components: int,
    evaluation_grid: np.ndarray,
    mean_bandwidth: float,
    total_covariance_bandwidth: float,
    between_covariance_bandwidth: float,
    noise_bandwidth: float | None = None,
    noise_support: tuple[float, float] | None = None,
    analysis_support_action: str = "error",
    weighting: str = "observation",
    mean_smoother: str = "local_linear",
    covariance_smoother: str = "local_linear",
    kernel: str = "epanechnikov",
    noise_variance_method: str = "diagonal_difference",
    measurement_error_variance: float | None = None,
    psd_action: str = "error",
    psd_tolerance: float = 1e-8,
    positive_eigen_tolerance: float = 1e-10,
    score_ridge: float = 0.0,
    score_condition_limit: float = 1e12,
    min_participant_score_samples: int = 2,
    score_failure_action: str = "error",
    mean_min_local_points: int = 3,
    total_covariance_min_local_pairs: int = 6,
    between_covariance_min_local_pairs: int = 6,
    noise_min_local_points: int = 3,
) -> SparseMultilevelFPCAResult:
    """Fit native sparse participant/trial multilevel FPCA.

    The estimator targets an exchangeable two-level hierarchy. Trial-condition
    or visit-specific fixed functional effects are not estimated; users must
    stratify or remove such effects upstream when scientifically required.

    Raw sparse trials remain on native grids. ``evaluation_grid`` is used only
    for fitted population functions/operators. Population quantities are later
    evaluated at native times for joint participant/trial BLUP scores.

    The first tranche uses observation/raw-pair weighting. Participants with
    more retained observations or covariance pairs can therefore contribute
    more to local smoothers; this is recorded explicitly and is not described as
    equal-participant weighting.
    """

    dimension_index = _validate_native_sparse_dimension(
        trajectories,
        dimension=dimension,
    )
    participants, unique_participants, raw_trial_counts = _validate_hierarchy(
        trajectories,
        participant_column=participant_column,
    )
    grid = _validate_evaluation_grid(trajectories, evaluation_grid)
    _validate_multilevel_settings(
        participant_components=participant_components,
        trial_components=trial_components,
        weighting=weighting,
        mean_smoother=mean_smoother,
        covariance_smoother=covariance_smoother,
        kernel=kernel,
        noise_variance_method=noise_variance_method,
        measurement_error_variance=measurement_error_variance,
        noise_support=noise_support,
    )

    original_values = tuple(
        np.asarray(values[:, dimension_index], dtype=float)
        for values in trajectories.values
    )
    curve_times, curve_values, support = _analysis_support_views(
        trajectories,
        original_values,
        grid,
        action=analysis_support_action,
    )
    analysis_counts = np.asarray(
        [int(time.size) for time in curve_times],
        dtype=int,
    )
    if np.any(analysis_counts == 0):
        failed = [
            str(trajectories.curve_ids[index])
            for index in np.flatnonzero(analysis_counts == 0)
        ]
        raise SparseNativeError(
            "curve_without_analysis_support",
            "every trial must retain at least one observation on analysis support",
            details={"curve_ids": failed},
        )

    pooled_time = np.concatenate(curve_times)
    pooled_values = np.concatenate(curve_values)
    mean_fit = local_linear_smooth_1d(
        pooled_time,
        pooled_values,
        grid,
        bandwidth=mean_bandwidth,
        min_local_points=mean_min_local_points,
    )

    residuals: list[np.ndarray] = []
    for time, observed in zip(curve_times, curve_values, strict=True):
        native_mean = local_linear_smooth_1d(
            pooled_time,
            pooled_values,
            np.asarray(time, dtype=float),
            bandwidth=mean_bandwidth,
            min_local_points=mean_min_local_points,
        ).values
        residuals.append(np.asarray(observed, dtype=float) - native_mean)
    residual_tuple = tuple(residuals)

    total_pairs = raw_offdiagonal_covariance_pairs(
        curve_times,
        residual_tuple,
        include_mirror=True,
    )
    between_pairs = raw_cross_trial_covariance_pairs(
        curve_times,
        residual_tuple,
        participants,
        include_mirror=True,
    )
    total_fit = local_linear_covariance_surface(
        total_pairs,
        grid,
        bandwidth=total_covariance_bandwidth,
        min_local_pairs=total_covariance_min_local_pairs,
    )
    between_fit = local_linear_covariance_surface(
        between_pairs,
        grid,
        bandwidth=between_covariance_bandwidth,
        min_local_pairs=between_covariance_min_local_pairs,
    )
    smoothed_within = 0.5 * (
        (total_fit.values - between_fit.values)
        + (total_fit.values - between_fit.values).T
    )

    noise_result = None
    if noise_variance_method == "diagonal_difference":
        if noise_bandwidth is None:
            raise ValueError(
                "noise_bandwidth is required when noise_variance_method='diagonal_difference'"
            )
        latent_diagonal = rotated_local_quadratic_covariance_diagonal(
            total_pairs,
            grid,
            bandwidth=total_covariance_bandwidth,
            min_local_pairs=total_covariance_min_local_pairs,
        )
        noise_result = estimate_noise_variance_diagonal_difference(
            curve_times,
            residual_tuple,
            grid,
            total_fit.values,
            latent_diagonal=latent_diagonal.values,
            bandwidth=noise_bandwidth,
            noise_support=noise_support,
            min_local_points=noise_min_local_points,
        )
        if noise_result.status_code != "ok":
            raise SparseNativeError(
                "noise_variance_invalid",
                "diagonal-difference measurement-noise estimate is not positive",
                details={
                    "estimated_noise_variance": float(noise_result.variance),
                    "minimum_diagonal_difference": float(
                        np.min(noise_result.diagonal_difference)
                    ),
                    "maximum_diagonal_difference": float(
                        np.max(noise_result.diagonal_difference)
                    ),
                },
            )
        noise_variance = float(noise_result.variance)
    else:
        noise_variance = float(measurement_error_variance)

    between_psd = repair_covariance_psd(
        between_fit.values,
        grid,
        action=psd_action,
        tolerance=psd_tolerance,
    )
    within_psd = repair_covariance_psd(
        smoothed_within,
        grid,
        action=psd_action,
        tolerance=psd_tolerance,
    )
    between_eigen = weighted_covariance_eigendecomposition(
        between_psd.covariance,
        grid,
        n_components=participant_components,
        positive_tolerance=positive_eigen_tolerance,
    )
    within_eigen = weighted_covariance_eigendecomposition(
        within_psd.covariance,
        grid,
        n_components=trial_components,
        positive_tolerance=positive_eigen_tolerance,
    )

    score_result = sparse_multilevel_blup_scores(
        trajectories.curve_ids,
        participants,
        curve_times,
        curve_values,
        evaluation_grid=grid,
        fitted_mean=mean_fit.values,
        between_covariance=between_psd.covariance,
        within_covariance=within_psd.covariance,
        participant_eigenvalues=between_eigen.eigenvalues,
        participant_eigenfunctions=between_eigen.eigenfunctions,
        trial_eigenvalues=within_eigen.eigenvalues,
        trial_eigenfunctions=within_eigen.eigenfunctions,
        noise_variance=noise_variance,
        participant_components=participant_components,
        trial_components=trial_components,
        score_ridge=score_ridge,
        condition_limit=score_condition_limit,
        min_participant_samples=min_participant_score_samples,
        failure_action=score_failure_action,
    )

    participant_scores = pd.DataFrame(
        score_result.participant_scores,
        columns=[
            f"participant_FPC{component + 1}"
            for component in range(participant_components)
        ],
    )
    participant_scores.insert(
        0,
        participant_column,
        list(score_result.participant_ids),
    )
    trial_scores = pd.DataFrame(
        score_result.trial_scores,
        columns=[f"trial_FPC{component + 1}" for component in range(trial_components)],
    )
    trial_scores.insert(0, participant_column, participants.tolist())
    trial_scores.insert(0, "curve_id", list(score_result.curve_ids))

    between_psd_diagnostics = _psd_audit_dict(between_psd)
    within_psd_diagnostics = _psd_audit_dict(within_psd)
    covariance_diagnostics: dict[str, Any] = {
        "between": between_psd_diagnostics,
        "within": within_psd_diagnostics,
        "smoothed_within_definition": "smoothed_total_minus_smoothed_between",
        "score_total_definition": "repaired_between_plus_repaired_within",
        "post_psd_total_difference_frobenius_norm": float(
            np.linalg.norm(
                (between_psd.covariance + within_psd.covariance)
                - total_fit.values
            )
        ),
    }

    trial_counts = {
        participant: int(np.count_nonzero(participants == participant))
        for participant in unique_participants
    }
    participant_sample_counts = {
        participant: int(
            np.sum(analysis_counts[np.flatnonzero(participants == participant)])
        )
        for participant in unique_participants
    }
    between_pair_counts = np.bincount(
        between_pairs.curve_index,
        minlength=len(unique_participants),
    )
    support_diagnostics: dict[str, Any] = {
        **support,
        "n_participants": int(len(unique_participants)),
        "n_repeated_participants": int(
            sum(count >= 2 for count in trial_counts.values())
        ),
        "raw_trial_counts_by_participant": raw_trial_counts,
        "analysis_trial_counts_by_participant": trial_counts,
        "analysis_sample_counts_by_participant": participant_sample_counts,
        "total_covariance_pair_count": int(total_pairs.n_pairs),
        "between_covariance_pair_count": int(between_pairs.n_pairs),
        "between_pair_counts_by_participant": {
            participant: int(between_pair_counts[index])
            for index, participant in enumerate(unique_participants)
        },
        "single_trial_participants": [
            participant
            for participant, count in trial_counts.items()
            if count == 1
        ],
    }

    total_used = between_psd.covariance + within_psd.covariance
    provenance: dict[str, Any] = {
        **dict(trajectories.provenance),
        "sparse_multilevel_fpca": {
            "backend": "native",
            "method": "sparse_two_level_covariance_BLUP",
            "model": "Y_ij(t)=mu(t)+U_i(t)+V_ij(t)+epsilon_ij(t)",
            "dimension": dimension,
            "participant_column": participant_column,
            "participant_components": int(participant_components),
            "trial_components": int(trial_components),
            "n_participants": int(len(unique_participants)),
            "n_trials": int(trajectories.n_curves),
            "exchangeable_trials_assumed": True,
            "trial_fixed_effects_estimated": False,
            "fixed_effect_requirement": (
                "condition_or_visit effects must be absent, stratified, or removed upstream"
            ),
            "weighting": weighting,
            "weighting_interpretation": (
                "pooled native observations for mean smoothing; pooled raw covariance pairs for covariance smoothing"
            ),
            "equal_participant_weighting_claimed": False,
            "mean_smoother": mean_smoother,
            "covariance_smoother": covariance_smoother,
            "kernel": kernel,
            "mean_bandwidth": float(mean_bandwidth),
            "total_covariance_bandwidth": float(total_covariance_bandwidth),
            "between_covariance_bandwidth": float(between_covariance_bandwidth),
            "between_covariance_source": (
                "same_participant_distinct_trial_cross_products"
            ),
            "total_covariance_source": "within_trial_offdiagonal_products",
            "within_covariance_definition": "smoothed_total_minus_smoothed_between",
            "measurement_error_method": noise_variance_method,
            "noise_variance": float(noise_variance),
            "noise_bandwidth": (
                None if noise_bandwidth is None else float(noise_bandwidth)
            ),
            "noise_support": (
                None if noise_support is None else list(noise_support)
            ),
            "psd_action": psd_action,
            "psd_tolerance": float(psd_tolerance),
            "positive_eigen_tolerance": float(positive_eigen_tolerance),
            "score_method": "joint_hierarchical_Gaussian_BLUP",
            "score_covariance_source": score_result.covariance_source,
            "rank_k_covariance_used_for_scoring": False,
            "score_ridge": float(score_ridge),
            "score_condition_limit": float(score_condition_limit),
            "min_participant_score_samples": int(min_participant_score_samples),
            "score_failure_action": score_failure_action,
            "raw_sparse_trajectory_interpolation_performed": False,
            "population_function_evaluation_at_native_times": True,
            "automatic_bandwidth_selection_performed": False,
            "population_estimation_uncertainty_propagated": False,
            "analysis_support_action": analysis_support_action,
            "covariance_psd": covariance_diagnostics,
        },
    }
    if measurement_error_variance is not None:
        provenance["sparse_multilevel_fpca"][
            "measurement_error_variance_supplied"
        ] = float(measurement_error_variance)

    return SparseMultilevelFPCAResult(
        evaluation_grid=grid.copy(),
        dimension=dimension,
        participant_column=participant_column,
        curve_ids=tuple(str(value) for value in trajectories.curve_ids),
        participant_ids=tuple(str(value) for value in score_result.participant_ids),
        grand_mean=mean_fit.values.copy(),
        smoothed_total_covariance=total_fit.values.copy(),
        smoothed_between_covariance=between_fit.values.copy(),
        smoothed_within_covariance=smoothed_within.copy(),
        total_covariance=total_used.copy(),
        between_covariance=between_psd.covariance.copy(),
        within_covariance=within_psd.covariance.copy(),
        participant_eigenvalues=between_eigen.eigenvalues.copy(),
        participant_eigenfunctions=between_eigen.eigenfunctions.copy(),
        trial_eigenvalues=within_eigen.eigenvalues.copy(),
        trial_eigenfunctions=within_eigen.eigenfunctions.copy(),
        participant_scores=participant_scores,
        trial_scores=trial_scores,
        score_diagnostics=score_result.diagnostics.copy(),
        noise_variance=float(noise_variance),
        quadrature_weights=between_eigen.quadrature_weights.copy(),
        mean_support_counts=mean_fit.support_counts.copy(),
        total_covariance_support_counts=total_fit.support_counts.copy(),
        between_covariance_support_counts=between_fit.support_counts.copy(),
        covariance_diagnostics=covariance_diagnostics,
        support_diagnostics=support_diagnostics,
        noise_raw_diagonal=(
            None if noise_result is None else noise_result.raw_diagonal.copy()
        ),
        noise_diagonal_difference=(
            None
            if noise_result is None
            else noise_result.diagonal_difference.copy()
        ),
        provenance=provenance,
    )

eyetrajectoriespy.sparse_multilevel.sparse_multilevel_fpca_reporting_text

sparse_multilevel_fpca_reporting_text(
    result: SparseMultilevelFPCAResult, *, digits: int = 3
) -> str

Generate manuscript-ready wording for the sparse multilevel fit.

Source code in src/eyetrajectoriespy/sparse_multilevel.py
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
def sparse_multilevel_fpca_reporting_text(
    result: SparseMultilevelFPCAResult,
    *,
    digits: int = 3,
) -> str:
    """Generate manuscript-ready wording for the sparse multilevel fit."""

    if not isinstance(result, SparseMultilevelFPCAResult):
        raise TypeError("result must be a SparseMultilevelFPCAResult")
    if isinstance(digits, bool) or not isinstance(digits, int) or digits < 0:
        raise ValueError("digits must be a non-negative integer")
    provenance = result.provenance.get("sparse_multilevel_fpca", {})
    failed = int(
        np.count_nonzero(result.score_diagnostics["status_code"].to_numpy() != "ok")
    )
    between_action = result.covariance_diagnostics["between"]["applied_action"]
    within_action = result.covariance_diagnostics["within"]["applied_action"]
    return (
        "Native sparse multilevel FPCA modeled irregular trials as "
        "Y_ij(t)=mu(t)+U_i(t)+V_ij(t)+epsilon_ij(t), with "
        f"{len(result.participant_ids)} participants and {len(result.curve_ids)} trials. "
        "The grand mean used pooled native observations; total latent covariance "
        "used within-trial off-diagonal products and between-participant covariance "
        "used same-participant cross-trial products. The within-trial operator was "
        "defined as smoothed total minus smoothed between covariance before the "
        f"declared PSD policy (between={between_action}, within={within_action}). "
        f"Measurement-error variance was {result.noise_variance:.{digits}g}. "
        "Participant and trial scores were recovered jointly by Gaussian BLUP using "
        "the full repaired between/within covariance surfaces at native timestamps, "
        "not rank-truncated reconstructions. "
        f"{failed} participant score systems failed under the declared policy. "
        "The first sparse multilevel tranche assumes exchangeable repeated trials, "
        "does not estimate trial-condition fixed functional effects, uses observation/"
        "raw-pair weighting rather than equal-participant weighting, does not "
        "interpolate raw sparse trajectories, and does not propagate population-"
        "estimation or bandwidth uncertainty."
    )

Participant scores, trial scores, and score diagnostics remain separate result tables because they have different observational units.

Asynchronous coordinate-specific sparse planar MFPCA

eyetrajectoriespy.sparse_multivariate_async.SparseAsyncMFPCAResult dataclass

Native asynchronous sparse planar MFPCA/PACE result.

The input may contain coordinate-specific missingness on each curve's exact union of native x/y timestamps. No raw interpolation or synchronization is implied by this result.

Source code in src/eyetrajectoriespy/sparse_multivariate_async.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
@dataclass(frozen=True)
class SparseAsyncMFPCAResult:
    """Native asynchronous sparse planar MFPCA/PACE result.

    The input may contain coordinate-specific missingness on each curve's exact
    union of native x/y timestamps. No raw interpolation or synchronization is
    implied by this result.
    """

    scores: np.ndarray
    eigenvalues: np.ndarray
    eigenfunctions: np.ndarray
    mean: np.ndarray
    evaluation_grid: np.ndarray
    dimensions: tuple[str, str]
    curve_ids: tuple[str, ...]
    metadata: pd.DataFrame
    coordinate_system: str
    time_unit: str
    n_components: int
    quadrature_weights: np.ndarray
    smoothed_cxx: np.ndarray
    smoothed_cxy: np.ndarray
    smoothed_cyx: np.ndarray
    smoothed_cyy: np.ndarray
    covariance_cxx: np.ndarray
    covariance_cxy: np.ndarray
    covariance_cyx: np.ndarray
    covariance_cyy: np.ndarray
    measurement_error_covariance: np.ndarray
    score_diagnostics: pd.DataFrame
    mean_support_counts: np.ndarray
    covariance_support_counts: Mapping[str, np.ndarray]
    covariance_pair_counts: Mapping[str, int]
    covariance_diagnostics: Mapping[str, Any]
    support_diagnostics: Mapping[str, Any]
    fit_method: str
    score_method: str
    provenance: Mapping[str, Any] = field(default_factory=dict)

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

eyetrajectoriespy.sparse_multivariate_async.fit_sparse_mfpca_async

fit_sparse_mfpca_async(
    trajectories: IrregularTrajectorySet,
    *,
    dimensions: tuple[str, str] = ("x", "y"),
    n_components: int,
    evaluation_grid: ndarray,
    mean_bandwidth: float,
    covariance_bandwidth: float,
    measurement_error: str,
    measurement_error_variance: tuple[float, float]
    | None = None,
    measurement_error_covariance: ndarray | None = None,
    analysis_support_action: str = "error",
    same_time_tolerance: float = 0.0,
    psd_action: str = "error",
    psd_tolerance: float = 1e-08,
    positive_eigen_tolerance: float = 1e-10,
    score_ridge: float = 0.0,
    score_condition_limit: float = 1000000000000.0,
    min_score_observations: int = 2,
    score_failure_action: str = "error",
    mean_min_local_points: int = 3,
    covariance_min_local_pairs: int = 6,
) -> SparseAsyncMFPCAResult

Fit native asynchronous sparse planar MFPCA with joint PACE scores.

Each curve may observe x and y on different native grids. The existing :class:IrregularTrajectorySet is interpreted as an exact union-of-native- timestamps representation: a coordinate-specific NaN means that coordinate was not observed at that exact timestamp. Rows where both requested coordinates are absent are invalid.

Raw observations are never interpolated, nearest-neighbour synchronized, or binned. The common evaluation_grid belongs only to fitted population functions. Latent Cxy excludes simultaneous x-y residual products, while a declared correlated measurement-error covariance is used only for x/y scalar observations that are simultaneous within same_time_tolerance.

Source code in src/eyetrajectoriespy/sparse_multivariate_async.py
 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
def fit_sparse_mfpca_async(
    trajectories: IrregularTrajectorySet,
    *,
    dimensions: tuple[str, str] = ("x", "y"),
    n_components: int,
    evaluation_grid: np.ndarray,
    mean_bandwidth: float,
    covariance_bandwidth: float,
    measurement_error: str,
    measurement_error_variance: tuple[float, float] | None = None,
    measurement_error_covariance: np.ndarray | None = None,
    analysis_support_action: str = "error",
    same_time_tolerance: float = 0.0,
    psd_action: str = "error",
    psd_tolerance: float = 1e-8,
    positive_eigen_tolerance: float = 1e-10,
    score_ridge: float = 0.0,
    score_condition_limit: float = 1e12,
    min_score_observations: int = 2,
    score_failure_action: str = "error",
    mean_min_local_points: int = 3,
    covariance_min_local_pairs: int = 6,
) -> SparseAsyncMFPCAResult:
    """Fit native asynchronous sparse planar MFPCA with joint PACE scores.

    Each curve may observe x and y on different native grids. The existing
    :class:`IrregularTrajectorySet` is interpreted as an exact union-of-native-
    timestamps representation: a coordinate-specific ``NaN`` means that
    coordinate was not observed at that exact timestamp. Rows where both
    requested coordinates are absent are invalid.

    Raw observations are never interpolated, nearest-neighbour synchronized,
    or binned. The common ``evaluation_grid`` belongs only to fitted population
    functions. Latent Cxy excludes simultaneous x-y residual products, while a
    declared correlated measurement-error covariance is used only for x/y
    scalar observations that are simultaneous within ``same_time_tolerance``.
    """

    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")

    resolved_error = resolve_measurement_error_covariance(
        measurement_error,
        measurement_error_variance=measurement_error_variance,
        measurement_error_covariance=measurement_error_covariance,
    )

    population = estimate_async_sparse_planar_mean_covariance(
        trajectories,
        dimensions=dimensions,
        evaluation_grid=evaluation_grid,
        mean_bandwidth=mean_bandwidth,
        covariance_bandwidth=covariance_bandwidth,
        analysis_support_action=analysis_support_action,
        same_time_tolerance=same_time_tolerance,
        psd_action=psd_action,
        psd_tolerance=psd_tolerance,
        mean_min_local_points=mean_min_local_points,
        covariance_min_local_pairs=covariance_min_local_pairs,
    )

    eigen = weighted_planar_covariance_eigendecomposition(
        population.covariance_blocks.matrix,
        population.evaluation_grid,
        n_components=n_components,
        positive_tolerance=positive_eigen_tolerance,
    )

    scores = async_joint_pace_scores(
        trajectories.curve_ids,
        population.x_times,
        population.x_values,
        population.y_times,
        population.y_values,
        evaluation_grid=population.evaluation_grid,
        fitted_mean=population.mean,
        covariance_blocks=population.covariance_blocks,
        eigenvalues=eigen.eigenvalues,
        eigenfunctions=eigen.eigenfunctions,
        measurement_error_covariance=resolved_error.covariance,
        n_components=n_components,
        same_time_tolerance=same_time_tolerance,
        score_ridge=score_ridge,
        condition_limit=score_condition_limit,
        min_score_observations=min_score_observations,
        failure_action=score_failure_action,
    )

    covariance_diagnostics = _covariance_audit_mapping(population.operator_audit)
    covariance_support_counts = {
        "cxx": population.covariance_support_counts.cxx.copy(),
        "cxy": population.covariance_support_counts.cxy.copy(),
        "cyy": population.covariance_support_counts.cyy.copy(),
    }
    covariance_pair_counts = {
        key: int(value) for key, value in population.covariance_pair_counts.items()
    }
    support_diagnostics = {
        **dict(population.support_diagnostics),
        **dict(population.pair_diagnostics),
    }

    async_provenance: dict[str, Any] = {
        "backend": "native",
        "fit_method": "direct_async_sparse_block_covariance",
        "score_method": "asynchronous_joint_PACE",
        "dimensions": list(dimensions),
        "n_components": int(n_components),
        "component_rank_rule": "positive_fitted_joint_operator_spectrum",
        "input_representation": "exact_union_of_native_coordinate_timestamps",
        "coordinate_specific_missingness_supported": True,
        "mean_bandwidth": float(mean_bandwidth),
        "covariance_bandwidth": float(covariance_bandwidth),
        "analysis_support": list(population.provenance["analysis_support"]),
        "analysis_support_action": analysis_support_action,
        "same_time_tolerance": float(same_time_tolerance),
        "cross_covariance_same_time_products_excluded": True,
        "cross_covariance_same_time_pair_count_excluded": int(
            population.pair_diagnostics["cross_same_time_pair_count_excluded"]
        ),
        "measurement_error_independent_across_distinct_times_assumed": True,
        "measurement_error_cross_covariance_scope": "simultaneous_xy_only",
        "yx_estimated_independently": False,
        "cross_covariance_self_symmetrized": False,
        "score_observation_order": scores.provenance["score_observation_order"],
        "paired_reduction_order": scores.provenance["paired_reduction_order"],
        "score_covariance_source": scores.provenance["score_covariance_source"],
        "rank_k_covariance_used_for_scoring": False,
        "measurement_error_mode": measurement_error,
        "measurement_error_covariance": resolved_error.covariance.copy(),
        "measurement_error_covariance_eigenvalues": resolved_error.eigenvalues.copy(),
        "measurement_error_covariance_symmetry_error": float(
            resolved_error.symmetry_error
        ),
        "psd_action": psd_action,
        "psd_tolerance": float(psd_tolerance),
        "positive_eigen_tolerance": float(positive_eigen_tolerance),
        "score_ridge": float(score_ridge),
        "score_condition_limit": float(score_condition_limit),
        "min_score_observations": int(min_score_observations),
        "score_failure_action": score_failure_action,
        "mean_min_local_points": int(mean_min_local_points),
        "covariance_min_local_pairs": int(covariance_min_local_pairs),
        "raw_sparse_trajectory_interpolation_performed": False,
        "nearest_neighbour_synchronization_performed": False,
        "time_binning_performed": False,
        "population_function_evaluation_at_native_times": True,
        "automatic_bandwidth_selection_performed": False,
        "cross_channel_covariance_modeled": True,
        "covariance_psd": covariance_diagnostics,
    }

    return SparseAsyncMFPCAResult(
        scores=scores.scores.copy(),
        eigenvalues=eigen.eigenvalues.copy(),
        eigenfunctions=np.transpose(eigen.eigenfunctions, (0, 2, 1)).copy(),
        mean=population.mean.T.copy(),
        evaluation_grid=population.evaluation_grid.copy(),
        dimensions=(str(dimensions[0]), str(dimensions[1])),
        curve_ids=trajectories.curve_ids,
        metadata=trajectories.metadata.reset_index(drop=True).copy(),
        coordinate_system=trajectories.coordinate_system,
        time_unit=trajectories.time_unit,
        n_components=int(n_components),
        quadrature_weights=eigen.quadrature_weights.copy(),
        smoothed_cxx=population.smoothed_covariance_blocks.cxx.copy(),
        smoothed_cxy=population.smoothed_covariance_blocks.cxy.copy(),
        smoothed_cyx=population.smoothed_covariance_blocks.cyx.copy(),
        smoothed_cyy=population.smoothed_covariance_blocks.cyy.copy(),
        covariance_cxx=population.covariance_blocks.cxx.copy(),
        covariance_cxy=population.covariance_blocks.cxy.copy(),
        covariance_cyx=population.covariance_blocks.cyx.copy(),
        covariance_cyy=population.covariance_blocks.cyy.copy(),
        measurement_error_covariance=resolved_error.covariance.copy(),
        score_diagnostics=scores.diagnostics.copy(),
        mean_support_counts=population.mean_support_counts.T.copy(),
        covariance_support_counts=covariance_support_counts,
        covariance_pair_counts=covariance_pair_counts,
        covariance_diagnostics=covariance_diagnostics,
        support_diagnostics=support_diagnostics,
        fit_method="direct_async_sparse_block_covariance",
        score_method="asynchronous_joint_PACE",
        provenance={
            **dict(trajectories.provenance),
            "sparse_mfpca_async": async_provenance,
        },
    )

eyetrajectoriespy.sparse_multivariate_async.sparse_mfpca_async_score_frame

sparse_mfpca_async_score_frame(
    result: SparseAsyncMFPCAResult,
) -> pd.DataFrame

Return asynchronous joint scores with curve IDs and metadata.

Source code in src/eyetrajectoriespy/sparse_multivariate_async.py
290
291
292
293
294
295
296
297
298
299
300
301
def sparse_mfpca_async_score_frame(
    result: SparseAsyncMFPCAResult,
) -> pd.DataFrame:
    """Return asynchronous joint scores with curve IDs and metadata."""

    if not isinstance(result, SparseAsyncMFPCAResult):
        raise TypeError("result must be a SparseAsyncMFPCAResult")
    frame = result.metadata.reset_index(drop=True).copy()
    frame.insert(0, "curve_id", list(result.curve_ids))
    for component in range(result.n_components):
        frame[f"ASM FPC{component + 1}".replace(" ", "")] = result.scores[:, component]
    return frame

eyetrajectoriespy.sparse_multivariate_async.sparse_mfpca_async_reporting_text

sparse_mfpca_async_reporting_text(
    result: SparseAsyncMFPCAResult, *, digits: int = 3
) -> str

Generate audit-oriented reporting text for asynchronous sparse MFPCA.

Source code in src/eyetrajectoriespy/sparse_multivariate_async.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
def sparse_mfpca_async_reporting_text(
    result: SparseAsyncMFPCAResult,
    *,
    digits: int = 3,
) -> str:
    """Generate audit-oriented reporting text for asynchronous sparse MFPCA."""

    if not isinstance(result, SparseAsyncMFPCAResult):
        raise TypeError("result must be a SparseAsyncMFPCAResult")
    if isinstance(digits, bool) or not isinstance(digits, int) or digits < 0:
        raise ValueError("digits must be a non-negative integer")

    provenance = result.provenance.get("sparse_mfpca_async", {})
    status = result.score_diagnostics["status_code"].astype(str).to_numpy()
    failed = int(np.count_nonzero(status != "ok"))
    solved = int(np.count_nonzero(status == "ok"))
    psd = result.covariance_diagnostics
    error_text = np.array2string(
        result.measurement_error_covariance,
        precision=digits,
        separator=", ",
    )

    return (
        f"Native asynchronous sparse planar MFPCA was fitted to dimensions "
        f"{result.dimensions[0]!r} and {result.dimensions[1]!r} for "
        f"{result.n_curves} curve(s), retaining {result.n_components} joint "
        f"component(s). Coordinate-specific native timestamps were retained "
        f"without interpolation, nearest-neighbour synchronization, or time "
        f"binning. Separate coordinate means and direct Cxx/Cxy/Cyy latent "
        f"covariance surfaces used declared local-linear bandwidths "
        f"{provenance.get('mean_bandwidth')} and "
        f"{provenance.get('covariance_bandwidth')}. Cxy used asynchronous "
        f"x-y residual products and excluded "
        f"{provenance.get('cross_covariance_same_time_pair_count_excluded')} "
        f"simultaneous cross-product(s) from latent covariance fitting. Cyx "
        f"was defined as Cxy transpose. The full joint operator used PSD action "
        f"{psd.get('requested_action')!r} (applied={psd.get('applied_action')!r}; "
        f"relative operator correction="
        f"{float(psd.get('relative_operator_correction_frobenius_norm', np.nan)):.{digits}f}). "
        f"Measurement error used mode {provenance.get('measurement_error_mode')!r} "
        f"with R_epsilon={error_text}; off-diagonal measurement-error covariance "
        f"was added only for simultaneous x/y observations. Asynchronous joint "
        f"PACE used the full fitted covariance plus measurement error and "
        f"ridge={provenance.get('score_ridge')}, never a rank-K covariance "
        f"reconstruction. {solved} curve(s) were scored successfully and "
        f"{failed} retained score-system failure(s) were recorded under "
        f"failure_action={provenance.get('score_failure_action')!r}."
    )

This path operates on exact coordinate-specific native observation times represented by a union timestamp container. It does not interpolate or nearest-neighbour synchronize raw x/y measurements.

Sparse partial/future trajectory prediction

eyetrajectoriespy.sparse_partial_prediction.SparseFPCAPartialPredictionResult dataclass

Conditional prediction of a future trajectory segment.

The latent covariance is conditional on the fitted population mean, covariance surface, measurement-error variance and declared history-system regularization. observed_predictive_covariance additionally includes the fitted measurement-error variance at each future target point. The numerical history ridge is never relabelled as future measurement noise.

Source code in src/eyetrajectoriespy/sparse_partial_prediction.py
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
@dataclass(frozen=True)
class SparseFPCAPartialPredictionResult:
    """Conditional prediction of a future trajectory segment.

    The latent covariance is conditional on the fitted population mean,
    covariance surface, measurement-error variance and declared history-system
    regularization.  ``observed_predictive_covariance`` additionally includes
    the fitted measurement-error variance at each future target point.  The
    numerical history ridge is never relabelled as future measurement noise.
    """

    reference: SparseFPCAResult
    curve_id: str
    dimension: str
    history_cutoff: float
    history_times: np.ndarray
    history_values: np.ndarray
    prediction_grid: np.ndarray
    conditional_mean: np.ndarray
    latent_covariance: np.ndarray
    latent_standard_errors: np.ndarray
    observed_predictive_covariance: np.ndarray
    observed_predictive_standard_errors: np.ndarray
    diagnostics: pd.DataFrame
    method: str = _METHOD
    provenance: Mapping[str, Any] = field(default_factory=dict)

    @property
    def n_history(self) -> int:
        return int(self.history_times.size)

    @property
    def n_prediction(self) -> int:
        return int(self.prediction_grid.size)

eyetrajectoriespy.sparse_partial_prediction.sparse_fpca_partial_trajectory_prediction

sparse_fpca_partial_trajectory_prediction(
    fit: SparseFPCAResult,
    trajectory: IrregularTrajectorySet,
    *,
    history_cutoff: float,
    prediction_grid: ndarray,
    min_history_observations: int = 2,
    condition_limit: float | None = None,
    history_ridge: float | None = None,
) -> SparseFPCAPartialPredictionResult

Predict a latent future trajectory from native sparse history.

The conditional predictor uses the complete fitted covariance surface:

mu_f + C_fo Sigma_oo^{-1} (Y_o - mu_o)

with latent conditional covariance

C_ff - C_fo Sigma_oo^{-1} C_of.

Sigma_oo contains the full fitted history covariance plus fitted measurement-error variance and an optional numerical history ridge. The raw target history is never interpolated and observations after history_cutoff are ignored completely.

Source code in src/eyetrajectoriespy/sparse_partial_prediction.py
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
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 sparse_fpca_partial_trajectory_prediction(
    fit: SparseFPCAResult,
    trajectory: IrregularTrajectorySet,
    *,
    history_cutoff: float,
    prediction_grid: np.ndarray,
    min_history_observations: int = 2,
    condition_limit: float | None = None,
    history_ridge: float | None = None,
) -> SparseFPCAPartialPredictionResult:
    """Predict a latent future trajectory from native sparse history.

    The conditional predictor uses the complete fitted covariance surface:

    ``mu_f + C_fo Sigma_oo^{-1} (Y_o - mu_o)``

    with latent conditional covariance

    ``C_ff - C_fo Sigma_oo^{-1} C_of``.

    ``Sigma_oo`` contains the full fitted history covariance plus fitted
    measurement-error variance and an optional numerical history ridge.  The
    raw target history is never interpolated and observations after
    ``history_cutoff`` are ignored completely.
    """

    sparse = _native_sparse_provenance(fit)
    grid, fitted_mean, fitted_covariance, noise_variance = _population_arrays(fit)
    time, values, curve_id = _validate_target(fit, trajectory)

    cutoff = float(history_cutoff)
    if not np.isfinite(cutoff):
        raise ValueError("history_cutoff must be finite")
    if cutoff < grid[0] or cutoff >= grid[-1]:
        raise SparseNativeError(
            "history_cutoff_outside_fitted_support",
            "history_cutoff must lie inside fitted support and before its end",
            details={
                "history_cutoff": cutoff,
                "grid_start": float(grid[0]),
                "grid_end": float(grid[-1]),
            },
        )

    target = np.asarray(prediction_grid, dtype=float)
    if (
        target.ndim != 1
        or target.size < 1
        or not np.all(np.isfinite(target))
        or (target.size > 1 and not np.all(np.diff(target) > 0))
    ):
        raise ValueError(
            "prediction_grid must be a finite strictly increasing one-dimensional array"
        )
    if np.any(target <= cutoff):
        raise SparseNativeError(
            "prediction_grid_not_strictly_future",
            "all prediction_grid values must be strictly after history_cutoff",
            details={
                "history_cutoff": cutoff,
                "prediction_grid_min": float(np.min(target)),
            },
        )
    if target[0] < grid[0] or target[-1] > grid[-1]:
        raise SparseNativeError(
            "prediction_grid_outside_fitted_support",
            "prediction_grid must lie within fitted evaluation-grid support",
            details={
                "grid_start": float(grid[0]),
                "grid_end": float(grid[-1]),
                "prediction_start": float(target[0]),
                "prediction_end": float(target[-1]),
            },
        )

    if isinstance(min_history_observations, bool) or not isinstance(
        min_history_observations, int
    ):
        raise TypeError("min_history_observations must be an integer")
    if min_history_observations < 1:
        raise ValueError("min_history_observations must be positive")

    history_mask = time <= cutoff
    history_times = time[history_mask].copy()
    history_values = values[history_mask].copy()
    if history_times.size < min_history_observations:
        raise SparseNativeError(
            "insufficient_history_observations",
            "target history has fewer observations than required",
            details={
                "curve_id": curve_id,
                "history_cutoff": cutoff,
                "n_history": int(history_times.size),
                "min_history_observations": int(min_history_observations),
            },
        )
    if not np.all(np.isfinite(history_values)):
        raise SparseNativeError(
            "nonfinite_history_observation",
            "history observations for the fitted dimension must be finite",
            details={
                "curve_id": curve_id,
                "n_nonfinite": int(np.count_nonzero(~np.isfinite(history_values))),
            },
        )
    if history_times[0] < grid[0] or history_times[-1] > grid[-1]:
        raise SparseNativeError(
            "history_observations_outside_fitted_support",
            "history observations lie outside fitted evaluation-grid support",
            details={
                "curve_id": curve_id,
                "history_min": float(history_times[0]),
                "history_max": float(history_times[-1]),
                "grid_start": float(grid[0]),
                "grid_end": float(grid[-1]),
            },
        )

    fitted_condition_limit = float(sparse.get("score_condition_limit", 1e12))
    selected_condition_limit = (
        fitted_condition_limit if condition_limit is None else float(condition_limit)
    )
    if not np.isfinite(selected_condition_limit) or selected_condition_limit <= 1:
        raise ValueError("condition_limit must be finite and greater than 1")

    fitted_ridge = float(sparse.get("score_ridge", 0.0))
    selected_ridge = fitted_ridge if history_ridge is None else float(history_ridge)
    if not np.isfinite(selected_ridge) or selected_ridge < 0:
        raise ValueError("history_ridge must be finite and non-negative")

    combined = np.concatenate([history_times, target])
    combined_covariance = evaluate_fitted_covariance(
        grid,
        fitted_covariance,
        combined,
    )
    n_history = history_times.size
    c_oo = combined_covariance[:n_history, :n_history]
    c_of = combined_covariance[:n_history, n_history:]
    c_fo = combined_covariance[n_history:, :n_history]
    c_ff = combined_covariance[n_history:, n_history:]

    mu_o = evaluate_fitted_function(grid, fitted_mean, history_times)
    mu_f = evaluate_fitted_function(grid, fitted_mean, target)
    sigma_oo = c_oo + (noise_variance + selected_ridge) * np.eye(n_history)
    sigma_oo = 0.5 * (sigma_oo + sigma_oo.T)

    sigma_eigenvalues = np.linalg.eigvalsh(sigma_oo)
    minimum_eigenvalue = float(np.min(sigma_eigenvalues))
    maximum_eigenvalue = float(np.max(sigma_eigenvalues))
    condition_number = (
        np.inf
        if minimum_eigenvalue <= 0
        else maximum_eigenvalue / minimum_eigenvalue
    )
    if minimum_eigenvalue <= 0:
        raise SparseNativeError(
            "history_covariance_not_positive_definite",
            "history conditioning covariance is not positive definite",
            details={
                "curve_id": curve_id,
                "minimum_eigenvalue": minimum_eigenvalue,
                "history_ridge": selected_ridge,
            },
        )
    if condition_number > selected_condition_limit:
        raise SparseNativeError(
            "history_covariance_ill_conditioned",
            "history conditioning covariance exceeds condition_limit",
            details={
                "curve_id": curve_id,
                "condition_number": condition_number,
                "condition_limit": selected_condition_limit,
            },
        )

    centered_history = history_values - mu_o
    rhs = np.column_stack([centered_history, c_of])
    solved = np.linalg.solve(sigma_oo, rhs)
    conditional_mean = mu_f + c_fo @ solved[:, 0]
    latent_covariance = c_ff - c_fo @ solved[:, 1:]
    latent_covariance = 0.5 * (latent_covariance + latent_covariance.T)

    covariance_scale = max(1.0, float(np.max(np.abs(c_ff))))
    posterior_psd_tolerance = 1e-10 * covariance_scale
    posterior_eigenvalues = np.linalg.eigvalsh(latent_covariance)
    posterior_minimum_eigenvalue = float(np.min(posterior_eigenvalues))
    posterior_psd_repair_applied = False
    if posterior_minimum_eigenvalue < -posterior_psd_tolerance:
        raise SparseNativeError(
            "conditional_future_covariance_not_psd",
            "conditional latent future covariance is not PSD within tolerance",
            details={
                "curve_id": curve_id,
                "minimum_eigenvalue": posterior_minimum_eigenvalue,
                "tolerance": posterior_psd_tolerance,
            },
        )
    if posterior_minimum_eigenvalue < 0:
        values_psd, vectors_psd = np.linalg.eigh(latent_covariance)
        values_psd = np.maximum(values_psd, 0.0)
        latent_covariance = (vectors_psd * values_psd) @ vectors_psd.T
        latent_covariance = 0.5 * (latent_covariance + latent_covariance.T)
        posterior_psd_repair_applied = True

    latent_standard_errors = np.sqrt(
        np.maximum(np.diag(latent_covariance), 0.0)
    )
    observed_predictive_covariance = latent_covariance + noise_variance * np.eye(
        target.size
    )
    observed_predictive_standard_errors = np.sqrt(
        np.maximum(np.diag(observed_predictive_covariance), 0.0)
    )

    residual_scale = max(1.0, float(np.linalg.norm(rhs)))
    solve_relative_residual = float(
        np.linalg.norm(sigma_oo @ solved - rhs) / residual_scale
    )
    future_rows_ignored = int(np.count_nonzero(time > cutoff))
    diagnostics = pd.DataFrame(
        [
            {
                "curve_id": curve_id,
                "history_cutoff": cutoff,
                "n_history": int(history_times.size),
                "n_prediction": int(target.size),
                "future_rows_ignored": future_rows_ignored,
                "condition_number": float(condition_number),
                "minimum_eigenvalue": minimum_eigenvalue,
                "maximum_eigenvalue": maximum_eigenvalue,
                "conditional_covariance_minimum_eigenvalue": (
                    posterior_minimum_eigenvalue
                ),
                "conditional_covariance_psd_repair_applied": bool(
                    posterior_psd_repair_applied
                ),
                "solve_relative_residual": solve_relative_residual,
                "measurement_error_variance": noise_variance,
                "history_ridge": selected_ridge,
                "condition_limit": selected_condition_limit,
                "status_code": "ok",
            }
        ]
    )

    provenance: dict[str, Any] = {
        "method": _METHOD,
        "prediction_target": "latent_future_trajectory_conditional_on_fitted_population",
        "observed_predictive_covariance_includes_measurement_error": True,
        "history_ridge_in_observed_future_noise": False,
        "covariance_source": "full_fitted_repaired_covariance",
        "rank_k_covariance_used_for_prediction": False,
        "raw_history_interpolation_performed": False,
        "future_observations_after_cutoff_used": False,
        "population_function_evaluation_at_native_and_target_times": True,
        "measurement_error_variance": noise_variance,
        "history_ridge": selected_ridge,
        "history_ridge_source": (
            "fitted_score_ridge" if history_ridge is None else "explicit_override"
        ),
        "condition_limit": selected_condition_limit,
        "condition_limit_source": (
            "fitted_score_condition_limit"
            if condition_limit is None
            else "explicit_override"
        ),
        "population_estimation_uncertainty_included": False,
        "bandwidth_uncertainty_included": False,
        "conformal_calibration_applied": False,
        "coverage_claim": "none_model_based_conditional_moments_only",
    }
    return SparseFPCAPartialPredictionResult(
        reference=fit,
        curve_id=curve_id,
        dimension=fit.dimension,
        history_cutoff=cutoff,
        history_times=history_times,
        history_values=history_values,
        prediction_grid=target.copy(),
        conditional_mean=np.asarray(conditional_mean, dtype=float),
        latent_covariance=np.asarray(latent_covariance, dtype=float),
        latent_standard_errors=np.asarray(latent_standard_errors, dtype=float),
        observed_predictive_covariance=np.asarray(
            observed_predictive_covariance,
            dtype=float,
        ),
        observed_predictive_standard_errors=np.asarray(
            observed_predictive_standard_errors,
            dtype=float,
        ),
        diagnostics=diagnostics,
        provenance=provenance,
    )

eyetrajectoriespy.sparse_partial_prediction.sparse_fpca_partial_prediction_frame

sparse_fpca_partial_prediction_frame(
    result: SparseFPCAPartialPredictionResult,
) -> pd.DataFrame

Return one row per future prediction-grid point.

Source code in src/eyetrajectoriespy/sparse_partial_prediction.py
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
def sparse_fpca_partial_prediction_frame(
    result: SparseFPCAPartialPredictionResult,
) -> pd.DataFrame:
    """Return one row per future prediction-grid point."""

    if not isinstance(result, SparseFPCAPartialPredictionResult):
        raise TypeError("result must be a SparseFPCAPartialPredictionResult")
    return pd.DataFrame(
        {
            "curve_id": result.curve_id,
            "dimension": result.dimension,
            "time": result.prediction_grid,
            "conditional_mean": result.conditional_mean,
            "latent_standard_error": result.latent_standard_errors,
            "observed_predictive_standard_error": (
                result.observed_predictive_standard_errors
            ),
        }
    )

eyetrajectoriespy.sparse_partial_prediction.sparse_fpca_partial_prediction_reporting_text

sparse_fpca_partial_prediction_reporting_text(
    result: SparseFPCAPartialPredictionResult,
    *,
    digits: int = 3,
) -> str

Generate audit-oriented wording for a sparse partial prediction.

Source code in src/eyetrajectoriespy/sparse_partial_prediction.py
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
def sparse_fpca_partial_prediction_reporting_text(
    result: SparseFPCAPartialPredictionResult,
    *,
    digits: int = 3,
) -> str:
    """Generate audit-oriented wording for a sparse partial prediction."""

    if not isinstance(result, SparseFPCAPartialPredictionResult):
        raise TypeError("result must be a SparseFPCAPartialPredictionResult")
    if isinstance(digits, bool) or not isinstance(digits, int) or digits < 0:
        raise ValueError("digits must be a non-negative integer")

    row = result.diagnostics.iloc[0]
    return (
        f"A native sparse partial-trajectory prediction was computed for curve "
        f"{result.curve_id!r} and dimension {result.dimension!r} from "
        f"{result.n_history} observed history value(s) through cutoff "
        f"{result.history_cutoff:.{digits}f}, predicting {result.n_prediction} "
        f"future grid point(s). The conditional mean and latent covariance used "
        f"the full fitted/repaired sparse-FPCA covariance, not a rank-K "
        f"reconstruction, with fitted measurement-error variance "
        f"{float(row['measurement_error_variance']):.{digits}f} and history "
        f"ridge {float(row['history_ridge']):.{digits}f}. Raw history was not "
        f"interpolated and observations after the cutoff were ignored. The "
        f"reported latent uncertainty is conditional on fitted population "
        f"objects; the observed predictive standard errors additionally include "
        f"measurement noise. No conformal calibration or frequentist coverage "
        f"claim is attached to this result."
    )

The conditional moments use the full fitted sparse-FPCA covariance and are conditional on the fitted population objects.

Participant-aware split-conformal future-trajectory bands

eyetrajectoriespy.sparse_partial_conformal.SparseFPCAPartialConformalCalibrationResult dataclass

Split-conformal calibration for a fixed future grid and cutoff.

Source code in src/eyetrajectoriespy/sparse_partial_conformal.py
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
@dataclass(frozen=True)
class SparseFPCAPartialConformalCalibrationResult:
    """Split-conformal calibration for a fixed future grid and cutoff."""

    reference: SparseFPCAResult
    calibration_curve_ids: tuple[str, ...]
    calibration_unit_ids: tuple[str, ...]
    curve_scores: pd.DataFrame
    unit_scores: np.ndarray
    alpha: float
    critical_value: float
    history_cutoff: float
    prediction_grid: np.ndarray
    group_column: str | None
    min_history_observations: int
    condition_limit: float | None
    history_ridge: float | None
    method: str = _CALIBRATION_METHOD
    provenance: Mapping[str, Any] = field(default_factory=dict)

    @property
    def n_calibration_curves(self) -> int:
        return len(self.calibration_curve_ids)

    @property
    def n_calibration_units(self) -> int:
        return len(self.calibration_unit_ids)

eyetrajectoriespy.sparse_partial_conformal.SparseFPCAPartialConformalBandResult dataclass

Simultaneous finite-grid band for a future observed trajectory.

Source code in src/eyetrajectoriespy/sparse_partial_conformal.py
57
58
59
60
61
62
63
64
65
66
67
68
69
@dataclass(frozen=True)
class SparseFPCAPartialConformalBandResult:
    """Simultaneous finite-grid band for a future observed trajectory."""

    calibration: SparseFPCAPartialConformalCalibrationResult
    prediction: SparseFPCAPartialPredictionResult
    lower: np.ndarray
    upper: np.ndarray
    critical_value: float
    alpha: float
    target_calibration_unit: str
    method: str = _BAND_METHOD
    provenance: Mapping[str, Any] = field(default_factory=dict)

eyetrajectoriespy.sparse_partial_conformal.calibrate_sparse_fpca_partial_prediction_conformal

calibrate_sparse_fpca_partial_prediction_conformal(
    fit: SparseFPCAResult,
    calibration: IrregularTrajectorySet,
    *,
    history_cutoff: float,
    prediction_grid: ndarray,
    alpha: float = 0.05,
    group_column: str | None = None,
    min_history_observations: int = 2,
    condition_limit: float | None = None,
    history_ridge: float | None = None,
) -> SparseFPCAPartialConformalCalibrationResult

Calibrate a simultaneous finite-grid future-observation band.

Curve-level nonconformity is the maximum standardized absolute residual over the declared future target grid. With group_column supplied, curve scores are aggregated by maximum within participant/group before the conformal order statistic is computed, making the declared exchangeability unit the participant/group rather than repeated trial.

Source code in src/eyetrajectoriespy/sparse_partial_conformal.py
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
def calibrate_sparse_fpca_partial_prediction_conformal(
    fit: SparseFPCAResult,
    calibration: IrregularTrajectorySet,
    *,
    history_cutoff: float,
    prediction_grid: np.ndarray,
    alpha: float = 0.05,
    group_column: str | None = None,
    min_history_observations: int = 2,
    condition_limit: float | None = None,
    history_ridge: float | None = None,
) -> SparseFPCAPartialConformalCalibrationResult:
    """Calibrate a simultaneous finite-grid future-observation band.

    Curve-level nonconformity is the maximum standardized absolute residual over
    the declared future target grid. With ``group_column`` supplied, curve
    scores are aggregated by maximum within participant/group before the
    conformal order statistic is computed, making the declared exchangeability
    unit the participant/group rather than repeated trial.
    """

    target_grid = np.asarray(prediction_grid, dtype=float)
    unit_values, training_units = _validate_calibration_partition(
        fit,
        calibration,
        group_column=group_column,
    )
    rows: list[dict[str, Any]] = []
    for index, curve_id in enumerate(calibration.curve_ids):
        curve = _single_curve(calibration, index)
        prediction = sparse_fpca_partial_trajectory_prediction(
            fit,
            curve,
            history_cutoff=history_cutoff,
            prediction_grid=target_grid,
            min_history_observations=min_history_observations,
            condition_limit=condition_limit,
            history_ridge=history_ridge,
        )
        observed_future = _exact_future_values(
            curve,
            dimension=fit.dimension,
            prediction_grid=target_grid,
        )
        scale = np.asarray(
            prediction.observed_predictive_standard_errors,
            dtype=float,
        )
        if np.any(~np.isfinite(scale)) or np.any(scale <= 0):
            raise SparseNativeError(
                "invalid_conformal_predictive_scale",
                "observed predictive standard errors must be finite and positive",
                details={"curve_id": str(curve_id)},
            )
        standardized = np.abs(observed_future - prediction.conditional_mean) / scale
        score = float(np.max(standardized))
        if not np.isfinite(score):
            raise SparseNativeError(
                "nonfinite_conformal_score",
                "calibration nonconformity score is not finite",
                details={"curve_id": str(curve_id)},
            )
        rows.append(
            {
                "curve_id": str(curve_id),
                "calibration_unit": str(unit_values[index]),
                "nonconformity_score": score,
                "n_history": int(prediction.n_history),
                "n_future_grid": int(target_grid.size),
            }
        )

    curve_scores = pd.DataFrame(rows)
    unit_frame = (
        curve_scores.groupby("calibration_unit", sort=True, as_index=False)[
            "nonconformity_score"
        ]
        .max()
        .sort_values("calibration_unit")
        .reset_index(drop=True)
    )
    unit_ids = tuple(unit_frame["calibration_unit"].astype(str).tolist())
    unit_scores = unit_frame["nonconformity_score"].to_numpy(dtype=float)
    critical_value, rank = _split_conformal_critical_value(unit_scores, alpha=alpha)

    provenance: dict[str, Any] = {
        "method": _CALIBRATION_METHOD,
        "prediction_target": "future_observed_measurements_on_declared_finite_grid",
        "nonconformity": "maximum_standardized_absolute_future_residual",
        "standardization": "model_based_observed_predictive_standard_error",
        "calibration_unit": "curve" if group_column is None else "group",
        "group_column": group_column,
        "group_aggregation": "maximum_curve_nonconformity_within_group",
        "proper_training_curve_ids_disjoint": True,
        "proper_training_units": list(training_units),
        "raw_future_response_interpolation_performed": False,
        "future_grid_observation_match": "exact_timestamp_equality",
        "finite_sample_order_statistic_rank": int(rank),
        "finite_sample_quantile_rule": "ceil((n_calibration_units+1)*(1-alpha))",
        "simultaneous_scope": "declared_finite_future_grid",
        "continuous_domain_coverage_claimed": False,
        "latent_function_coverage_claimed": False,
        "exchangeability_required": True,
        "population_estimation_uncertainty_separately_propagated": False,
    }
    return SparseFPCAPartialConformalCalibrationResult(
        reference=fit,
        calibration_curve_ids=tuple(map(str, calibration.curve_ids)),
        calibration_unit_ids=unit_ids,
        curve_scores=curve_scores,
        unit_scores=unit_scores,
        alpha=float(alpha),
        critical_value=critical_value,
        history_cutoff=float(history_cutoff),
        prediction_grid=target_grid.copy(),
        group_column=group_column,
        min_history_observations=int(min_history_observations),
        condition_limit=condition_limit,
        history_ridge=history_ridge,
        provenance=provenance,
    )

eyetrajectoriespy.sparse_partial_conformal.sparse_fpca_conformal_prediction_band

sparse_fpca_conformal_prediction_band(
    calibration: SparseFPCAPartialConformalCalibrationResult,
    trajectory: IrregularTrajectorySet,
) -> SparseFPCAPartialConformalBandResult

Apply a fitted conformal calibration to one new target trajectory.

Source code in src/eyetrajectoriespy/sparse_partial_conformal.py
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
def sparse_fpca_conformal_prediction_band(
    calibration: SparseFPCAPartialConformalCalibrationResult,
    trajectory: IrregularTrajectorySet,
) -> SparseFPCAPartialConformalBandResult:
    """Apply a fitted conformal calibration to one new target trajectory."""

    if not isinstance(calibration, SparseFPCAPartialConformalCalibrationResult):
        raise TypeError(
            "calibration must be a SparseFPCAPartialConformalCalibrationResult"
        )
    if not isinstance(trajectory, IrregularTrajectorySet):
        raise TypeError("trajectory must be an IrregularTrajectorySet")
    if trajectory.n_curves != 1:
        raise ValueError("trajectory must contain exactly one target curve")

    target_curve_id = str(trajectory.curve_ids[0])
    training_ids = set(map(str, calibration.reference.curve_ids))
    calibration_ids = set(calibration.calibration_curve_ids)
    if target_curve_id in training_ids or target_curve_id in calibration_ids:
        raise ValueError(
            "target curve ID must be disjoint from proper training and calibration"
        )

    if calibration.group_column is None:
        target_unit = target_curve_id
    else:
        group_column = calibration.group_column
        if group_column not in trajectory.metadata.columns:
            raise ValueError(
                f"group_column {group_column!r} is absent from target metadata"
            )
        target_unit = str(trajectory.metadata.iloc[0][group_column])
        if target_unit in set(calibration.calibration_unit_ids):
            raise ValueError(
                "target participant/group must be disjoint from calibration units"
            )
        fit_metadata = calibration.reference.metadata
        if group_column not in fit_metadata.columns:
            raise ValueError(
                f"group_column {group_column!r} is absent from fitted metadata"
            )
        training_units = set(fit_metadata[group_column].astype(str).tolist())
        if target_unit in training_units:
            raise ValueError(
                "target participant/group must be disjoint from proper-training units"
            )

    prediction = sparse_fpca_partial_trajectory_prediction(
        calibration.reference,
        trajectory,
        history_cutoff=calibration.history_cutoff,
        prediction_grid=calibration.prediction_grid,
        min_history_observations=calibration.min_history_observations,
        condition_limit=calibration.condition_limit,
        history_ridge=calibration.history_ridge,
    )
    scale = prediction.observed_predictive_standard_errors
    lower = prediction.conditional_mean - calibration.critical_value * scale
    upper = prediction.conditional_mean + calibration.critical_value * scale
    provenance = {
        "method": _BAND_METHOD,
        "prediction_target": "future_observed_measurements_on_declared_finite_grid",
        "simultaneous_scope": "declared_finite_future_grid",
        "continuous_domain_coverage_claimed": False,
        "latent_function_coverage_claimed": False,
        "exchangeability_unit": (
            "curve" if calibration.group_column is None else "participant_or_group"
        ),
        "target_unit": target_unit,
        "calibration_critical_value": float(calibration.critical_value),
        "alpha": float(calibration.alpha),
        "raw_future_response_interpolation_performed": False,
    }
    return SparseFPCAPartialConformalBandResult(
        calibration=calibration,
        prediction=prediction,
        lower=np.asarray(lower, dtype=float),
        upper=np.asarray(upper, dtype=float),
        critical_value=float(calibration.critical_value),
        alpha=float(calibration.alpha),
        target_calibration_unit=target_unit,
        provenance=provenance,
    )

eyetrajectoriespy.sparse_partial_conformal.sparse_fpca_conformal_band_frame

sparse_fpca_conformal_band_frame(
    result: SparseFPCAPartialConformalBandResult,
) -> pd.DataFrame

Return the finite-grid conformal prediction band as a tidy frame.

Source code in src/eyetrajectoriespy/sparse_partial_conformal.py
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
def sparse_fpca_conformal_band_frame(
    result: SparseFPCAPartialConformalBandResult,
) -> pd.DataFrame:
    """Return the finite-grid conformal prediction band as a tidy frame."""

    if not isinstance(result, SparseFPCAPartialConformalBandResult):
        raise TypeError("result must be a SparseFPCAPartialConformalBandResult")
    prediction = result.prediction
    return pd.DataFrame(
        {
            "curve_id": prediction.curve_id,
            "dimension": prediction.dimension,
            "time": prediction.prediction_grid,
            "conditional_mean": prediction.conditional_mean,
            "observed_predictive_standard_error": (
                prediction.observed_predictive_standard_errors
            ),
            "lower": result.lower,
            "upper": result.upper,
            "alpha": result.alpha,
            "critical_value": result.critical_value,
        }
    )

eyetrajectoriespy.sparse_partial_conformal.sparse_fpca_conformal_band_reporting_text

sparse_fpca_conformal_band_reporting_text(
    result: SparseFPCAPartialConformalBandResult,
    *,
    digits: int = 3,
) -> str

Generate reporting text with an explicit finite-grid coverage scope.

Source code in src/eyetrajectoriespy/sparse_partial_conformal.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
def sparse_fpca_conformal_band_reporting_text(
    result: SparseFPCAPartialConformalBandResult,
    *,
    digits: int = 3,
) -> str:
    """Generate reporting text with an explicit finite-grid coverage scope."""

    if not isinstance(result, SparseFPCAPartialConformalBandResult):
        raise TypeError("result must be a SparseFPCAPartialConformalBandResult")
    if isinstance(digits, bool) or not isinstance(digits, int) or digits < 0:
        raise ValueError("digits must be a non-negative integer")
    calibration = result.calibration
    unit = "curve" if calibration.group_column is None else "participant/group"
    return (
        f"A split-conformal simultaneous prediction band was calibrated from "
        f"{calibration.n_calibration_units} exchangeability unit(s) ({unit}) at "
        f"alpha={calibration.alpha:.{digits}f}. Nonconformity was the maximum "
        f"standardized absolute future residual over the declared "
        f"{calibration.prediction_grid.size}-point target grid, with critical "
        f"value {calibration.critical_value:.{digits}f}. Calibration responses "
        f"were required to be actually observed at every target-grid timestamp; "
        f"no interpolation was used. The resulting coverage interpretation is "
        f"simultaneous for future observed measurements on that declared finite "
        f"grid under the exchangeability/grouping contract. No continuous-domain "
        f"or latent-function coverage claim is made."
    )

The conformal claim is simultaneous finite-grid coverage for future observed measurements under the declared exchangeability/grouping contract. It is not continuous-domain latent-function coverage.

Compatibility note

These supported 1.1 module paths are additive. They do not alter the signatures, result schemas, or scientific meanings frozen in ONE_DOT_ZERO_API_STABILITY.json. A later decision to expose selected names at package root would itself be an additive API decision and would require a separate explicit review; this page does not imply such promotion.