Repository navigation
Expand file tree
/
Copy pathhelpers.R
More file actions
2096 lines (2000 loc) · 73 KB
/
Copy pathhelpers.R
File metadata and controls
2096 lines (2000 loc) · 73 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
# =============================================================================
# helpers.R -- Shared packages, constants, and reusable functions
# =============================================================================
# Source it once at the beginning of an analytical section to load
# the packages and objects shared by the project.
# --- Packages ----------------------------------------------------------------
library(lavaan)
library(semTools)
library(semPower)
library(semPlot)
library(simsem)
library(MASS)
library(ggplot2)
library(dplyr)
library(here)
library(knitr)
library(downlit) # Required by Quarto HTML code hyperlinks.
library(xml2) # Required by HTML code hyperlinks and figure export.
library(quantreg)
library(parallel)
library(tibble)
library(purrr)
library(stringr)
library(readr)
library(careless)
library(modi)
library(psych)
library(Rcsdp) # Required by psych::glb.algebraic().
library(likert)
library(dynamic)
# --- Configuration -----------------------------------------------------------
# Number of Monte Carlo replications at every value of N.
REP <- 10
# Number of Monte Carlo replications at fixed value of N.
SIM <- 1000
# Sample-size sequences.
SEQ <- rep(101:400, each = REP)
# Target N used to inspect power and cutoffs after varying N simulations.
N_TARGET <- 200
# Random seed used by simsem to make the simulations reproducible.
SEED <- 123321
# A priori decision criteria.
ALPHA <- 0.05
POWER <- 0.80
# Number of observed indicators in the four-domain WHOQOL-Bref model.
P <- 24
# Fit indices used to evaluate power and derive model-calibrated cutoffs.
FITS <- c("rmsea", "srmr", "cfi", "tli")
FITS_ROB <- c("rmsea.robust", "srmr", "cfi.robust", "tli.robust")
# Conventional cutoffs retained only as descriptive reference values.
RULE_OF_THUMB <- c(
rmsea = 0.06,
cfi = 0.95,
tli = 0.95,
srmr = 0.06
)
# Mild non-normality based on Yoshitake et al. (2015). Across the 24 items,
# skewness ranges from -1 to +1 and kurtosis ranges from 1.7 to 2.0.
DIST <- bindDist(
skewness = seq(-1, 1, length.out = P),
kurtosis = seq(1.7, 2, length.out = P)
)
# Missing Complete at Random (MCAR) percent
P_MCAR <- 0.10
#
options(max.print = 1e6)
# Named character vector mapping original node names to display labels.
LABELS <- c(
psycho = "PSI",
physical = "FIS",
social = "SOC",
environment = "AMB"
)
# --- Reusable plotting function ---------------------------------------------
# =============================================================================
# CFA path diagram in color or black and white
# =============================================================================
# Purpose:
# Draw a SEM diagram with consistent styling and optional custom node labels.
#
# Input:
# model: A model specification or fitted object accepted by semPlotModel().
# color: "color" (default) uses automatic latent-group colors; "BW" uses
# white nodes with black edges, borders, and labels.
# what_labels: Edge labels passed to semPaths(); defaults to "est".
# Use "no" to omit numerical labels or "std" for standardized estimates.
# node_labels: Optional named character vector mapping original node names
# to display labels. Unspecified nodes retain their original names.
# layout: Layout passed to semPaths(); defaults to "circle".
#
# Output:
# A path diagram drawn by semPaths(), with its return value passed through.
# In color mode, semPaths() chooses colors automatically for latent groups.
# Original node names are preserved unless explicitly replaced. The function
# does not estimate a model or save a file. In color mode, lines, borders,
# and text use the plotting defaults; BW explicitly makes them black.
#
# Example:
# plot_cfa(fit)
# plot_cfa(popmodel, color = "BW", node_labels = c(
# psycho = "PSI", physical = "FIS", social = "SOC", environment = "AMB"
# ))
# plot_cfa(adjustedzemodel, color = "color", what_labels = "no")
plot_cfa <- function(
model,
what_labels = "est",
node_labels = NULL,
layout = "circle",
color = "color"
) {
# Select the diagram appearance independently of its model and layout.
color <- match.arg(color, c("color", "BW"))
# Convert the supplied model and preserve its original node names.
plot_model <- semPlot::semPlotModel(model)
original_names <- plot_model@Vars[["name"]]
display_labels <- original_names
# Validate optional replacements and match them by original node name.
if (!is.null(node_labels)) {
if (
!is.character(node_labels) ||
is.null(names(node_labels)) ||
anyNA(node_labels) ||
anyNA(names(node_labels)) ||
any(names(node_labels) == "") ||
anyDuplicated(names(node_labels))
) {
stop("node_labels must be a named character vector with unique names.")
}
unknown_names <- setdiff(names(node_labels), original_names)
if (length(unknown_names) > 0) {
stop("Unknown node names: ", paste(unknown_names, collapse = ", "))
}
replacements <- match(original_names, names(node_labels))
matched <- !is.na(replacements)
display_labels[matched] <- unname(node_labels[replacements[matched]])
}
# Share the model, geometry, and labels between both appearances.
arguments <- list(
object = plot_model,
whatLabels = what_labels,
style = "lisrel",
layout = layout,
intercepts = FALSE,
thresholds = FALSE,
residuals = FALSE,
nodeLabels = display_labels,
nCharNodes = 0,
reorder = FALSE,
sizeLat = 9,
sizeMan = 5,
sizeMan2 = 5,
label.cex = 1.2,
edge.label.cex = 1.2,
edge.label.bg = TRUE,
weighted = FALSE,
mar = c(2, 1.5, 2, 1.5)
)
# Apply automatic group colors or the monochrome publication style.
if (color == "color") {
arguments$groups <- "latents"
} else {
arguments$color <- "white"
arguments$edge.color <- "black"
arguments$border.color <- "black"
arguments$label.color <- "black"
arguments$edge.label.color <- "black"
}
do.call(semPlot::semPaths, arguments)
}
# --- semPower Output Decoupling Helpers ----------------------------------------
# =============================================================================
# Numeric formatting for analytical power summaries
# =============================================================================
# Purpose:
# Format one numeric value for the semPower summary table.
#
# Input:
# x: A numeric scalar, NULL, or an empty numeric vector.
# digits: Decimal places for fixed notation; defaults to 3.
#
# Output:
# A character value. Missing or empty inputs become NA_character_.
# Positive values below 1e-4 use scientific notation with two decimal places;
# other values use fixed notation. This helper expects at most one value.
#
# Example:
# format_metric_val(0.04912)
format_metric_val <- function(x, digits = 3) {
# Preserve missing or empty inputs as a missing character value.
if (is.null(x) || length(x) == 0 || is.na(x)) {
return(NA_character_)
}
# Use scientific notation for small positive values.
if (abs(x) < 1e-4 && x > 0) {
return(formatC(x, format = "e", digits = 2))
}
# Format the remaining values with the requested decimal places.
formatC(x, format = "f", digits = digits)
}
# =============================================================================
# Organized analytical power summary
# =============================================================================
# Purpose:
# Extract the selected semPower results and prepare their display values.
#
# Input:
# object: An a priori or post hoc semPower result, including model-based
# results from semPower.powerLav(), with the corresponding result fields.
# lang: Display language, "en" (default) or "pt".
#
# Output:
# Internal helper used automatically by table_sempower().
# A data.frame with Measure, Symbol, Value (en) or Medida, Símbolo, Valor (pt).
# Values are formatted character strings, not full-precision numeric data;
# the original values remain available in object. Optional fit-index rows
# are included when their fields exist. A priori results use requiredN and
# the implied NCP, beta, and power; post hoc results use N, ncp, beta, and power.
# Power above 0.9999 is displayed as "> 0.9999".
#
# Example:
# tidy_sempower(ap_h1model)
tidy_sempower <- function(object, lang = "en") {
# Validate the display language without changing the numerical results.
lang <- match.arg(lang, c("en", "pt"))
# Select the result fields for a priori or post hoc analysis.
is_apriori <- inherits(object, "semPower.aPriori") ||
identical(object[["type"]], "a-priori")
n_label <- if (is_apriori) {
"Tamanho amostral necessário"
} else {
"Tamanho amostral observado"
}
n_val <- if (is_apriori) object[["requiredN"]] else object[["N"]]
ncp_val <- if (is_apriori) object[["impliedNCP"]] else object[["ncp"]]
beta_val <- if (is_apriori) object[["impliedBeta"]] else object[["beta"]]
power_val <- if (is_apriori) object[["impliedPower"]] else object[["power"]]
# Assemble the selected measures and their formatted display values.
res <- list(
c(
"Discrepância populacional",
"$F_0$",
format_metric_val(object[["fmin"]])
),
c(
"Raiz do erro quadrático médio de aproximação",
"$\\text{RMSEA}$",
format_metric_val(object[["rmsea"]])
),
c(
"Índice de centralidade de McDonald",
"$Mc$",
format_metric_val(object[["mc"]])
),
if (!is.null(object[["gfi"]])) {
c(
"Índice de qualidade do ajuste",
"$\\text{GFI}$",
format_metric_val(object[["gfi"]])
)
},
if (!is.null(object[["agfi"]])) {
c(
"Índice de qualidade do ajuste ajustado",
"$\\text{AGFI}$",
format_metric_val(object[["agfi"]])
)
},
if (!is.null(object[["srmr"]])) {
c(
"Raiz do resíduo quadrático médio padronizado",
"$\\text{SRMR}$",
format_metric_val(object[["srmr"]])
)
},
if (!is.null(object[["cfi"]])) {
c(
"Índice de ajuste comparativo",
"$\\text{CFI}$",
format_metric_val(object[["cfi"]])
)
},
c("Graus de liberdade", "$df$", as.character(object[["df"]])),
c(n_label, "$N$", as.character(n_val)),
c(
"Qui-quadrado crítico",
"$\\chi^2_{\\text{crit}}$",
format_metric_val(object[["chiCrit"]])
),
c(
"Parâmetro de não centralidade",
"$\\lambda$",
format_metric_val(ncp_val)
),
c(
"Nível de significância (alfa)",
"$\\alpha$",
format_metric_val(object[["alpha"]])
),
c(
"Erro tipo II (beta)",
"$\\beta$",
format_metric_val(beta_val, digits = 4)
),
c(
"Poder estatístico",
"$1 - \\beta$",
if (power_val > 0.9999) {
"> 0.9999"
} else {
format_metric_val(power_val, digits = 4)
}
)
)
# Remove absent optional measures and name the display columns.
res <- do.call(rbind, res[!sapply(res, is.null)])
# Translate only the measure descriptions and column headings.
if (lang == "en") {
labels_en <- c(
"Discrepância populacional" = "Population discrepancy",
"Raiz do erro quadrático médio de aproximação" = "Root mean square error of approximation",
"Índice de centralidade de McDonald" = "McDonald centrality index",
"Índice de qualidade do ajuste" = "Goodness-of-fit index",
"Índice de qualidade do ajuste ajustado" = "Adjusted goodness-of-fit index",
"Raiz do resíduo quadrático médio padronizado" = "Standardized root mean square residual",
"Índice de ajuste comparativo" = "Comparative fit index",
"Graus de liberdade" = "Degrees of freedom",
"Tamanho amostral necessário" = "Required sample size",
"Tamanho amostral observado" = "Observed sample size",
"Qui-quadrado crítico" = "Critical chi-square",
"Parâmetro de não centralidade" = "Noncentrality parameter",
"Nível de significância (alfa)" = "Significance level (alpha)",
"Erro tipo II (beta)" = "Type II error rate (beta)",
"Poder estatístico" = "Statistical power"
)
res[, 1] <- unname(labels_en[res[, 1]])
colnames(res) <- c("Measure", "Symbol", "Value")
} else {
colnames(res) <- c("Medida", "Símbolo", "Valor")
}
as.data.frame(res, stringsAsFactors = FALSE)
}
# =============================================================================
# Formatted analytical power table
# =============================================================================
# Purpose:
# Present the selected semPower results separately from the distribution plot.
#
# Input:
# object: A semPower result supported by tidy_sempower().
# align: Column alignment passed to knitr::kable(); defaults to left,
# center, and right for the measure, symbol, and value columns, respectively.
# lang: Display language, "en" (default) or "pt"; passed to tidy_sempower().
#
# Output:
# A knitr_kable table using the display values from tidy_sempower().
# No model is estimated and no file is saved by this function.
#
# Example:
# table_sempower(ap_h1model)
# table_sempower(ap_h1model, lang = "pt")
table_sempower <- function(object, align = c("l", "c", "r"), lang = "en") {
# Prepare the selected measures and display values.
df_tidy <- tidy_sempower(object, lang = lang)
# Render the table using the requested column alignment.
knitr::kable(df_tidy, align = align)
}
# =============================================================================
# Analytical power distribution plot
# =============================================================================
# Purpose:
# Draw central and noncentral chi-square distributions without a text summary.
# Plot labels retain the original English wording from semPower.
#
# Input:
# object: A semPower result containing chiCrit, df, and impliedNCP or ncp.
# linewidth: Curve width; defaults to 1.2.
# show_labels: Whether to display the plot labels; defaults to TRUE.
#
# Output:
# A plot drawn by semPower.showPlot(), with its return value passed through.
# Uses impliedNCP when available, otherwise ncp. It does not rerun the power
# analysis or save a figure file.
#
# Example:
# plot_sempower(ap_h1model)
plot_sempower <- function(object, linewidth = 1.2, show_labels = TRUE) {
# Select the noncentrality parameter available in the result.
ncp <- if (!is.null(object[["impliedNCP"]])) {
object[["impliedNCP"]]
} else {
object[["ncp"]]
}
# Draw the distributions without printing the analytical summary.
semPower::semPower.showPlot(
chiCrit = object[["chiCrit"]],
ncp = ncp,
df = object[["df"]],
linewidth = linewidth,
showLabels = show_labels
)
}
# --- Table Global Fit --------------------------------------------------------
# =============================================================================
# Organized global fit measures
# =============================================================================
# Purpose:
# Internal helper called automatically by table_fit(). Preserve every measure
# returned by lavaan and identify the eleven measures used in the summary.
#
# Input:
# object: One fitted, converged lavaan model with an available model test.
#
# Output:
# A data.frame with measure, estimate, and selected columns. Estimates retain
# their original numerical precision. The selected_measures attribute gives
# the summary order; test_version and index_version identify its variants.
# Inspect actual corrected tests, rather than inferring robustness from the
# estimator name. Prefer robust CFI/TLI/RMSEA when a corrected test exists;
# otherwise use scaled indices if robust fields are absent. Standard tests
# use standard indices. Missing selected values remain NA with a warning;
# there is no silent substitution of individual measures between variants.
# N is read from lavInspect(object, "ntotal") if fitMeasures() omits it.
# RMSEA uses a 90% CI and a close-fit null of RMSEA <= .05.
# No model is estimated and no file is saved.
#
# Example:
# fit_data <- tidy_fit(fit_adjustedzemodel)
# fit_data[fit_data$selected, ]
tidy_fit <- function(object) {
# Validate the fitted model before requesting its fit measures.
if (!inherits(object, "lavaan")) {
stop("object must be one fitted lavaan model.")
}
if (!isTRUE(lavaan::lavInspect(object, "converged"))) {
stop("Global fit measures require a converged model.")
}
# Inspect the actual tests and retain every native numerical measure.
tests <- lavaan::lavInspect(object, "test")
corrected_tests <- c(
"satorra.bentler",
"yuan.bentler",
"yuan.bentler.mplus",
"mean.var.adjusted",
"scaled.shifted",
"satterthwaite"
)
has_correction <- any(names(tests) %in% corrected_tests)
measures <- lavaan::fitMeasures(
object,
fm.args = list(rmsea.ci.level = 0.90, rmsea.close.h0 = 0.05)
)
# Recover the actual sample size when the fit-measure vector omits N.
if (!"ntotal" %in% names(measures)) {
measures <- c(
measures,
ntotal = as.numeric(
lavaan::lavInspect(object, "ntotal")
)
)
}
test_version <- if (has_correction) "scaled" else "standard"
index_version <- "standard"
if (has_correction) {
if (
any(
c("cfi.robust", "tli.robust", "rmsea.robust") %in%
names(measures)
)
) {
index_version <- "robust"
} else {
index_version <- "scaled"
warning("Robust index fields are absent; using scaled indices.")
}
}
# Select the test and one consistent CFI/TLI/RMSEA family by native names.
test_suffix <- if (has_correction) ".scaled" else ""
index_suffix <- if (index_version == "standard") {
""
} else {
paste0(".", index_version)
}
selected_measures <- c(
"ntotal",
paste0(c("chisq", "df", "pvalue"), test_suffix),
paste0(c("cfi", "tli", "rmsea"), index_suffix),
paste0(c("rmsea.ci.lower", "rmsea.ci.upper", "rmsea.pvalue"), index_suffix),
"srmr"
)
# Preserve unavailable summary fields and flag them without relabeling.
missing_names <- setdiff(selected_measures, names(measures))
if (length(missing_names) > 0) {
measures <- c(
measures,
stats::setNames(
rep(NA_real_, length(missing_names)),
missing_names
)
)
}
unavailable <- selected_measures[
!is.finite(measures[selected_measures])
]
if (length(unavailable) > 0) {
warning(
"Unavailable fit measures retained as NA: ",
paste(unavailable, collapse = ", ")
)
}
# Return full-precision values and metadata for presentation or reuse.
result <- data.frame(
measure = names(measures),
estimate = as.numeric(measures),
selected = names(measures) %in% selected_measures,
stringsAsFactors = FALSE
)
attr(result, "selected_measures") <- selected_measures
attr(result, "test_version") <- test_version
attr(result, "index_version") <- index_version
result
}
# =============================================================================
# Formatted global fit table
# =============================================================================
# Purpose:
# Present the usual eleven global fit measures for one fitted lavaan model.
#
# Input:
# object: One fitted lavaan model, passed to tidy_fit() automatically.
# lang: Label language, "en" (default) or "pt". Acronyms remain unchanged.
#
# Output:
# A knitr_kable table with compact labels and unchanged index acronyms.
# N and df use
# integer display, other measures three decimals, and p values three
# decimal places with values below .001 displayed as < 0.001.
# Robust/scaled index families are marked in their labels; RMSEA CI bounds
# and its close-fit p value use that same family. SRMR has no robust suffix.
# The table_data attribute contains exactly the displayed labels and values
# for CSV export. The fit_data attribute contains all full-precision measures.
# Original model estimates are not changed; no file is saved.
#
# Example:
# table_fit(fit_adjustedzemodel)
# table_fit(fit_adjustedzemodel, lang = "pt")
# fit_data <- tidy_fit(fit_adjustedzemodel)
table_fit <- function(object, lang = "en") {
# Obtain all numerical measures and select the standard summary order.
lang <- match.arg(lang, c("en", "pt"))
fit_data <- tidy_fit(object)
selected <- attr(fit_data, "selected_measures")
values <- fit_data$estimate[match(selected, fit_data$measure)]
index_version <- attr(fit_data, "index_version")
test_version <- attr(fit_data, "test_version")
# Build compact labels without translating standardized index acronyms.
index_tag <- switch(
index_version,
standard = "",
robust = if (lang == "pt") " (robusto)" else " (robust)",
scaled = if (lang == "pt") " (ajust.)" else " (scaled)"
)
chi_label <- if (test_version == "standard") {
"χ²"
} else if (lang == "pt") {
"χ² ajust."
} else {
"χ² scaled"
}
labels <- c(
"N",
chi_label,
"df",
if (lang == "pt") "p-valor" else "p",
paste0(c("CFI", "TLI", "RMSEA"), index_tag),
if (lang == "pt") {
c("RMSEA IC90% inf.", "RMSEA IC90% sup.")
} else {
c("RMSEA CI90% low", "RMSEA CI90% up")
},
if (lang == "pt") "p (RMSEA ≤ 0,05)" else "p (RMSEA ≤ 0.05)",
"SRMR"
)
# Format a display copy while preserving the original numerical data.
display <- rep("NA", length(values))
available <- is.finite(values)
display[available] <- formatC(values[available], format = "f", digits = 3)
for (i in c(1L, 3L)) {
if (available[i]) display[i] <- formatC(values[i], format = "f", digits = 0)
}
for (i in c(4L, 10L)) {
if (available[i]) {
display[i] <- if (values[i] < 0.001) {
"< 0.001"
} else {
formatC(values[i], format = "f", digits = 3)
}
}
}
output <- data.frame(labels, display, stringsAsFactors = FALSE)
names(output) <- if (lang == "pt") {
c("Medida", "Valor")
} else {
c("Measure", "Value")
}
# Render the table and retain access to its complete numerical source.
rendered <- output
if (knitr::is_latex_output()) {
rendered[[1]] <- gsub("χ²", "$\\chi^2$", rendered[[1]], fixed = TRUE)
rendered[[1]] <- gsub("≤", "$\\leq$", rendered[[1]], fixed = TRUE)
}
result <- knitr::kable(
rendered,
format = "pipe",
align = c("l", "r"),
row.names = FALSE
)
attr(result, "fit_data") <- fit_data
attr(result, "table_data") <- output
result
}
# --- Table Parameters --------------------------------------------------------
# =============================================================================
# Organized model parameters
# =============================================================================
# Purpose:
# Internal helper called automatically by table_par(). Organize native lavaan
# parameters, including fixed rows, with parameter types and construct labels.
#
# Input:
# object: One fitted, converged lavaan model.
#
# Output:
# A data.frame retaining the native parameter estimates, standard errors,
# z statistics, p values, confidence intervals, and standardized estimates.
# Additional columns parameter_type, construct, and fixed support selection.
# Construct labels are original latent-variable names. Observed variables are
# mapped to their loading factors within the same group/level; cross-loaded
# items can have several constructs. Unassociated variables receive NA.
# std.all uses cov.std = TRUE: residual covariances are standardized using
# residual variances. SE, z, and p refer to the unstandardized estimates.
# Defined parameters, scaling parameters, and other unsupported operators are
# preserved in the data but classified as other. No model is fitted or saved.
#
# Example:
# par_data <- tidy_par(fit_adjustedzemodel)
tidy_par <- function(object) {
# Validate the fitted model and retain its native parameter estimates.
if (!inherits(object, "lavaan")) {
stop("object must be one fitted lavaan model.")
}
if (!isTRUE(lavaan::lavInspect(object, "converged"))) {
stop("Parameter summaries require a converged model.")
}
result <- lavaan::parameterEstimates(
object,
standardized = TRUE,
cov.std = TRUE,
remove.nonfree = FALSE,
output = "data.frame"
)
parameters <- lavaan::parTable(object)
latent_names <- unique(parameters$lhs[parameters$op == "=~"])
# Match rows to their free/fixed status, respecting group and level keys.
keys <- intersect(
c("lhs", "op", "rhs", "group", "level", "block"),
intersect(names(result), names(parameters))
)
row_key <- function(data) {
do.call(paste, c(lapply(data[keys], as.character), sep = "\034"))
}
parameter_row <- match(row_key(result), row_key(parameters))
result$fixed <- parameters$free[parameter_row] == 0L
# Classify parameter types without confusing intercepts and thresholds.
result$parameter_type <- "other"
result$parameter_type[result$op == "=~"] <- "loadings"
result$parameter_type[result$op == "~~" & result$lhs != result$rhs] <- "cov"
result$parameter_type[result$op == "~~" & result$lhs == result$rhs] <- "var"
result$parameter_type[
result$op == "~1" &
!result$lhs %in% latent_names
] <- "intercepts"
result$parameter_type[
result$op == "~1" &
result$lhs %in% latent_names
] <- "means"
result$parameter_type[result$op == "|"] <- "thresholds"
result$parameter_type[result$op == "~"] <- "regressions"
# Associate each row with constructs in its own model group and level.
context_keys <- intersect(c("group", "level", "block"), names(result))
result$construct <- vapply(
seq_len(nrow(result)),
function(i) {
if (result$parameter_type[i] == "loadings") {
return(result$lhs[i])
}
same_context <- rep(TRUE, nrow(result))
for (key in context_keys) {
same_context <- same_context & result[[key]] == result[[key]][i]
}
nodes <- unique(c(result$lhs[i], result$rhs[i]))
factors <- unique(c(
nodes[nodes %in% latent_names],
result$lhs[same_context & result$op == "=~" & result$rhs %in% nodes]
))
if (length(factors) == 0L) {
NA_character_
} else {
paste(factors, collapse = " / ")
}
},
character(1)
)
result
}
# =============================================================================
# Formatted model parameter table
# =============================================================================
# Purpose:
# Present selected parameters using their lavaan relations as identifiers.
#
# Input:
# object: One fitted lavaan model, passed to tidy_par() automatically.
# type: Parameter types to show; defaults to loadings. Accepts a character
# vector containing loadings, cov, var, intercepts, means, thresholds, and
# regressions. all selects those seven types, not unsupported operators.
# lang: Label language, en (default) or pt. Variable names are not translated.
# include_fixed: Include fixed parameters; defaults to FALSE. Setting TRUE
# also adds a Fixed column. A marker loading fixed to 1 is omitted by default.
#
# Output:
# A knitr_kable table with Relation, Estimate, SE, z, and Std.
# Estimates and statistics use three decimals. Rows follow the requested type
# order and retain native order within each type. Group/level columns are
# included when needed to distinguish multiple groups or levels.
# Relations retain lavaan operators instead of separate type/factor columns.
# Std. is std.all, including
# residual correlations for residual covariance rows, not a separate SE/z
# test of the standardized estimate. Missing values display as NA.
# table_data contains exactly the displayed content for CSV export.
# par_data retains every organized native parameter at full precision.
# No parameter estimation or file export is performed by this function.
#
# Example:
# table_par(fit_adjustedzemodel)
# table_par(fit_adjustedzemodel, type = c("loadings", "cov"), lang = "pt")
# table_par(fit_adjustedzemodel, type = "all", include_fixed = TRUE)
table_par <- function(
object,
type = "loadings",
lang = "en",
include_fixed = FALSE
) {
# Validate the selection and obtain all organized native parameters.
lang <- match.arg(lang, c("en", "pt"))
allowed <- c(
"loadings",
"cov",
"var",
"intercepts",
"means",
"thresholds",
"regressions"
)
if (
!is.character(type) ||
length(type) == 0L ||
anyNA(type) ||
any(!type %in% c(allowed, "all"))
) {
stop(
"type must contain loadings, cov, var, intercepts, means, ",
"thresholds, regressions, or all."
)
}
if (
!is.logical(include_fixed) ||
length(include_fixed) != 1L ||
is.na(include_fixed)
) {
stop("include_fixed must be TRUE or FALSE.")
}
if ("all" %in% type) {
type <- allowed
}
type <- unique(type)
par_data <- tidy_par(object)
selected <- par_data[par_data$parameter_type %in% type, , drop = FALSE]
if (!include_fixed) {
selected <- selected[
!is.na(selected$fixed) &
!selected$fixed,
,
drop = FALSE
]
}
selected <- selected[
order(match(selected$parameter_type, type)),
,
drop = FALSE
]
if (nrow(selected) == 0L) {
stop("No parameters match the requested types and fixed/free selection.")
}
# Preserve grouping information when several model contexts are present.
output <- data.frame(row.names = seq_len(nrow(selected)))
for (key in intersect(c("group", "level", "block"), names(selected))) {
if (length(unique(par_data[[key]])) > 1L) {
title <- switch(
key,
group = if (lang == "pt") "Grupo" else "Group",
level = if (lang == "pt") "Nível" else "Level",
block = if (lang == "pt") "Bloco" else "Block"
)
output[[title]] <- selected[[key]]
}
}
# Build relations and display values without changing numerical estimates.
relation <- paste(selected$lhs, selected$op, selected$rhs)
relation[selected$op == "~1"] <- paste(
selected$lhs[selected$op == "~1"],
"~ 1"
)
main <- data.frame(relation = relation, stringsAsFactors = FALSE)
names(main) <- if (lang == "pt") "Relação" else "Relation"
for (key in c("est", "se", "z", "std.all")) {
value <- selected[[key]]
formatted <- rep("NA", length(value))
formatted[is.finite(value)] <- formatC(
value[is.finite(value)],
format = "f",
digits = 3
)
title <- switch(
key,
est = if (lang == "pt") "Estimativa" else "Estimate",
se = if (lang == "pt") "EP" else "SE",
z = "z",
std.all = if (lang == "pt") "Padronizada" else "Std."
)
main[[title]] <- formatted
}
output <- cbind(output, main)
if (include_fixed) {
title <- if (lang == "pt") "Fixado" else "Fixed"
output[[title]] <- if (lang == "pt") {
ifelse(selected$fixed, "Sim", "Não")
} else {
ifelse(selected$fixed, "Yes", "No")
}
}
# Render the table and retain presentation and numerical data for reuse.
alignment <- rep("l", ncol(output))
alignment[match(names(main)[2:5], names(output))] <- "r"
result <- knitr::kable(output, align = alignment, row.names = FALSE)
attr(result, "table_data") <- output
attr(result, "par_data") <- par_data
result
}
# --- Table Reliability -------------------------------------------------------
# =============================================================================
# Organized score reliability
# =============================================================================
# Purpose:
# Internal helper called automatically by table_reliability(). Calculate
# omega and algebraic GLB for equally weighted factor-specific scores.
#
# Input:
# object: One converged, single-group, first-order CFA with continuous items.
# score_items: Optional named list keyed by original factor names. Each item
# set must match that factor's nonzero/freely estimated indicator set in the
# fitted model. A subset of factors may be requested, but not arbitrary item
# subsets: the native factor omega must correspond to the same score as GLB.
# NULL derives the item sets automatically, including cross-loadings.
#
# Output:
# A data.frame with factor, items, omega, glb, and glb_status. Values retain
# full precision. Attributes score_items, omega_result, and glb_results keep
# item composition and native computational results, including solver status.
# Uses compRelSEM(obs.var = TRUE, tau.eq = FALSE) and glb.algebraic() on the
# corresponding observed covariance blocks from lavInspect(). A cross-loaded
# item can belong to several factor scores; factor names are not translated.
# GLB does not model residual covariances, unlike model-based omega.
# Failed GLB calculations remain NA with a warning and diagnostic details.
# Single-indicator scores have NA reliability with a warning. Coefficients
# outside [0,1] are flagged, not clipped. Ordinal, multigroup, higher-order,
# and structural-regression models are outside this initial implementation.
# No model is estimated, no data are simulated, and no file is saved.
#
# Example:
# reliability_data <- tidy_reliability(fit_adjustedzemodel)
tidy_reliability <- function(object, score_items = NULL) {
# Validate the model scope before calculating comparable score coefficients.
if (
!inherits(object, "lavaan") ||
!isTRUE(lavaan::lavInspect(object, "converged"))
) {
stop("object must be one converged lavaan CFA model.")
}
if (
lavaan::lavInspect(object, "ngroups") != 1L ||
lavaan::lavInspect(object, "nlevels") != 1L
) {
stop("This version supports single-group, single-level CFA models.")
}
if (length(lavaan::lavNames(object, "ov.ord")) > 0L) {
stop(
"This version requires continuous indicators to keep omega and GLB ",
"on the same observed-score scale."
)
}
parameters <- lavaan::parTable(object)
latent_names <- unique(parameters$lhs[parameters$op == "=~"])
if (
length(latent_names) == 0L ||
any(parameters$op == "~") ||
any(parameters$op == "=~" & parameters$rhs %in% latent_names)
) {
stop(
"This version requires a first-order CFA without structural regressions."
)
}
# Derive each factor's equally weighted score, excluding fixed-zero loadings.
loadings <- parameters[
parameters$op == "=~" &
(parameters$free > 0L | parameters$est != 0),
,
drop = FALSE
]
native_items <- stats::setNames(
lapply(latent_names, function(factor) {
unique(loadings$rhs[loadings$lhs == factor])
}),
latent_names
)
native_items <- native_items[lengths(native_items) > 0L]
if (is.null(score_items)) {
score_items <- native_items
}
if (
!is.list(score_items) ||
length(score_items) == 0L ||
is.null(names(score_items)) ||
anyNA(names(score_items)) ||
any(names(score_items) == "") ||
anyDuplicated(names(score_items)) ||
any(!names(score_items) %in% names(native_items))
) {
stop("score_items must be a named list of fitted factor scores.")
}
for (factor in names(score_items)) {