Principal Component Analysis (PCA) is a fundamental technique for
exploring multivariate data. In distributional data analysis, each
observation is not a single number but an entire probability
distribution. The DistributionalDDA package extends R’s
classical princomp() function to work seamlessly with
“BData” objects - the package’s representation of distributional data.
This means you can use the familiar princomp(),
plot(), summary(), predict(),
biplot(), and screeplot() functions on your
distributional data, with specialized methods that understand the
functional nature of distributions.
This vignette demonstrates PCA for distributional data using the
age dataset included in the package. This dataset contains
age distributions from 57 communes in Upper Austria, separated by sex -
a real-world example where we want to understand the main modes of
variation in how populations are distributed across age groups.
In general, PCA for distributional data identifies the main “modes of variation” - the principal ways in which distributions in a dataset differ from each other. It answers questions like:
Applied to the age dataset, we can specifically ask:
Let’s start by loading the age distribution dataset:
data("age")
str(age)
## List of 3
## $ data :'data.frame': 114 obs. of 22 variables:
## ..$ commune : chr [1:114] "4010" "4020" "4030" "4040" ...
## ..$ municipality: chr [1:114] "Linz" "Steyr" "Wels" "Altheim" ...
## ..$ sex : Factor w/ 2 levels "male","female": 1 1 1 1 1 1 1 1 1 1 ...
## ..$ AGE_2 : num [1:114] 0.0517 0.0515 0.0532 0.0479 0.0512 ...
## ..$ AGE_7 : num [1:114] 0.0446 0.0476 0.0539 0.0464 0.0532 ...
## ..$ AGE_12 : num [1:114] 0.0452 0.0478 0.0548 0.0543 0.0552 ...
## ..$ AGE_17 : num [1:114] 0.0507 0.0612 0.0642 0.0651 0.0649 ...
## ..$ AGE_22 : num [1:114] 0.0689 0.0706 0.0661 0.0654 0.0638 ...
## ..$ AGE_27 : num [1:114] 0.0824 0.0696 0.0726 0.0624 0.061 ...
## ..$ AGE_32 : num [1:114] 0.0822 0.0644 0.0713 0.0639 0.0666 ...
## ..$ AGE_37 : num [1:114] 0.0676 0.0607 0.0616 0.059 0.0704 ...
## ..$ AGE_42 : num [1:114] 0.0729 0.0693 0.0796 0.0762 0.0799 ...
## ..$ AGE_47 : num [1:114] 0.0816 0.0794 0.0825 0.0911 0.0934 ...
## ..$ AGE_52 : num [1:114] 0.0756 0.0811 0.0774 0.0824 0.0771 ...
## ..$ AGE_57 : num [1:114] 0.0615 0.0662 0.0623 0.0698 0.0716 ...
## ..$ AGE_62 : num [1:114] 0.0541 0.0577 0.0549 0.0546 0.0552 ...
## ..$ AGE_67 : num [1:114] 0.0481 0.0529 0.0456 0.0459 0.0404 ...
## ..$ AGE_72 : num [1:114] 0.0473 0.0506 0.045 0.0451 0.0404 ...
## ..$ AGE_77 : num [1:114] 0.0265 0.0283 0.0234 0.0319 0.026 ...
## ..$ AGE_82 : num [1:114] 0.0222 0.0222 0.0181 0.0244 0.0204 ...
## ..$ AGE_87 : num [1:114] 0.01236 0.01352 0.00991 0.01099 0.00687 ...
## ..$ AGE_92 : num [1:114] 0.00448 0.00543 0.00362 0.00341 0.00242 ...
## $ ages : num [1:19] 2 7 12 17 22 27 32 37 42 47 ...
## $ distributions: num [1:114, 1:19] 0.0517 0.0515 0.0532 0.0479 0.0512 ...
## ..- attr(*, "dimnames")=List of 2
## .. ..$ : chr [1:114] "Linz_male" "Steyr_male" "Wels_male" "Altheim_male" ...
## .. ..$ : chr [1:19] "AGE_2" "AGE_7" "AGE_12" "AGE_17" ...The dataset contains:
data: A data frame with commune codes, sex, and
proportions for 19 age groupsages: The midpoint ages of each 5-year age group (2, 7,
12, …, 92)distributions: A matrix where each row is an age
distribution (sums to 1)# Number of observations
nrow(age$data)
## [1] 114
# The age midpoints
age$ages
## [1] 2 7 12 17 22 27 32 37 42 47 52 57 62 67 72 77 82 87 92
# First few rows of the data
head(age$data[, 1:7])
## commune municipality sex AGE_2 AGE_7 AGE_12
## 1 4010 Linz male 0.05167582 0.04464853 0.04522866
## 2 4020 Steyr male 0.05147977 0.04762422 0.04778713
## 3 4030 Wels male 0.05318663 0.05388969 0.05480367
## 4 4040 Altheim male 0.04785847 0.04643079 0.05425202
## 5 4041 Gilgenberg am Weilhart male 0.05120178 0.05322157 0.05524137
## 6 4042 Maria Schmolln male 0.04995852 0.05198636 0.05281593
## AGE_17
## 1 0.05071258
## 2 0.06120011
## 3 0.06422470
## 4 0.06511484
## 5 0.06493638
## 6 0.06728731
# Check that distributions sum to 1
summary(rowSums(age$distributions))
## Min. 1st Qu. Median Mean 3rd Qu. Max.
## 1 1 1 1 1 1Before performing PCA, let’s visualize the age distributions:
# Plot all distributions with transparency, plus the mean
matplot(age$ages, t(age$distributions), type = "l", lty = 1,
col = adjustcolor("gray", alpha.f = 0.4),
xlab = "Age", ylab = "Proportion",
main = "All Age Distributions with Mean")
# Add the mean distribution
meanDist <- colMeans(age$distributions)
lines(age$ages, meanDist, col = "black", lwd = 3)
legend("topright", c("Individual", "Mean"),
col = c("gray", "black"), lwd = c(1, 3))We can also visualize the distributions separately by sex:
# Separate by sex
maleIdx <- which(age$data$sex == "male")
femaleIdx <- which(age$data$sex == "female")
# Plot all distributions colored by sex
matplot(age$ages, t(age$distributions[maleIdx, ]), type = "l",
lty = 1, col = adjustcolor("blue", alpha.f = 0.3),
ylim = c(0, 0.1), xlab = "Age", ylab = "Proportion",
main = "Age Distributions by Sex")
matlines(age$ages, t(age$distributions[femaleIdx, ]),
lty = 1, col = adjustcolor("red", alpha.f = 0.3))
# Add means for each sex
lines(age$ages, colMeans(age$distributions[maleIdx, ]), col = "blue", lwd = 3)
lines(age$ages, colMeans(age$distributions[femaleIdx, ]), col = "red", lwd = 3)
legend("topright", c("Male", "Female"), col = c("blue", "red"), lwd = 3)To perform PCA, we need to convert our data into the “BData” format used by the package. This involves:
Age distributions have support on [0, 100] (approximately), so we use a uniform reference measure on this interval:
# Reference measure: Uniform on [0, 100]
rmAge <- ReferenceMeasureUniform(0, 100)
# Create the Bayes space
B2Age <- BayesSpace(rmAge)
B2Age
## BayesSpace with reference measure:ReferenceMeasureUniform(a = 0, b = 100)
# Create a basis with 7 dimensions
# (this provides a good balance between flexibility and parsimony)
basisAge <- BayesSpaceBasis(B2Age, nbasis = 7)
basisAge
## BayesSpaceBasis.ReferenceMeasureUniform(basis = basis$referenceMeasure,
## bayesSpace = basis, nbasis = 7)The age distributions are given as proportions in 5-year age bins. We treat these as density histograms and convert them through the pipeline: histograms -> densities -> splines
# Define the histogram breaks
# Note: The last bin (90-100) is 10 years wide, others are 5 years
breaks <- c(0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50,
55, 60, 65, 70, 75, 80, 85, 90, 100)
# Create density histograms from the proportions
ageHist <- BDataDensityHistograms(age$distributions,
breaks = breaks,
basis = basisAge)
class(ageHist)
## [1] "BDataDensityHistograms" "BDataHistograms" "BData"
dim(ageHist)
## [1] 114 19
# Convert to continuous densities
ageDensities <- as.BDataDensities(ageHist)
# Convert to spline representation (needed for PCA)
ageSplines <- as.BDataSplines(ageDensities)
class(ageSplines)
## [1] "BDataSplines" "BData"
dim(ageSplines)
## [1] 114 7Let’s check that our conversion worked correctly:
# Plot all distributions as PDFs with transparency
plot(ageSplines, as = "PDF", col = adjustcolor("gray", alpha.f = 0.4),
main = "All Age Distributions (as PDFs) with Mean")
# Add the mean distribution
meanAge <- mean(ageSplines)
plot(meanAge, as = "PDF", add = TRUE, col = "black", lwd = 3)
legend("topright", c("Individual", "Mean"), col = c("gray", "black"), lwd = c(1, 3))# Plot by sex with transparency
maleSplines <- BDataSplines(unclass(ageSplines)[maleIdx, ], basis = basisAge)
femaleSplines <- BDataSplines(unclass(ageSplines)[femaleIdx, ], basis = basisAge)
plot(maleSplines, as = "PDF", col = adjustcolor("blue", alpha.f = 0.3),
main = "Age Distributions by Sex (as PDFs)")
plot(femaleSplines, as = "PDF", col = adjustcolor("red", alpha.f = 0.3), add = TRUE)
# Add sex-specific means
plot(mean(maleSplines), as = "PDF", add = TRUE, col = "blue", lwd = 3)
plot(mean(femaleSplines), as = "PDF", add = TRUE, col = "red", lwd = 3)
legend("topright", c("Male", "Female"), col = c("blue", "red"), lwd = 3)Now we can perform PCA on our distributional data. The syntax is
identical to R’s classical princomp() function - simply
pass the “BData” object:
pcaAge <- princomp(ageSplines)
pcaAge
## Call:
## princomp(x = ageSplines)
##
## Standard deviations:
## Comp.1 Comp.2 Comp.3 Comp.4 Comp.5 Comp.6 Comp.7
## 0.23002255 0.03460215 0.02770435 0.02165770 0.01681350 0.01309618 0.01272954
##
## PC1 explains 94.5 % of the variance, PC1-2 explain 96.7 %
## Basis dimension: 7
## Number of observations: 114
## Basis: non-orthonormal (Gram matrix correction applied)Behind the scenes, R dispatches to the princomp.BData()
method, which:
Extracts the spline coefficients from the “BDataSplines” object
Centers the coefficient matrix
Computes the covariance matrix and its eigendecomposition
Returns a “princomp.BData” object that extends the standard “princomp” class
The output shows familiar PCA information (standard deviations, variance explained) plus additional details specific to distributional data:
The summary() method works just like for standard
PCA:
summary(pcaAge)
## Importance of components:
## Comp.1 Comp.2 Comp.3 Comp.4 Comp.5 Comp.6 Comp.7
## Standard deviation 0.230 0.035 0.028 0.022 0.017 0.013 0.013
## Proportion of Variance 0.945 0.021 0.014 0.008 0.005 0.003 0.003
## Cumulative Proportion 0.945 0.967 0.981 0.989 0.994 0.997 1.000The summary shows:
The plot() method for “princomp.BData” objects provides
several plot types for exploring and interpreting PCA results. The
following table summarizes all available options:
| Type | Description |
|---|---|
"screeplot" |
Bar chart of variance explained by each PC; helps decide how many components to retain |
"variance" |
Cumulative variance plot with reference lines at 80%, 90%, 95% |
"scores" |
Scatter plot of observations in PC space; reveals clusters and outliers |
"loadings" |
Shows how distribution shape changes along each PC direction |
"biplot" |
Combines scores and loadings in one plot |
"covariance" |
Heatmap of functional covariance surface across the domain |
"reconstruction" |
Distributions reconstructed from a reduced number of PCs |
"boxplot" |
Boxplots of score distributions for each PC |
The screeplot helps decide how many principal components are meaningful:
Interpretation: Look for an “elbow” where the variance drops off. Components before the elbow capture the main structure in the data. Here, PC1 captures most of the variance, with PC2 adding some additional information.
Interpretation: The dashed lines show common thresholds (80%, 90%, 95%). This helps decide how many components to retain based on a target variance explained.
The score plot shows each distribution as a point in PC space:
A key question is whether male and female distributions differ systematically:
# Create color vector
sexColors <- ifelse(age$data$sex == "male", "blue", "red")
# Score plot colored by sex
plot(pcaAge, type = "scores", col = sexColors)
legend("topright", c("Male", "Female"), col = c("blue", "red"), pch = 19)Interpretation: If males and females separate along PC1, this suggests that the primary mode of variation in age distributions captures sex differences. This makes sense because women tend to live longer, creating different proportions in older age groups.
To identify specific distributions, we can display labels instead of
points. With labels = TRUE, the row names are used - in
this case, the commune codes (e.g., “4101”, “4102”, etc.) combined with
sex:
# Use row names as labels (commune codes)
plot(pcaAge, type = "scores", labels = TRUE, col = sexColors, cex = 0.7)Interpretation: The labels allow us to identify which communes have unusual age structures. Communes appearing far from the center or separated from the main cluster may have distinctive demographic characteristics - for example, urban centers with younger populations or rural areas with aging communities. Pairs of labels (same commune code, different colors) that are far apart indicate communes where male and female age distributions differ substantially.
This is the most important plot for understanding what PCA reveals.
The loadings plot shows how the distribution shape changes along each principal component direction. For each PC, two perturbations of the mean distribution are shown:
where sdev is the standard deviation (square root of the
eigenvalue) for that PC, and loading is the unit-length
eigenvector. By default, scale.sdev = 1, so the
perturbations represent distributions that are ±1 standard deviation
away from the mean along each PC direction.
# Use colorblind-friendly colors: black (mean), blue (PC1), orange (PC2)
cbPalette <- c("black", "blue", "orange")
plot(pcaAge, type = "loadings", npcs = 2, col = cbPalette)How to read this plot:
Detailed interpretation for the age data:
PC1 (blue lines) captures the dominant mode of variation:
Since males and females separate along PC1 (as seen in the score plot), PC1 essentially captures the sex difference in age structure - primarily driven by women’s longer life expectancy creating higher proportions in older age groups.
PC2 (orange lines) captures the second mode of variation:
PC2 likely captures demographic differences between communes - for example, urban areas attracting young adults (negative PC2) versus suburban/rural areas with established families and middle-aged residents (positive PC2).
The scale.sdev parameter controls how far from the mean
the perturbed distributions are shown. By default
(scale.sdev = 1), the plot displays distributions at ±1
standard deviation along each PC direction - this represents a “typical”
deviation from the mean.
To see more extreme perturbations, increase
scale.sdev:
# Show +/- 2 standard deviations from mean
plot(pcaAge, type = "loadings", npcs = 2, scale.sdev = 2, col = cbPalette)When to adjust the scale:
scale.sdev = 2 or higher when the differences
between solid and dashed lines are subtle and hard to see at the default
scalescale.sdev = 0.5 if the perturbations look
unrealistically extremeThe loadings can be displayed in different forms using the
as parameter. Each representation emphasizes different
aspects of the distributions:
as |
Description |
|---|---|
"PDF" |
Probability density function (default) - shows proper densities |
"logPDF" |
Log of PDF - emphasizes differences in the tails |
"clr" |
Centered log-ratio - the natural Bayes space representation |
"density" |
Density with respect to the reference measure |
"logDensity" |
Log density with respect to reference measure |
The CLR (centered log-ratio) representation is particularly useful because it is the representation in the subspace \(L^2_0\) of zero-integral square integrable log-density functions, a subspace isometric to the Bayes space - and hence the set where PCA is actually performed:
# Centered log-ratio (CLR) representation
plot(pcaAge, type = "loadings", npcs = 2, as = "clr", col = cbPalette)Interpretation: In the CLR representation, values are centered around zero. Positive values indicate age groups with higher-than-geometric-mean proportions; negative values indicate lower-than-geometric-mean proportions. This makes it easy to see which parts of the age range increase or decrease along each PC direction.
The covariance surface shows which parts of the age range co-vary:
Interpretation:
How well do the first few PCs approximate the original data?
# Reconstruct using only 2 PCs with transparency
sexColorsTransp <- ifelse(age$data$sex == "male",
adjustcolor("blue", alpha.f = 0.3),
adjustcolor("red", alpha.f = 0.3))
plot(pcaAge, type = "reconstruction", npcs = 2, col = sexColorsTransp)Compare this to the original distributions. If the reconstructed curves look similar to the originals, 2 PCs capture most of the structure. With only 2 PCs, some detail is lost, but the main patterns (especially the male/female separation) are preserved.
To see how the PC scores vary across all age distributions:
This helps identify potential outliers (points beyond the whiskers) and compare the spread along each PC direction.
The biplot combines scores and loadings in one visualization:
Interpretation:
For functional interpretation, the loadings plot
(type = "loadings") is more informative.
When comparing groups (e.g., males vs females), we can perform PCA separately for each group:
pcaGrouped <- princomp(ageSplines, group = age$data$sex)
pcaGrouped
## Grouped PCA on distributional data
## Groups: male, female
##
## --- Group: male ---
## Observations: 57
## PC1 explains: 79.8 % of variance
## --- Group: female ---
## Observations: 57
## PC1 explains: 80.2 % of varianceDo males and females have similar dimensionality of variation?
Are the modes of variation the same for both groups?
Interpretation: If the loadings look similar across groups, both sexes vary in similar ways. If they differ, each group has distinct patterns of age distribution variation.
The grouped PCA above performs separate PCA for each group. But what if we want to analyze how male and female age distributions jointly vary across communes? For example, communes where males have older age structures might also have females with older age structures - this covariation between groups is not captured by separate PCAs.
Multivariate SFPCA (also called 2D-SFPCA, following Hron et al. 2016) solves this by:
This requires paired observations: the same index must correspond to the same subject (commune) across groups. In our data, each commune has both a male and female age distribution - these are paired.
Simply add multivariate = TRUE to the
princomp() call:
pcaMulti <- princomp(ageSplines, group = age$data$sex, multivariate = TRUE)
pcaMulti
## Multivariate SFPCA (2D-SFPCA) on distributional data
## Groups: male, female
## Observations per group: 57
## Coefficients per group: 7
## Total dimensions: 14
## Note: Non-orthonormal basis, Gram matrix correction applied
##
## Importance of components:
## PC1 PC2 PC3 PC4 PC5
## Std. Dev. 0.1352 0.0642 0.0419 0.0303 0.0256
## Prop. Var. 0.6703 0.1510 0.0644 0.0337 0.0240
## Cum. Prop. 0.6703 0.8213 0.8857 0.9194 0.9434The output shows:
Interpretation: This shows the total variance explained across both male and female distributions. Compare this to the separate grouped PCA - the joint analysis typically explains variance differently because it captures cross-group covariation.
Interpretation: Each point represents a commune (not a single distribution). Communes close together have similar age structures for both males and females. Outliers are communes with unusual joint age patterns.
This is the key plot for interpretation. It shows how distributions change along each PC, with separate panels for each group:
How to read this plot:
Interpretation for age data:
| Approach | Use when… |
|---|---|
Separate (multivariate = FALSE) |
You want to compare modes of variation between groups |
Joint (multivariate = TRUE) |
You have paired data and want to analyze cross-group covariation |
Example questions:
If you have new age distributions, you can project them onto the existing PC space without re-running PCA:
# Suppose we have the first 5 observations as "training" data
# and want to project observations 6-10 as "new" data
# Create a subset as new BDataSplines (subsetting keeps the class)
newAgeSplines <- BDataSplines(unclass(ageSplines)[6:10, ], basis = basisAge)
# The pcaAge model was fit on all data, but let's demonstrate predict():
newScores <- predict(pcaAge, newAgeSplines)
print(newScores[, 1:3]) # Show first 3 PCs
## Comp.1 Comp.2 Comp.3
## Maria Schmolln_male -0.1564309 -0.043924789 0.003360579
## Perwang am Grabensee_male -0.2156627 0.001655020 -0.029175939
## St. Veit im Innkreis_male -0.2338532 0.019797560 -0.023275240
## Alkoven_male -0.1688755 -0.018438557 -0.008804513
## St. Marienkirchen an der Polsenz_male -0.3137905 0.003724351 -0.038584233
# Compare with the actual scores
actualScores <- pcaAge$scores[6:10, 1:3]
print(actualScores)
## Comp.1 Comp.2 Comp.3
## Maria Schmolln_male -0.1564309 -0.043924789 0.003360579
## Perwang am Grabensee_male -0.2156627 0.001655020 -0.029175939
## St. Veit im Innkreis_male -0.2338532 0.019797560 -0.023275240
## Alkoven_male -0.1688755 -0.018438557 -0.008804513
## St. Marienkirchen an der Polsenz_male -0.3137905 0.003724351 -0.038584233
# They should be identical (since we used the same data)In practice, predict() is useful when:
Here’s a concise workflow for PCA on distributional data:
# 1. Load data
data("age")
# 2. Create Bayes space and basis
rmAge <- ReferenceMeasureUniform(0, 100)
basisAge <- BayesSpaceBasis(BayesSpace(rmAge), nbasis = 7)
# 3. Convert to BData (adapt based on your data format)
breaks <- c(0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50,
55, 60, 65, 70, 75, 80, 85, 90, 100)
ageSplines <-
BDataDensityHistograms(age$distributions,
breaks = breaks,
basis = basisAge) |>
as.BataDensities() |>
as.BDataSplines() # pipe |> instead of nesting
# 4. Run PCA
pcaResult <- princomp(ageSplines)
# 5. Explore results
summary(pcaResult) # Variance explained
plot(pcaResult, type = "screeplot") # How many components?
plot(pcaResult, type = "loadings", npcs = 2) # What do components mean?
plot(pcaResult, type = "scores", col = myColors) # Where are my observations?
# 6. Optional: Separate grouped analysis (compare groups)
pcaGrouped <- princomp(ageSplines, group = age$data$sex)
plot(pcaGrouped, type = "loadings", npcs = 2) # Compare groups
# 7. Optional: Multivariate SFPCA (joint analysis, requires paired data)
pcaMulti <- princomp(ageSplines, group = age$data$sex, multivariate = TRUE)
plot(pcaMulti, type = "eigenfunctions", npcs = 2) # Cross-group covariationChoose the right basis dimension: Too few dimensions may lose important variation; too many may introduce noise. Start with 5-7 and adjust based on how well reconstructions match originals.
Use the loadings plot: The
type = "loadings" plot is the key to interpreting what each
PC means in terms of distribution shape.
Consider the reference measure: The reference
measure should match your data’s support. Use
ReferenceMeasureUniform(a, b) for bounded data on [a,
b].
Check variance explained: If PC1 explains nearly 100% of variance, your data may vary primarily in one characteristic. This is not a bug but a property of your data.
Use grouped PCA for comparison: When comparing subgroups, grouped PCA reveals whether they have the same or different modes of variation.
sessionInfo()
## R version 4.3.3 (2024-02-29)
## Platform: x86_64-pc-linux-gnu (64-bit)
## Running under: Ubuntu 24.04.4 LTS
##
## Matrix products: default
## BLAS: /usr/lib/x86_64-linux-gnu/blas/libblas.so.3.12.0
## LAPACK: /usr/lib/x86_64-linux-gnu/lapack/liblapack.so.3.12.0
##
## locale:
## [1] LC_CTYPE=en_US.UTF-8 LC_NUMERIC=C
## [3] LC_TIME=en_ZA.UTF-8 LC_COLLATE=C
## [5] LC_MONETARY=en_ZA.UTF-8 LC_MESSAGES=en_US.UTF-8
## [7] LC_PAPER=en_ZA.UTF-8 LC_NAME=C
## [9] LC_ADDRESS=C LC_TELEPHONE=C
## [11] LC_MEASUREMENT=en_ZA.UTF-8 LC_IDENTIFICATION=C
##
## time zone: Europe/Berlin
## tzcode source: system (glibc)
##
## attached base packages:
## [1] splines stats graphics grDevices utils datasets methods
## [8] base
##
## other attached packages:
## [1] igraph_2.3.3 ggraph_2.2.2 ggplot2_4.0.2
## [4] DistributionalDDA_0.1.1 compositions_2.0-9
##
## loaded via a namespace (and not attached):
## [1] gtable_0.3.6 tensorA_0.36.2.1 xfun_0.56 bslib_0.10.0
## [5] ks_1.14.2 ggrepel_0.9.5 lattice_0.22-5 vctrs_0.7.1
## [9] tools_4.3.3 bitops_1.0-7 generics_0.1.3 tibble_3.3.1
## [13] DEoptimR_1.1-3 cluster_2.1.6 pkgconfig_2.0.3 fds_1.8
## [17] Matrix_1.6-5 KernSmooth_2.23-22 RColorBrewer_1.1-3 S7_0.2.1
## [21] lifecycle_1.0.5 compiler_4.3.3 farver_2.1.1 ggforce_0.4.1
## [25] graphlayouts_1.2.4 htmltools_0.5.9 rainbow_3.8 sass_0.4.10
## [29] RCurl_1.98-1.14 yaml_2.3.12 pracma_2.4.4 pillar_1.11.1
## [33] jquerylib_0.1.4 tidyr_1.3.1 MASS_7.3-60.0.1 cachem_1.1.0
## [37] viridis_0.6.5 mclust_6.0.1 robustbase_0.99-2 tidyselect_1.2.1
## [41] digest_0.6.39 mvtnorm_1.2-4 dplyr_1.1.4 purrr_1.2.1
## [45] labeling_0.4.3 polyclip_1.10-6 pcaPP_2.0-4 fastmap_1.2.0
## [49] grid_4.3.3 colorspace_2.1-0 cli_3.6.5 magrittr_2.0.4
## [53] dichromat_2.0-0.1 tidygraph_1.3.1 withr_3.0.2 scales_1.4.0
## [57] rmarkdown_2.25 otel_0.2.0 gridExtra_2.3 deSolve_1.40
## [61] memoise_2.0.1 evaluate_1.0.5 knitr_1.51 viridisLite_0.4.2
## [65] rlang_1.1.7 Rcpp_1.1.1 glue_1.8.0 fda_6.3.0
## [69] bayesm_3.1-6 hdrcde_3.4 tweenr_2.0.2 rstudioapi_0.18.0
## [73] jsonlite_2.0.0 R6_2.6.1