Repository navigation
Expand file tree
/
Copy pathssm_analysis.R
More file actions
1388 lines (1300 loc) · 55 KB
/
Copy pathssm_analysis.R
File metadata and controls
1388 lines (1300 loc) · 55 KB
1
2
3
4
5
6
7
8
9
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
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#' Perform analyses using the Structural Summary Method
#'
#' Calculate SSM parameters with confidence intervals (bootstrapped by
#' default, or Monte Carlo via `method`) for a variety of different analysis
#' types. Depending on what arguments are supplied, either mean-based or
#' correlation-based analyses will be performed, one or more groups will be
#' used to stratify the data, and contrasts between groups or measures will be
#' calculated.
#'
#' @param data Required. A data frame or matrix containing at least
#' circumplex scales.
#' @param scales Required unless `occasions` is supplied (the two are mutually
#' exclusive). A character vector of column names, or a numeric vector of
#' column indexes, from `data` that contains the circumplex scale scores to
#' be analyzed.
#' @param angles Optional. A numeric vector containing the angular displacement
#' of each circumplex scale included in `scales` (in degrees). (default =
#' `octants()`). The closed-form SSM estimator used here equals the
#' ordinary-least-squares cosine fit for equally spaced `angles` (e.g.,
#' octants at 45-degree intervals) -- more generally, for any angle set
#' satisfying first- and second-harmonic balance. For angle sets violating
#' that balance (generic unequally spaced sets), it is the conventional
#' Gurtman estimator, not a least-squares fit, and the reported model fit
#' is then no longer a bounded R-squared in `[0, 1]` (it can fall below 0).
#' @param measures Optional. Either `NULL` or a character vector of column names
#' from `data` that contains one or more variables to be correlated with the
#' circumplex scales and analyzed using correlation-based SSM analyses.
#' @param grouping Optional. Either `NULL` or a string that contains the column
#' name from `data` of the variable that indicates the group membership of
#' each observation.
#' @param contrast Optional. A logical indicating whether to output the
#' difference between two measures', two groups', or two occasions' SSM
#' parameters. Can only be set to TRUE when exactly one of these holds: two
#' measures and one group; one measure and two groups; no measures and two
#' groups; or two occasions and one group (default = FALSE). The contrast is
#' always the second level minus the first. For two groups, this is the
#' second level of `grouping` alphabetically, unless `grouping` is already a
#' factor with an explicit level order, in which case that order is used.
#' For two measures, this is simply the second entry of `measures` as given
#' (no reordering). For two occasions, it is the second listed element of
#' `occasions` minus the first (list order as supplied -- temporal order --
#' never alphabetical). The direction is shown in the result's Label (e.g.,
#' "Male - Female").
#' @param boots Optional. A single positive whole number indicating how many
#' bootstrap resamples (or, when `method = "montecarlo"`, Monte Carlo draws)
#' to use when estimating the confidence intervals (default = 2000).
#' @param interval Optional. A single positive number between 0 and 1
#' (exclusive) that indicates what confidence level to use when estimating the
#' confidence intervals (default = 0.95).
#' @param listwise Optional. A logical indicating whether missing values should
#' be handled by listwise deletion (TRUE) or pairwise deletion (FALSE). Note
#' that pairwise deletion may result in different missing data patterns in
#' each bootstrap resample and is slower to compute (default = TRUE).
#' Occasions analyses require `listwise = TRUE`: a person missing any
#' occasion is dropped from all occasions (complete cases across waves), so
#' the paired contrast stays a within-person comparison. Note the selection
#' caution: complete-cases-across-waves estimates completers' change, which
#' can differ from population change when dropout relates to the outcome.
#' @param measures_labels Optional. Either `NULL` or a character vector
#' providing a label for each measure provided in `measures` (in the same
#' order) to appear in the results as well as tables and plots derived from
#' the results.
#' @param parallel Optional. A string indicating whether to distribute the
#' bootstrap computation across multiple CPU cores: "no" (default),
#' "multicore" (process forking; available on macOS and Linux, ignored on
#' Windows), or "snow" (a local PSOCK cluster; available on all platforms).
#' Passed to \code{\link[boot]{boot}}. Because the bootstrap resample
#' indices are drawn in the main R process before any work is distributed,
#' results for a given `set.seed()` are identical regardless of the
#' `parallel` and `ncpus` settings.
#' @param ncpus Optional. A single positive whole number indicating how many
#' CPU cores to use when `parallel` is not "no" (default = 1).
#' @param occasions Optional. Either `NULL` or a named list of character or
#' numeric vectors, each selecting the same circumplex scales measured at
#' one occasion, in the same scale order, all of length `length(angles)`
#' (e.g., `occasions = list(T1 = c("PA_1", ..., "NO_1"), T2 = c("PA_2", ...,
#' "NO_2"))`). Mutually exclusive with `scales` (and not combinable with
#' `measures`). Data must be wide -- one row per person -- so persons remain
#' the resampling unit and within-person dependence across occasions is
#' preserved in both engines. Results gain an `Occasion` column (labels are
#' `names(occasions)`, defaulting to `T1..Tk`); this column is present only
#' for occasions analyses. Grouping is time-invariant by construction (one
#' group per person-row). Cross-occasion column alignment is validated by
#' stem matching; when the columns have no common stem structure, positional
#' alignment is assumed and messaged.
#' @param method Optional. A string indicating how to estimate the confidence
#' intervals: "bootstrap" (default) resamples the data, whereas "montecarlo"
#' draws parameter replicates from the asymptotic sampling distribution of
#' the group mean vector (mean-based analyses) or the measure-scale
#' correlation vector (correlation-based analyses) -- a multivariate normal
#' with empirically estimated covariance -- and propagates them through the
#' SSM parameter transformation. The Monte Carlo method is much faster for
#' large samples but relies on the asymptotic normality of the means or
#' correlations, so prefer the bootstrap for small samples; it also requires
#' listwise-complete data. Correlations are drawn jointly across measures
#' within each group on the Fisher z scale and back-transformed. The
#' `parallel` and `ncpus` arguments apply only to the bootstrap.
#' @return A list containing the results and description of the analysis.
#' \item{results}{A data frame with the SSM parameter estimates}
#' \item{details}{A list with the number of bootstrap resamples or Monte
#' Carlo draws (boots), the confidence interval percentage level (interval),
#' the angular displacement of scales (angles), and the interval estimation
#' method (method)}
#' \item{call}{A language object containing the function call that created
#' this object}
#' \item{scores}{A data frame containing the mean scale scores} \item{type}{A
#' string indicating what type of SSM analysis was done}
#'
#' The profile displacement parameter is reported in the half-open interval
#' `[0, 360)` degrees. A profile that peaks exactly at the 0/360 degree
#' boundary is reported as approximately 360 (equivalently 0, the same
#' direction); which of the two appears is a floating-point detail and both
#' denote the same pole. A displacement *confidence-interval endpoint* that
#' lands exactly on that pole is always reported as 360 (never 0), matching
#' the package's LM = 360 labeling. Contrast displacements are instead
#' reported as a signed difference in `(-180, 180]` degrees (see the
#' "Contrast" block in the printed output).
#'
#' Degenerate profiles (flat or zero-amplitude) have undefined displacement
#' (and fit, if flat), which is reported as `NA` with a warning. Bootstrap
#' resamples that produce degenerate profiles (e.g., a resampled measure
#' with zero variance) are excluded from the confidence intervals with a
#' warning reporting how many were dropped; the intervals are then
#' conditional on estimability.
#' @section Reproducibility:
#' This function consumes R's random number stream (so do
#' `cpm_fit(ci_method = "bootstrap")`, `cpm_simulate()`, and
#' `ssm_ci_accuracy()`; `ssm_score()`/`ssm_parameters()` and the tidying
#' functions are deterministic). Call `set.seed()` immediately before
#' `ssm_analyze()` for reproducible confidence intervals:
#' \itemize{
#' \item \strong{Bootstrap} (`method = "bootstrap"`, the default): the
#' same seed gives byte-identical `results`, *regardless of* the
#' `parallel`/`ncpus` settings (see their descriptions below), because
#' `boot::boot()` draws all resample indices from the seed before any
#' work is parallelized.
#' \item \strong{Monte Carlo} (`method = "montecarlo"`): the same seed
#' gives byte-identical `results`. Adding a group or measure, or
#' reordering `scales`/`measures`, changes the random draw sequence, so
#' results are reproducible for a fixed call but will not match after
#' such structural edits even with the same seed.
#' \item The two methods are \strong{not} expected to agree numerically
#' for the same seed -- they consume the random stream in unrelated
#' ways. Their statistical agreement (validated on real data; see
#' `vignette("introduction-to-ssm-analysis")`) is a separate property
#' from RNG reproducibility.
#' \item Increasing `boots` changes the CI by design (more resamples/draws
#' should tighten Monte Carlo error), so results are not expected to be
#' stable across different `boots` values, only within a fixed call.
#' }
#' @section Occasions (repeated measures):
#' Supplying `occasions` analyzes the same circumplex scales measured at
#' k >= 2 occasions on the same persons (wide data, one row per person).
#' Each occasion yields its own profile row; with `contrast = TRUE`
#' (exactly 2 occasions, single group) the paired within-person contrast
#' is estimated with both engines preserving the within-person dependence
#' (the bootstrap resamples persons; the Monte Carlo engine draws the
#' stacked occasion mean vectors jointly).
#'
#' Interpretation notes. A paired displacement-contrast CI is
#' interpretable only when *both* occasions' amplitudes are reliably
#' nonzero (both profiles print without the amplitude note); if only one
#' occasion's profile is interpretable, do not read the contrast as
#' directional change. Paired designs are not unconditionally more
#' efficient than independent groups: the paired elevation contrast has a
#' narrower CI exactly when the within-person elevation correlation is
#' positive, while for the amplitude and displacement contrasts the paired
#' CI is narrower only when the gradient-projected cross-occasion
#' covariance is positive -- under isotropic dependence this is
#' proportional to cos(displacement change), so paired CIs are narrower
#' for displacement changes under 90 degrees and can be *wider* than
#' independent-groups CIs for changes beyond 90 degrees, even with
#' strongly positive within-person correlation.
#'
#' With `method = "montecarlo"` the per-group draw has dimension k x p
#' (occasions times scales); group sizes should comfortably exceed k x p
#' for the asymptotic covariance to be well estimated (the percentile
#' bootstrap is the safer small-sample choice). Grouping is time-invariant
#' by construction (one group per person-row).
#' @family ssm functions
#' @family analysis functions
#' @export
#' @examples
#' # `boots` is lowered from its default of 2000 throughout these examples so
#' # they run quickly; a reported analysis should use the default.
#'
#' # Load example data
#' data("jz2017")
#'
#' # Single-group mean-based SSM
#' ssm_analyze(
#' jz2017,
#' scales = c("PA", "BC", "DE", "FG", "HI", "JK", "LM", "NO"),
#' boots = 200
#' )
#'
#' # Single-group correlation-based SSM
#' ssm_analyze(
#' jz2017,
#' scales = c("PA", "BC", "DE", "FG", "HI", "JK", "LM", "NO"),
#' measures = c("NARPD", "ASPD"),
#' boots = 200
#' )
#'
#' # Monte Carlo confidence intervals (faster for large samples)
#' ssm_analyze(
#' jz2017,
#' scales = c("PA", "BC", "DE", "FG", "HI", "JK", "LM", "NO"),
#' method = "montecarlo",
#' boots = 200
#' )
#' \donttest{
#' # Multiple-group mean-based SSM
#' ssm_analyze(
#' jz2017,
#' scales = c("PA", "BC", "DE", "FG", "HI", "JK", "LM", "NO"),
#' grouping = "Gender",
#' boots = 200
#' )
#'
#' # Multiple-group mean-based SSM with contrast
#' ssm_analyze(
#' jz2017,
#' scales = c("PA", "BC", "DE", "FG", "HI", "JK", "LM", "NO"),
#' grouping = "Gender",
#' contrast = TRUE,
#' boots = 200
#' )
#'
#' # Single-group correlation-based SSM with contrast
#' ssm_analyze(
#' jz2017,
#' scales = c("PA", "BC", "DE", "FG", "HI", "JK", "LM", "NO"),
#' measures = c("NARPD", "ASPD"),
#' contrast = TRUE,
#' boots = 200
#' )
#'
#' # Multiple-group correlation-based SSM
#' ssm_analyze(
#' jz2017,
#' scales = c("PA", "BC", "DE", "FG", "HI", "JK", "LM", "NO"),
#' measures = "NARPD",
#' grouping = "Gender",
#' boots = 200
#' )
#'
#' # Multiple-group correlation-based SSM with contrast
#' ssm_analyze(
#' jz2017,
#' scales = c("PA", "BC", "DE", "FG", "HI", "JK", "LM", "NO"),
#' measures = "NARPD",
#' grouping = "Gender",
#' contrast = TRUE,
#' boots = 200
#' )
#' }
#'
ssm_analyze <- function(data, scales = NULL, angles = octants(),
measures = NULL, grouping = NULL, contrast = FALSE,
boots = 2000, interval = 0.95, listwise = TRUE,
measures_labels = NULL, parallel = "no", ncpus = 1,
method = "bootstrap", occasions = NULL) {
# Save function call
call <- match.call()
# Validate arguments
stopifnot(is.data.frame(data) || is.matrix(data))
stopifnot(is.numeric(angles))
if (is.null(occasions)) {
# `scales` and `occasions` are mutually exclusive spellings of "which
# columns hold the circumplex scores" (spec devel/longitudinal-ssm-spec.md
# sec. 1.1); exactly one must be supplied.
if (is.null(scales)) {
stop("Supply either `scales` or `occasions`.", call. = FALSE)
}
stopifnot(is_var(scales))
stopifnot(length(scales) == length(angles))
} else {
if (!is.null(scales)) {
stop(
"`scales` and `occasions` are mutually exclusive: `occasions` names ",
"the same circumplex scales measured at each occasion, so supply ",
"only one of the two.",
call. = FALSE
)
}
if (!is.null(measures)) {
stop(
"`occasions` cannot be combined with `measures`: the ",
"occasions-by-measures correlation analysis is not supported.",
call. = FALSE
)
}
if (!isTRUE(listwise)) {
stop(
"Occasions analyses require listwise = TRUE: with pairwise deletion ",
"the paired contrast would compare partially overlapping ",
"subpopulations (T1-completers vs T2-completers) instead of ",
"within-person change.",
call. = FALSE
)
}
if (!is.list(occasions)) {
stop(
"`occasions` must be a list of character or numeric vectors, one ",
"per occasion, each selecting the same circumplex scales in the ",
"same order.",
call. = FALSE
)
}
if (length(occasions) < 2) {
stop("`occasions` must select at least two occasions.", call. = FALSE)
}
if (!all(vapply(occasions, is_var, logical(1)))) {
stop(
"Each element of `occasions` must be a character or numeric vector ",
"of column names or indices.",
call. = FALSE
)
}
if (!all(lengths(occasions) == length(angles))) {
stop(
"Each `occasions` block must have the same length as `angles` ",
"(one column per circumplex scale, in the same scale order).",
call. = FALSE
)
}
occ_labels <- names(occasions)
if (is.null(occ_labels)) {
occ_labels <- paste0("T", seq_along(occasions))
} else if (any(!nzchar(occ_labels)) || anyDuplicated(occ_labels)) {
stop(
"`occasions` names must be either absent (labels default to T1..Tk) ",
"or complete and unique.",
call. = FALSE
)
}
}
stopifnot(is_null_or_var(measures))
stopifnot(is_null_or_var(grouping, n = 1))
stopifnot(is_flag(contrast))
stopifnot(is_scalar_count(boots))
stopifnot(is.numeric(interval) && interval > 0 && interval < 1)
stopifnot(is_flag(listwise))
stopifnot(is_null_or_char(measures_labels, n = length(measures)))
parallel <- match.arg(parallel, c("no", "multicore", "snow"))
stopifnot(is_scalar_count(ncpus))
method <- match.arg(method, c("bootstrap", "montecarlo"))
# Coerce matrix input to a data frame so column indexing behaves uniformly
if (is.matrix(data)) data <- as.data.frame(data)
# Drop observations with missing grouping values (unusable in any group).
# Done here, on the user's actual grouping column, so both analysis paths
# inherit clean data and never pass NA group codes into the C++ estimators.
if (!is.null(grouping)) {
na_group <- is.na(data[[grouping]])
if (any(na_group)) {
message(
sum(na_group),
" observation(s) removed due to missing values in the grouping variable."
)
data <- data[!na_group, , drop = FALSE]
if (nrow(data) == 0) {
stop("No observations remain after removing missing grouping values.")
}
}
}
if (contrast) {
# Contrast requires exactly two of one dimension: 2 groups, 2 measures,
# or 2 occasions (the occasions triple is 1 group, 0 measures,
# 2 occasions; spec sec. 1.2).
n_measures <- length(measures)
n_occasions <- length(occasions)
n_groups <- ifelse(is.null(grouping), 1, nlevels(factor(data[[grouping]])))
group_mean_contrast <- n_measures == 0 && n_groups == 2 && n_occasions == 0
group_corr_contrast <- n_measures == 1 && n_groups == 2 && n_occasions == 0
measure_corr_contrast <- n_measures == 2 && n_groups == 1 && n_occasions == 0
occasion_mean_contrast <- n_measures == 0 && n_groups == 1 && n_occasions == 2
if (!any(group_mean_contrast, group_corr_contrast, measure_corr_contrast,
occasion_mean_contrast)) {
stop(
"Contrast can only be TRUE when comparing 2 groups, 2 measures, ",
"or 2 occasions (occasion contrasts require a single group)."
)
}
}
# Convert angles from degrees to radians
angles <- as_radian(as_degree(angles))
# Occasions = repeated-measures mean analysis
if (!is.null(occasions)) {
# Resolve every block to column POSITIONS: numeric blocks stay positional
# (resolving them to names first-match-collapses duplicated column names
# -- e.g. cbind()ed waves that both keep PA..NO -- silently copying one
# occasion over another; review F1, 2026-07-16), and character blocks
# resolve via match() with unknown names errored. Names are derived from
# the positions afterwards, for stem validation and display only.
cn <- colnames(data)
occ_idx <- lapply(occasions, function(x) {
if (is.numeric(x)) {
ix <- as.integer(x)
if (any(ix < 1 | ix > ncol(data))) {
stop("`occasions` column indices out of range.", call. = FALSE)
}
ix
} else {
ix <- match(x, cn)
if (anyNA(ix)) {
stop("Unknown column name(s) in `occasions`: ",
paste(x[is.na(ix)], collapse = ", "), call. = FALSE)
}
ix
}
})
if (anyDuplicated(unlist(occ_idx))) {
stop(
"`occasions` blocks select overlapping columns; each occasion must ",
"select its own distinct columns.",
call. = FALSE
)
}
# Validate cross-occasion column alignment by stem matching (the rotation
# channel; spec sec. 1.1)
occ_cols <- lapply(occ_idx, function(ix) cn[ix])
occ_validate_alignment(occ_cols, occ_labels)
return(ssm_analyze_occasions(
data = data,
occ_idx = occ_idx,
occ_cols = occ_cols,
occ_labels = occ_labels,
angles = angles,
grouping = grouping,
contrast = contrast,
boots = boots,
interval = interval,
parallel = parallel,
ncpus = ncpus,
method = method,
call = call
))
}
# Forward to the appropriate subfunction
if (is.null(measures)) {
# No Measures = Mean Analysis
ssm_analyze_means(
data = data,
scales = scales,
angles = angles,
grouping = grouping,
contrast = contrast,
boots = boots,
interval = interval,
listwise = listwise,
parallel = parallel,
ncpus = ncpus,
method = method,
call = call
)
} else {
# Measures = Correlation Analysis
ssm_analyze_corrs(
data = data,
scales = scales,
angles = angles,
measures = measures,
grouping = grouping,
contrast = contrast,
boots = boots,
interval = interval,
listwise = listwise,
measures_labels = measures_labels,
parallel = parallel,
ncpus = ncpus,
method = method,
call = call
)
}
}
# Cross-occasion column-alignment validation (spec sec. 1.1) ------------------
# Longest common prefix of a character vector ("" when none)
str_common_prefix <- function(x) {
if (length(x) < 2) return("")
chars <- strsplit(x, "", fixed = TRUE)
n <- min(lengths(chars))
i <- 0
while (i < n) {
if (length(unique(vapply(chars, `[[`, character(1), i + 1))) > 1) break
i <- i + 1
}
substr(x[[1]], 1, i)
}
str_reverse <- function(x) {
vapply(strsplit(x, "", fixed = TRUE),
function(ch) paste(rev(ch), collapse = ""), character(1))
}
# Validate that every occasion block selects the same scales in the same order,
# by stem matching: strip each block's own longest common prefix and suffix
# from its column names; comparable stems must agree in order across blocks.
# Same stems in a different order is exactly the silent-rotation bug (a rotated
# occasion block silently rotates displacement), so it is an error, never a
# message. When any block has no detectable stem structure (no common prefix
# or suffix, or stripped stems that are empty/duplicated), the contract is not
# checkable by name: fall back to a one-time message naming the assumed
# positional alignment. Literal column names can never match across occasions
# (PA_1 vs PA_2), which is why stems are compared instead.
occ_validate_alignment <- function(occ_cols, occ_labels) {
stems <- lapply(occ_cols, function(nm) {
prefix <- str_common_prefix(nm)
suffix <- str_reverse(str_common_prefix(str_reverse(nm)))
if (!nzchar(prefix) && !nzchar(suffix)) return(NULL) # no stem structure
out <- substr(nm, nchar(prefix) + 1, nchar(nm) - nchar(suffix))
if (any(!nzchar(out)) || anyDuplicated(out)) return(NULL)
out
})
if (any(vapply(stems, is.null, logical(1)))) {
message(
"Occasion columns could not be stem-matched; assuming positional ",
"alignment (e.g., '", occ_cols[[1]][[1]], "' ~ '", occ_cols[[2]][[1]],
"' aligned by position 1). Verify every occasion lists the same ",
"scales in the same order."
)
return(invisible(NULL))
}
ref <- stems[[1]]
for (j in seq_along(stems)[-1]) {
if (identical(stems[[j]], ref)) next
if (setequal(stems[[j]], ref)) {
stop(
"Occasion block '", occ_labels[[j]], "' lists the same scale stems ",
"as '", occ_labels[[1]], "' but in a different order; a reordered ",
"occasion block would silently rotate displacement. Reorder its ",
"columns to match.",
call. = FALSE
)
}
stop(
"Occasion block '", occ_labels[[j]], "' has column stems (",
paste(stems[[j]], collapse = ", "), ") that do not match ",
"'", occ_labels[[1]], "' (", paste(ref, collapse = ", "), "); every ",
"occasion must list the same scales in the same order.",
call. = FALSE
)
}
invisible(NULL)
}
# Build the Label/Group/Measure identifier columns ----------------------------
# Construct the Label/Group/Measure columns shared by the observed-score and
# CI tables of both analysis paths. Extracted (M12) from four byte-identical
# inline blocks (two per path, pre- and post-CI); the output must stay
# identical across every branch, so the branch logic lives here unchanged.
# `measures_labels`/`n_measures` are unused on the mean path (pass NULL).
build_result_labels <- function(score_type, group_levels, measures_labels,
n_groups, n_measures, contrast, grouping,
occasions_labels = NULL) {
if (!is.null(occasions_labels)) {
# Occasions path (mean-based only): group-major, occasion-minor rows,
# paralleling the measure path's layout; Label composed the way
# Group/Measure already are. The Occasion column is conditional-presence
# (occasions analyses only; spec sec. 1.1), so it is added here and only
# here. An occasion contrast is second listed minus first (list order).
k <- length(occasions_labels)
Group <- rep(group_levels, each = k)
Occasion <- rep(occasions_labels, times = n_groups)
Measure <- rep(NA_character_, times = n_groups * k)
if (contrast) { # validated upstream: 1 group, 2 occasions
Group <- c(Group, Group[[1]])
Occasion <- c(Occasion, paste0(Occasion[[2]], " - ", Occasion[[1]]))
Measure <- c(Measure, NA_character_)
}
Label <- if (is.null(grouping)) Occasion else paste0(Occasion, ": ", Group)
return(data.frame(Label = Label, Group = Group, Measure = Measure,
Occasion = Occasion, stringsAsFactors = FALSE))
}
if (score_type == "Mean") {
Group <- group_levels
Measure <- rep(NA_character_, times = n_groups)
if (contrast && !is.null(grouping)) {
Group <- c(Group, paste0(Group[[2]], " - ", Group[[1]]))
Measure <- c(Measure, Measure[[1]])
}
Label <- Group
} else {
Group <- rep(group_levels, each = n_measures)
Measure <- rep(measures_labels, times = n_groups)
if (contrast && is.null(grouping)) {
Group <- c(Group, Group[[1]])
Measure <- c(Measure, paste0(Measure[[2]], " - ", Measure[[1]]))
} else if (contrast && !is.null(grouping)) {
Group <- c(Group, paste0(Group[[2]], " - ", Group[[1]]))
Measure <- c(Measure, Measure[[1]])
}
Label <- if (is.null(grouping)) Measure else paste0(Measure, ": ", Group)
}
data.frame(Label = Label, Group = Group, Measure = Measure,
stringsAsFactors = FALSE)
}
# Perform analyses using the mean-based Structural Summary Method --------------
ssm_analyze_means <- function(data, scales, angles, grouping, contrast,
boots, interval, listwise, parallel, ncpus,
method, call) {
# Select circumplex scales and grouping variable (if applicable)
bs_input <- data[scales]
scales_names <- colnames(bs_input)
if (is.null(grouping)) {
bs_input <- cbind(bs_input, Group = rep("All", times = nrow(data)))
} else {
Group <- data[grouping]
colnames(Group) <- "Group"
bs_input <- cbind(bs_input, Group)
}
# Perform listwise deletion if requested
if (listwise) {
bs_input <- stats::na.omit(bs_input)
}
# Set group to factor
bs_input[[ncol(bs_input)]] <- factor(bs_input[[ncol(bs_input)]])
# Get counts
n_scales <- length(scales)
n_groups <- nlevels(bs_input[[ncol(bs_input)]])
group_levels <- levels(bs_input[[ncol(bs_input)]])
# Calculate mean observed scores
mat <- as.matrix(bs_input[scales_names])
grp <- as.integer(bs_input[[ncol(bs_input)]])
obs_scores <- mean_scores(mat, grp, listwise)
scores <- obs_scores
colnames(scores) <- scales_names
group_levels <- levels(bs_input[[ncol(bs_input)]])
if (contrast) {
scores <- rbind(scores, scores[2, ] - scores[1, ])
}
scores <- as.data.frame(scores)
labels <- build_result_labels(
"Mean", group_levels, NULL, n_groups, NULL, contrast, grouping
)
scores <- cbind(labels, scores)
# Create function that will perform bootstrapping
bs_function <- function(.data, index, scales, angles, contrast, listwise, ...) {
resample <- .data[index, ]
mat <- as.matrix(resample[scales])
grp <- as.integer(resample[[ncol(resample)]])
scores_r <- mean_scores(mat, grp, listwise)
ssm_by_group(scores_r, angles, contrast)
}
# Estimate confidence intervals with the requested engine
bs_output <- ssm_estimate_intervals(
method = method,
bs_input = bs_input,
bs_function = bs_function,
scales = scales_names,
measures = NULL,
angles = angles,
boots = boots,
interval = interval,
contrast = contrast,
listwise = listwise,
parallel = parallel,
ncpus = ncpus,
strata = bs_input[[ncol(bs_input)]],
obs_scores = obs_scores
)
params <- bs_output
labels <- build_result_labels(
"Mean", group_levels, NULL, n_groups, NULL, contrast, grouping
)
results <- cbind(labels, params)
# Collect analysis details (suff_stats is a pure list addition for the
# CI-accuracy diagnostic; see ssm_compute_suff_stats() and spec sec. 8.3)
details <- list(
boots = boots,
interval = interval,
listwise = listwise,
angles = as_degree(angles),
contrast = contrast,
score_type = "Mean",
method = method,
suff_stats = ssm_compute_suff_stats(
data = data, scales = scales, measures = NULL,
grouping = grouping, listwise = listwise
)$stats
)
# Create output ssm object
out <- new_ssm(
results = results,
scores = scores,
call = call,
details = details
)
out
}
# Perform analyses using the occasions (repeated-measures) mean-based SSM ------
# Compute the occasions score matrix: one group-mean profile row per
# group x occasion cell, group-major / occasion-minor (spec sec. 1.2), from a
# matrix whose columns hold the k occasion blocks in contiguous strides of p.
# Row r of block j is mean_scores() row r, so both dimensions stay in the
# engines' sorted-group order.
occ_scores <- function(mat, grp, k, p, listwise) {
per_occ <- lapply(seq_len(k), function(j) {
mean_scores(mat[, (j - 1) * p + seq_len(p), drop = FALSE], grp, listwise)
})
n_groups <- nrow(per_occ[[1]])
out <- matrix(NA_real_, n_groups * k, p)
for (g in seq_len(n_groups)) {
for (j in seq_len(k)) {
out[(g - 1) * k + j, ] <- per_occ[[j]][g, ]
}
}
out
}
ssm_analyze_occasions <- function(data, occ_idx, occ_cols, occ_labels, angles,
grouping, contrast, boots, interval,
parallel, ncpus, method, call) {
k <- length(occ_cols)
p <- length(angles)
# Display names for the score columns: the validated stems when detectable,
# else the first occasion's column names
stems <- {
prefix <- str_common_prefix(occ_cols[[1]])
suffix <- str_reverse(str_common_prefix(str_reverse(occ_cols[[1]])))
s <- substr(occ_cols[[1]], nchar(prefix) + 1,
nchar(occ_cols[[1]]) - nchar(suffix))
if (any(!nzchar(s)) || anyDuplicated(s)) occ_cols[[1]] else s
}
# Assemble the wide person-row input: k contiguous occasion blocks, then
# Group. Subset by POSITION (occ_idx), never by name -- duplicated column
# names would first-match-collapse the blocks (review F1). The person-row
# IS the resampling unit, so the existing row resampler is the
# person-level case bootstrap (spec sec. 2.1).
bs_input <- data[unlist(occ_idx)]
if (is.null(grouping)) {
bs_input <- cbind(bs_input, Group = rep("All", times = nrow(data)))
} else {
Group <- data[grouping]
colnames(Group) <- "Group"
bs_input <- cbind(bs_input, Group)
}
# Occasions analyses are listwise-only (validated in ssm_analyze): a person
# missing any occasion is dropped from all occasions, and the deletion is
# messaged because with k*p columns the deletion rate grows with k
# (spec sec. 1.3)
n_before <- nrow(bs_input)
bs_input <- stats::na.omit(bs_input)
n_dropped <- n_before - nrow(bs_input)
if (n_dropped > 0) {
message(
n_dropped, " person(s) with missing values in at least one occasion ",
"removed (complete cases across all occasions). Note that this ",
"estimates completers' change, which can differ from population ",
"change when dropout relates to the outcome."
)
}
if (nrow(bs_input) == 0) {
stop("No persons remain after listwise deletion across occasions.")
}
# Set group to factor
bs_input[[ncol(bs_input)]] <- factor(bs_input[[ncol(bs_input)]])
n_groups <- nlevels(bs_input[[ncol(bs_input)]])
group_levels <- levels(bs_input[[ncol(bs_input)]])
# Calculate mean observed scores (group-major, occasion-minor)
mat <- as.matrix(bs_input[seq_len(k * p)])
grp <- as.integer(bs_input[[ncol(bs_input)]])
obs_scores <- occ_scores(mat, grp, k, p, listwise = TRUE)
scores <- obs_scores
colnames(scores) <- stems
if (contrast) {
scores <- rbind(scores, scores[2, ] - scores[1, ])
}
scores <- as.data.frame(scores)
labels <- build_result_labels(
"Mean", group_levels, NULL, n_groups, NULL, contrast, grouping,
occasions_labels = occ_labels
)
scores <- cbind(labels, scores)
# Create function that will perform bootstrapping: each drawn person-row
# carries that person's entire set of occasion scores, so within-person
# dependence is preserved nonparametrically. k and p are captured lexically;
# the occasion blocks sit in contiguous strides of p columns.
bs_function <- function(.data, index, scales, angles, contrast, listwise,
...) {
resample <- .data[index, ]
mat_r <- as.matrix(resample[seq_len(k * p)])
grp_r <- as.integer(resample[[ncol(resample)]])
scores_r <- occ_scores(mat_r, grp_r, k, p, listwise)
ssm_by_group(scores_r, angles, contrast)
}
# Estimate confidence intervals with the requested engine
bs_output <- ssm_estimate_intervals(
method = method,
bs_input = bs_input,
bs_function = bs_function,
scales = colnames(bs_input)[seq_len(k * p)],
measures = NULL,
angles = angles,
boots = boots,
interval = interval,
contrast = contrast,
listwise = TRUE,
parallel = parallel,
ncpus = ncpus,
strata = bs_input[[ncol(bs_input)]],
obs_scores = obs_scores,
occ_k = k
)
results <- cbind(labels, bs_output)
# Per-group stacked sufficient statistics for the CI-accuracy diagnostic
# (M29 / D-017): the occasions plug-in population is
# MVN(stacked occasion means, observed stacked k*p covariance), so the
# within-person cross-occasion dependence lives in the off-diagonal p x p
# blocks. Stored per group as (n, mean, cov) on the listwise-complete person
# rows -- sufficient, and keeps participant data out of the returned object.
# `occ_k` tags the shape so a classic p x p consumer (ssm_ci_accuracy()'s
# non-occasions path) refuses it rather than silently pooling a k*p matrix.
stacked_cols <- colnames(bs_input)[seq_len(k * p)]
occ_groups <- stats::setNames(vector("list", n_groups), group_levels)
for (g in seq_len(n_groups)) {
block <- mat[grp == g, , drop = FALSE]
occ_groups[[g]] <- list(
n = nrow(block),
mean = colMeans(block),
cov = stats::cov(block)
)
}
suff_stats <- list(
occ_k = k,
occ_labels = occ_labels,
scale_names = stems,
stacked_cols = stacked_cols,
groups = occ_groups
)
# Collect analysis details. `occasions` is the conditional occasions
# metadata (labels; k = its length) read by the print method. `suff_stats`
# carries the stacked per-group statistics the CI-accuracy diagnostic
# replays from (M29 / D-017); an occasions object is refused by the classic
# p x p path via the `occ_k` shape tag.
details <- list(
boots = boots,
interval = interval,
listwise = TRUE,
angles = as_degree(angles),
contrast = contrast,
score_type = "Mean",
method = method,
occasions = occ_labels,
suff_stats = suff_stats
)
new_ssm(
results = results,
scores = scores,
call = call,
details = details
)
}
# Perform analyses using the correlation-based SSM -----------------------------
ssm_analyze_corrs <- function(data, scales, angles, measures, grouping,
contrast, boots, interval, listwise,
measures_labels, parallel, ncpus, method,
call) {
# Select only the scales, measures, and grouping variables
scales_data <- data[scales]
scales_names <- colnames(scales_data)
measures_data <- data[measures]
measures_names <- colnames(measures_data)
bs_input <- cbind(scales_data, measures_data)
if (is.null(grouping)) {
newcol <- data.frame(Group = rep("All", nrow(data)))
bs_input <- cbind(bs_input, newcol)
} else {
newcol <- data[grouping]
colnames(newcol) <- "Group"
bs_input <- cbind(bs_input, newcol)
}
# Perform listwise deletion if requested
if (listwise == TRUE) {
bs_input <- stats::na.omit(bs_input)
}
# Set group as factor
bs_input[[ncol(bs_input)]] <- factor(bs_input[[ncol(bs_input)]])
# Get counts
n_scales <- length(scales)
n_measures <- length(measures)
n_groups <- nlevels(bs_input$Group)
# Get names of measures (using labels if provided)
if (is.null(measures_labels)) {
measures_labels <- measures_names
}
# Calculate observed correlation scores
cs <- as.matrix(bs_input[scales_names])
mv <- as.matrix(bs_input[measures_names])
grp <- as.integer(bs_input[[ncol(bs_input)]])
obs_scores <- corr_scores(cs, mv, grp, listwise)
scores <- obs_scores
colnames(scores) <- scales_names
group_levels <- levels(bs_input[[ncol(bs_input)]])
if (contrast) {
scores <- rbind(scores, scores[2, ] - scores[1, ])
}
scores <- as.data.frame(scores)
labels <- build_result_labels(
"Correlation", group_levels, measures_labels, n_groups, n_measures,
contrast, grouping
)
scores <- cbind(labels, scores)
# Create function that will perform bootstrapping
bs_function <- function(.data, index, scales, measures, angles, contrast,
listwise, ...) {
resample <- .data[index, ]
cs <- as.matrix(resample[scales])
mv <- as.matrix(resample[measures])
grp <- as.integer(resample[[ncol(resample)]])
scores_r <- corr_scores(cs, mv, grp, listwise)
ssm_by_group(scores_r, angles, contrast)
}
# Estimate confidence intervals with the requested engine
bs_output <- ssm_estimate_intervals(
method = method,
bs_input = bs_input,
bs_function = bs_function,
scales = scales_names,
measures = measures_names,
angles = angles,
boots = boots,
interval = interval,
contrast = contrast,
listwise = listwise,
parallel = parallel,
ncpus = ncpus,
strata = bs_input$Group,
obs_scores = obs_scores
)
labels <- build_result_labels(
"Correlation", group_levels, measures_labels, n_groups, n_measures,
contrast, grouping
)
results <- cbind(labels, bs_output)
# Collect analysis details (suff_stats is a pure list addition for the
# CI-accuracy diagnostic; see ssm_compute_suff_stats() and spec sec. 8.3)
details <- list(
boots = boots,
interval = interval,
listwise = listwise,
angles = as_degree(angles),
contrast = contrast,
score_type = "Correlation",
method = method,
suff_stats = ssm_compute_suff_stats(
data = data, scales = scales, measures = measures,
grouping = grouping, listwise = listwise