Structural Zeros in Correlation Matrices: A Twelve-Parameterization Bake-Off
stan
correlation
parameterizations
Author
Sean Pinkney
Published
July 15, 2026
Modified
July 25, 2026
1. The Problem with Zeros in Correlation Matrices
Sometimes you know, before seeing any data, that certain correlations are exactly zero. For example, a priori knowledge that two variables are marginally1 unrelated occurs in graphical models as either an explicit assumption or by pre-existing knowledge that rules out the variable coupling. Building this structure directly through the correlation matrix parameterization is what this blog post is about.
Sampling from an LKJ prior and setting selected cells to zero does not work because the resulting matrix will generally not be positive definite. Instead, the zeros have to be part of the parameterization. In this post I compare twelve Stan (Carpenter et al. 2017) implementations that target the same distribution. I check that they agree numerically, then compare divergences, effective sample size, gradient evaluations, and run time.
The implementations fall into two groups. The first solves for individual Cholesky entries so that the requested correlations are zero. The second samples each row in the subspace satisfying all of its zero constraints. I also introduce a triangular basis for the second approach. In these examples it removes the divergences seen in the entry-wise construction without the loss of efficiency I found with a QR basis.
2. Family 1: Forced Zeros
The first group uses the constrained-Cholesky approach I introduced in Pinkney (2024a) and presented at StanCon (Pinkney 2024b). The original method also allows a correlation to be fixed at a nonzero value or restricted to an interval. Writing \(\Omega = L L'\) with unit-norm rows of \(L\), the correlation in cell \((i,j)\) is \[
\Omega_{ij} = b_1 + L_{ij} L_{jj}, \qquad
b_1 = \sum_{k<j} L_{ik} L_{jk},
\] This is affine in \(L_{ij}\), so setting the correlation to zero gives \(L_{ij}=-b_1/L_{jj}\) and uses no parameter. A bound on \(\Omega_{ij}\) similarly becomes a bound on \(L_{ij}\). Conditioning on \(\Omega_{ij}=0\) contributes \(-\log L_{jj}\) to the log density for each zero. The carpenter variant below is Bob Carpenter’s compact formulation of this case from a Stan Discourse discussion:
canonical partial correlations, \(L_{ij} = r \tanh(x)\)
These variants share the same constraint. The value \(-b_1/L_{jj}\) is computed without reference to the remaining row norm, \(\sqrt{1 - \sum_{k<j} L_{ik}^2}\), and can exceed the available range. A later square root then receives a negative argument. In the \(K=7\) benchmarks below, each variant has divergences in about 1% of post-warmup transitions.
The three LDL variants are also useful for judging Monte Carlo variability. They define the same density and differ only in how some floating-point operations are arranged, but their total divergences over five patterns range from 356 to 466. Differences of that size should not be interpreted as evidence that one parameterization is better from a single set of fits.
3. Family 2: Exact-Constraint Subspaces
The second approach comes from Seth Axen’s Turing.jl discussion of positive definite matrices with structural zeros (Axen 2023):
For each column j, each column i for i < j that is orthogonal to column j decreases the dimensionality of the hemisphere by 1. So we simply construct a point on a lower dimensional hemisphere and use the QR decomposition of the orthogonal columns to lift it to a higher dimensional hemisphere.
Row \(i\) of a correlation Cholesky factor is a unit vector with a positive diagonal element. Since row \(j\) is zero after column \(j\), the condition \(\Omega_{ij}=0\) is equivalent to requiring rows \(i\) and \(j\) to be orthogonal. If row \(i\) has \(n_z\) constrained correlations, it can be sampled on the unit hemisphere in the orthogonal complement of those \(n_z\) rows. The dimension of this hemisphere is \(d=i-1-n_z\). The joint conditioning term for the row is \(-\tfrac12\log\det(A'A)\), where the columns of \(A\) are the rows to which it must be orthogonal.
My first two implementations, qr and qr2, construct this complement with a Householder QR decomposition. They use either a gnomonic map or the hyper-tanh peel (Pinkney 2026) for the hemisphere coordinates. Both models have zero divergences in the \(K=7\) benchmark, but their minimum ESS is about 40% of the entry-wise models. Changing the hemisphere map has little effect. The larger difference comes from the complement basis: the trailing columns of \(Q\) change as the preceding rows change, so the interpretation of the unconstrained coordinates also changes during sampling.
4. A Canonical Triangular Basis
I instead construct a basis tied to the free matrix entries. For each free column \(k\) of row \(i\), I start with the coordinate vector \(e_k\) and solve for the constrained columns by back-substitution. This is the entry-wise construction from the previous section applied to a basis vector rather than to the sampled row. If \(Z\) and \(F\) denote the constrained and free columns, the resulting basis matrix \(T\) satisfies \(T_F=I\) and \(T_Z=-(A_Z')^{-1}A_F'\).
To orthonormalize this basis, let \(S=\operatorname{chol}(T'T)\). Then \(v=T(S')^{-1}z\) preserves the norm of \(z\). I parameterize \(z\) using the row transformation from Pinkney (2025). With \(t=y'y\), \[
z = \frac{\sqrt{t+2}}{t+1}y, \qquad L_{ii}=\frac{1}{t+1},
\] and the log Jacobian is \(\tfrac12(d-2)\log(t+2)-(d+1)\log(1+t)\).
Two identities reduce the amount of linear algebra required.
The zero-partner rows have triangular support, so \(A_Z\) is triangular with the partners’ diagonals on its diagonal. Sylvester’s determinant identity gives \[
\det(A'A) = \Big(\prod_{j \in Z} L_{jj}\Big)^{2} \det(T'T).
\] Thus \(-\tfrac12\log\det(A'A)\) becomes \(-\sum_j\log L_{jj}-\sum_k\log S_{kk}\). The second term is available from the Cholesky factor already used to orthonormalize \(T\).
Since \(T_F=I\), \(T'T=I+W'W\), where \(W\) contains only the \(n_z\) constrained rows. The cross-product therefore uses vectors of length \(n_z\) rather than \(i-1\).
Each constrained row requires one \(d\times d\)cholesky_decompose and several matrix-vector products. I call this model tri; the Stan code is in the appendix.
I also test an over-parameterized version, called donut, that uses one additional parameter per row. It normalizes a free vector, \(u=y/\lVert y\rVert\), before mapping it into the constrained subspace. The normalization is rotationally symmetric but singular at the origin. Following Axen (2022), I replace the usual radial term \(-\tfrac12\lVert y\rVert^2\) with \(-\tfrac12(\lVert y\rVert-m)^2\) so that the typical radius is away from zero. Since this term only changes the radius, it does not change the induced distribution on \(\Omega\). The largest difference in a free-cell posterior mean relative to carpenter is 0.0046. Across the five \(K=7\), \(\eta=4\) patterns:
radial prior
total divergences
median min ESS
median ESS / 1k gradients
\(m = 0\) (implicit chi)
2,549
4,778
19
\(m = 2\)
349
8,106
80
\(m = 4\)
8
10,778
192
\(m = 6\)
0
9,045
162
Increasing \(m\) removes the divergences and improves efficiency up to \(m=4\); \(m=6\) gives zero divergences in these runs. I use \(m=6\) below. The tri model does not require this additional choice and uses \(K-1\) fewer parameters.
5. The Bake-Off at K = 7
Setup: \(K = 7\), \(\eta = 4\), prior-only, five random zero patterns with roughly half of the 21 lower-triangular cells zeroed, 4 chains \(\times\) 2000 draws after 1000 warmup, default adapt_delta = 0.8. Correctness was checked on a held-out pattern first: all twelve posteriors match the carpenter baseline within Monte Carlo error, and the constrained cells come back as numerically exact zeros (\(\sim 10^{-19}\)).
Divergences out of 8,000 post-warmup transitions:
pattern
carpenter
pinkney
ldl
ldl2
cvine
rowscale
ldl3
schur
qr
qr2
tri
donut
seed 1
34
53
53
26
41
44
40
40
0
0
0
0
seed 2
61
65
74
107
64
78
104
60
0
0
0
0
seed 3
77
44
67
57
69
44
68
59
0
0
0
0
seed 4
137
157
130
112
186
155
152
123
0
0
0
0
seed 5
110
94
114
54
141
114
102
29
0
0
0
0
total
419
413
438
356
501
435
466
311
0
0
0
0
Min bulk ESS over the free cells of \(\Omega\) (8,000 draws):
pattern
carpenter
pinkney
ldl
ldl2
cvine
rowscale
ldl3
schur
qr
qr2
tri
donut
seed 1
11639
10566
9979
10618
10219
11319
9840
11036
3995
3362
11238
10676
seed 2
10817
9866
10826
9772
10188
10153
10361
10040
4049
3728
11923
9045
seed 3
9344
11014
10244
10314
10946
9420
10625
10787
4334
4304
10655
9429
seed 4
8395
9159
8326
9257
8771
9463
8266
9458
3701
3815
9400
8161
seed 5
10381
10010
9268
8760
10301
9218
9896
4770
4267
4153
11845
8619
median
10381
10010
9979
9772
10219
9463
9896
10040
4049
3815
11238
9045
Efficiency (medians over the five patterns):
carpenter
pinkney
ldl
ldl2
cvine
rowscale
ldl3
schur
qr
qr2
tri
donut
min ESS / 1000 gradients
188.5
179.6
185.9
182.2
188.9
176.3
182.1
182.0
75.0
72.5
212.3
161.8
total wall clock, 5 fits (s)
4.53
4.10
4.40
4.51
5.14
4.21
4.51
4.15
2.07
2.06
1.86
2.39
The tri model has zero divergences for all five patterns. It also has the highest median minimum ESS, the highest median ESS per 1,000 gradients, and the shortest total run time. The QR models also remove divergences, but their changing complement basis produces substantially lower ESS. The triangular basis avoids that loss in this benchmark.
6. Does It Hold Up? Scaling K and \(\eta\)
I next repeat the comparison for \(K\in\{5,7,9,15,25\}\) and \(\eta\in\{1,2,3,4\}\). This sweep includes six entry-wise models, qr2, tri, and the donut model with \(m=6\). For each \(K\), I generate one Bernoulli(0.5) zero pattern and reuse it for all four values of \(\eta\). The realized number of zeros ranges from 6 of 10 cells at \(K=5\) to 143 of 300 at \(K=25\). Sampler settings are unchanged. There is one fit per configuration, so small differences should be interpreted with the variability from Section 2 in mind.
Figure 1: Divergence rate by matrix dimension and LKJ concentration. Each thin gold line is one entry-wise parameterization; missing points indicate initialization failures. The tri model has zero divergences in every panel. For qr2 at K = 15 and eta = 2, one chain has 100% divergences. The donut model remains below 1.5% at eta = 1 and has zero divergences otherwise.
Figure 2: Sampling efficiency (min bulk ESS over free correlations per 1,000 gradient evaluations, log scale) by dimension and concentration.
Outright failures: initialization could not find a feasible point after 100 attempts per chain.
model
K = 25
K = 15
K = 7
K = 9
carpenter
4 of 4 etas
—
—
—
cvine
4 of 4 etas
4 of 4 etas
—
—
ldl2
4 of 4 etas
—
—
—
ldl3
4 of 4 etas
—
—
—
pinkney
4 of 4 etas
—
—
—
schur
4 of 4 etas
4 of 4 etas
4 of 4 etas
4 of 4 etas
At \(\eta=1\), the entry-wise models have 13–17% divergences for \(K=5\) and 85–94% for \(K=7\) and \(K=9\). The uniform LKJ prior places more mass near the part of the parameter space where a solved Cholesky entry can exceed the remaining row norm. Four curves coincide in the \(\eta=1\) panel because carpenter, pinkney, ldl2, and ldl3 have the same density in their raw coordinates until floating-point differences separate their trajectories.
Initialization also becomes difficult for the entry-wise models. For these particular patterns, schur fails to initialize from \(K=7\) onward and cvine fails at \(K=15\). At \(K=25\), none of the six entry-wise models finds a feasible initial value in 100 attempts per chain. This depends on the zero pattern—schur initialized for every pattern in Section 5—but the problem becomes more likely as the number of constraints increases. Every initial value in the subspace models satisfies the zero constraints by construction.
The tri model has zero divergences in all 20 configurations. The donut model has a small number at \(\eta=1\) and none for \(\eta\ge2\). Through \(K=15\), the minimum bulk ESS for tri remains near the number of posterior draws. At \(K=25\), the minimum is taken over 157 free correlations and ranges from about 5,100 to 6,600 for tri. The donut model ranges from about 4,700 to 6,600. Both complete in seconds.
Code
sub_tab <- sweep |>filter(model %in%c("tri", "donut")) |>mutate(cell =sprintf("%s (%s)%s",format(round(min_ess), big.mark =","),round(ess_per_1k_grad),ifelse(divergences >0,paste0(" [", divergences, " div]"), "")))sub_wide <- sub_tab |>select(model, K, N_zero, eta, cell) |>pivot_wider(names_from = eta, values_from = cell,names_prefix ="eta = ") |>left_join( sub_tab |>group_by(model, K) |>summarize(`wall clock (s)`=sprintf("%.1f-%.1f",min(seconds), max(seconds)),.groups ="drop"),by =c("model", "K") ) |>mutate(`zero / free cells`=paste0(N_zero, " / ", choose(K, 2) - N_zero),.after = K) |>select(-N_zero) |>arrange(K, desc(model))knitr::kable( sub_wide, align =c("l", "r", "c", rep("c", 4), "c"),caption ="tri and the donut (m_radius = 6) across the full sweep: min bulk ESS of 8,000 draws (ESS per 1,000 gradients in parentheses; divergent transitions in brackets where nonzero -- tri has none anywhere). Wall clock is the range across eta for 4 parallel chains.")
tri and the donut (m_radius = 6) across the full sweep: min bulk ESS of 8,000 draws (ESS per 1,000 gradients in parentheses; divergent transitions in brackets where nonzero – tri has none anywhere). Wall clock is the range across eta for 4 parallel chains.
model
K
zero / free cells
eta = 1
eta = 2
eta = 3
eta = 4
wall clock (s)
tri
5
6 / 4
6,839 (127)
8,619 (183)
8,129 (192)
8,530 (204)
0.1-0.3
donut
5
6 / 4
2,364 (20) [23 div]
3,865 (66)
6,715 (120)
8,832 (172)
0.3-0.5
tri
7
7 / 14
6,922 (87)
7,572 (134)
10,480 (188)
10,832 (196)
0.4-0.5
donut
7
7 / 14
2,031 (19) [42 div]
5,796 (70)
7,128 (117)
8,430 (151)
0.5-0.8
tri
9
13 / 23
6,419 (87)
6,707 (118)
7,424 (132)
8,221 (147)
0.6-0.7
donut
9
13 / 23
5,315 (36) [54 div]
5,969 (74) [1 div]
6,977 (123)
6,834 (120)
0.7-1.5
tri
15
64 / 41
7,298 (67)
7,836 (134)
7,761 (127)
8,340 (149)
1.3-2.0
donut
15
64 / 41
5,046 (41) [99 div]
7,532 (79)
6,515 (82)
7,523 (122)
1.8-3.1
tri
25
143 / 157
5,081 (42)
5,837 (51)
5,742 (50)
6,553 (55)
7.0-8.9
donut
25
143 / 157
4,667 (29) [98 div]
5,547 (46) [2 div]
5,524 (46)
5,907 (50)
9.3-14.4
The QR basis remains feasible when the entry-wise models fail, but it becomes expensive as \(K\) grows. At \(K=25\), \(\eta=1\), qr2 takes 852 seconds compared with 8.9 seconds for tri. At \(K=15\), \(\eta=2\), one qr2 chain has 100% divergences and the minimum ESS is 7. The ldl3 model has a similar isolated failure at \(K=9\), \(\eta=2\), with a minimum ESS of 19. At \(K=25\), the minimum ESS for qr2 is about 3,500–4,100, compared with 5,100–6,600 for tri, and qr2 requires many more gradient evaluations. tri has the highest ESS per gradient in 17 of the 20 configurations; qr2 is higher in three of the \(K=5\) configurations.
7. When to Use Which
Scenario
Recommendation
Structural zeros only
Use tri
Structural zeros at very large \(K\)
Use tri, but profile the per-row \(d\times d\) Cholesky factorizations
Zeros plus other known correlation values
Use the entry-wise method (Pinkney 2024a, 2024b); tri currently handles zeros only
Zeros plus bounds on other correlations
Use the entry-wise method, which already supports bounded transforms (Pinkney 2024a, 2024b)
For structural zeros alone, I do not see a reason to prefer one of the entry-wise parameterizations. In these benchmarks tri is more reliable and at least as efficient. The entry-wise method remains useful because it handles more general constraints: correlations fixed at nonzero values and bounds on otherwise free correlations.
8. Discussion
The entry-wise construction solves each zero constraint but leaves the boundary of the feasible row norm in the parameter space. This produces divergences for the smaller examples and initialization failures for the larger ones. The subspace construction removes that boundary because every parameter value maps to a row satisfying the constraints.
The choice of basis is important. A complement obtained directly from QR is valid, but its coordinates change with the earlier rows and sampling becomes less efficient. The triangular basis fixes the free coordinates to matrix entries and only solves for the constrained coordinates. In these benchmarks, that gives zero divergences without sacrificing effective sample size or run time.
There are two limitations I have not addressed here. Adding bounds on otherwise free correlations changes the row’s feasible region from a sphere to the intersection of a sphere and a box. Also, the small Cholesky factorization performed for each constrained row should be profiled for matrices with dimensions in the hundreds.
Acknowledgments
The exact-constraint subspace idea, zeros as orthogonality constraints with the sample lifted from a lower-dimensional hemisphere through the orthogonal complement, including the \(\det R\) Jacobian factor, is due to Seth Axen (Axen 2023), whose radial prior (Axen 2022) the donut variant relies on. The tri model is that idea with a canonical triangular choice of complement basis and the determinant factored analytically. Thanks to Bob Carpenter for the compact zeros-only formulation benchmarked here as carpenter, and for the Discourse conversation that prompted this bake-off. The C-vine construction rests on the vine and partial-correlation results of Joe (2006) and Lewandowski et al. (2009).
Appendix: Stan Code for All Twelve Parameterizations
functions {int is_zero(int row_idx, int col_idx, array[,] int zeros, int zero_idx) {return zero_idx <=size(zeros)&& zeros[zero_idx, 1] == row_idx&& zeros[zero_idx, 2] == col_idx; }matrixcholesky_corr_constrain_outer_jacobian(int K, vector raw,array[,] int zeros) {matrix[K, K] L =rep_matrix(0, K, K);int raw_idx =1;int zero_idx =1; L[1, 1] =1;for (i in2:K) {// remaining radius of row i: sqrt(1 - sum of squares so far)real l_ij_old =1;for (j in1:(i -1)) {real l_ij_old_x_l_jj = l_ij_old * L[j, j];real b1 =dot_product(L[j, 1:(j -1)], L[i, 1:(j -1)]);// how to derive the bounds// we know that the correlation value C is bound by// b1 - Ljj * Lij_old <= C <= b1 + Ljj * Lij_old// Now we want our bounds to be enforced too so// max(lb, b1 - Ljj * Lij_old) <= C <= min(ub, b1 + Ljj * Lij_old)// We have Lij_new = (C - b1) / Ljjreal x;if (is_zero(i, j, zeros, zero_idx)) { x =-b1; // Omega[i, j] = b1 + Lij * Ljj = 0 zero_idx +=1; } else {real low =max({-l_ij_old_x_l_jj, -1- b1});real up =min({l_ij_old_x_l_jj, 1- b1}); x =lower_upper_bound_jacobian(raw[raw_idx], low, up); raw_idx +=1; } L[i, j] = x / L[j, j];// free entries: change of variables x -> L[i, j]// zero entries: Carpenter's correction for the LKJ over-adjustmentjacobian+=-log(L[j, j]); l_ij_old *=sqrt(1- (L[i, j] / l_ij_old)^2); } L[i, i] = l_ij_old; }return L; }}data {int<lower=2> K; // dimension of correlation matrixreal<lower=0> eta; // concentration in LKJ Choleskyint<lower=0, upper=choose(K, 2)> N_zero; // # structural zero correlationsarray[N_zero, 2] int zeros; // lower triangular, row major order}parameters {vector[choose(K, 2) - N_zero] raw;}transformed parameters {matrix[K, K] L_Omega =cholesky_corr_constrain_outer_jacobian(K, raw, zeros);}model { L_Omega ~ lkj_corr_cholesky(eta);}generated quantities {matrix[K, K] Omega = multiply_lower_tri_self_transpose(L_Omega);}
pinkney-corr-zeros-ldl.stan
functions {int is_zero(int row_idx, int col_idx, array[,] int zeros, int zero_idx) {return zero_idx <=size(zeros)&& zeros[zero_idx, 1] == row_idx&& zeros[zero_idx, 2] == col_idx; }matrixcholesky_corr_constrain_ldl_jacobian(int K, vector raw,array[,] int zeros) {// Omega = L * diag(D) * L' with unit lower-triangular L;// the Cholesky factor is diag_post_multiply(L, sqrt(D))matrix[K, K] L =diag_matrix(rep_vector(1, K));vector[K] D;vector[K] log_D;int raw_idx =1;int zero_idx =1; D[1] =1; log_D[1] =0;for (i in2:K) {// log remaining radius^2 of row i: log(1 - sum_{k<j} L[i,k]^2 * D[k])real log_r =0;for (j in1:(i -1)) {real b1 =dot_product(D[1:(j -1)]'.* L[j, 1:(j -1)], L[i, 1:(j -1)]);// Omega[i, j] = b1 + L[i, j] * D[j] and the correlation C satisfies// |C - b1| <= sqrt(D[j] * r), intersected with (-1, 1)real x;if (is_zero(i, j, zeros, zero_idx)) { x =-b1; // Omega[i, j] = b1 + Lij * Dj = 0 zero_idx +=1; } else {real b2 =exp(0.5* (log_r + log_D[j]));real low =max({-b2, -1- b1});real up =min({b2, 1- b1}); x =lower_upper_bound_jacobian(raw[raw_idx], low, up); raw_idx +=1; } L[i, j] = x / D[j];// free entries: change of variables x -> L_chol[i, j] = x / sqrt(D[j])// zero entries: Carpenter's correction -log(L_chol[j, j])jacobian+=-0.5* log_D[j]; log_r =log_diff_exp(log_r, log_D[j] +2*log(abs(L[i, j]))); } log_D[i] = log_r; D[i] =exp(log_r); }returndiag_post_multiply(L, sqrt(D)); }}data {int<lower=2> K; // dimension of correlation matrixreal<lower=0> eta; // concentration in LKJ Choleskyint<lower=0, upper=choose(K, 2)> N_zero; // # structural zero correlationsarray[N_zero, 2] int zeros; // lower triangular, row major order}parameters {vector[choose(K, 2) - N_zero] raw;}transformed parameters {matrix[K, K] L_Omega =cholesky_corr_constrain_ldl_jacobian(K, raw, zeros);}model { L_Omega ~ lkj_corr_cholesky(eta);}generated quantities {matrix[K, K] Omega = multiply_lower_tri_self_transpose(L_Omega);}
pinkney-corr-zeros-ldl2.stan
functions {int is_zero(int row_idx, int col_idx, array[,] int zeros, int zero_idx) {return zero_idx <=size(zeros)&& zeros[zero_idx, 1] == row_idx&& zeros[zero_idx, 2] == col_idx; }matrixcholesky_corr_constrain_ldl2_jacobian(int K, vector raw,array[,] int zeros) {// Omega = L * diag(D) * L' with unit lower-triangular L;// the Cholesky factor is diag_post_multiply(L, sqrt(D)).// Same as ldl version except the remaining radius is updated with// log1m of the ratio instead of log_diff_exp.matrix[K, K] L =diag_matrix(rep_vector(1, K));vector[K] D;vector[K] log_D;int raw_idx =1;int zero_idx =1; D[1] =1; log_D[1] =0;for (i in2:K) {// log remaining radius^2 of row i: log(1 - sum_{k<j} L[i,k]^2 * D[k])real log_r =0;for (j in1:(i -1)) {real b1 =dot_product(D[1:(j -1)]'.* L[j, 1:(j -1)], L[i, 1:(j -1)]);// Omega[i, j] = b1 + L[i, j] * D[j] and the correlation C satisfies// |C - b1| <= sqrt(D[j] * r), intersected with (-1, 1)real x;if (is_zero(i, j, zeros, zero_idx)) { x =-b1; // Omega[i, j] = b1 + Lij * Dj = 0 zero_idx +=1; } else {real b2 =exp(0.5* (log_r + log_D[j]));real low =max({-b2, -1- b1});real up =min({b2, 1- b1}); x =lower_upper_bound_jacobian(raw[raw_idx], low, up); raw_idx +=1; } L[i, j] = x / D[j];// free entries: change of variables x -> L_chol[i, j] = x / sqrt(D[j])// zero entries: Carpenter's correction -log(L_chol[j, j])jacobian+=-0.5* log_D[j]; log_r +=log1m(D[j] * L[i, j]^2/exp(log_r)); } log_D[i] = log_r; D[i] =exp(log_r); }returndiag_post_multiply(L, sqrt(D)); }}data {int<lower=2> K; // dimension of correlation matrixreal<lower=0> eta; // concentration in LKJ Choleskyint<lower=0, upper=choose(K, 2)> N_zero; // # structural zero correlationsarray[N_zero, 2] int zeros; // lower triangular, row major order}parameters {vector[choose(K, 2) - N_zero] raw;}transformed parameters {matrix[K, K] L_Omega =cholesky_corr_constrain_ldl2_jacobian(K, raw, zeros);}model { L_Omega ~ lkj_corr_cholesky(eta);}generated quantities {matrix[K, K] Omega = multiply_lower_tri_self_transpose(L_Omega);}
pinkney-corr-zeros-ldl3.stan
functions {int is_zero(int row_idx, int col_idx, array[,] int zeros, int zero_idx) {return zero_idx <=size(zeros)&& zeros[zero_idx, 1] == row_idx&& zeros[zero_idx, 2] == col_idx; }matrixcholesky_corr_constrain_ldl3_jacobian(int K, vector raw,array[,] int zeros) {// Omega = L * diag(D) * L' with unit lower-triangular L;// the Cholesky factor is diag_post_multiply(L, sqrt(D)).// Same as the ldl versions except the remaining radius is tracked on// the natural scale: r -= D[j] * L[i, j]^2.matrix[K, K] L =diag_matrix(rep_vector(1, K));vector[K] D;int raw_idx =1;int zero_idx =1; D[1] =1;for (i in2:K) {// remaining radius^2 of row i: 1 - sum_{k<j} L[i,k]^2 * D[k]real r =1;for (j in1:(i -1)) {real b1 =dot_product(D[1:(j -1)]'.* L[j, 1:(j -1)], L[i, 1:(j -1)]);// Omega[i, j] = b1 + L[i, j] * D[j] and the correlation C satisfies// |C - b1| <= sqrt(D[j] * r), intersected with (-1, 1)real x;if (is_zero(i, j, zeros, zero_idx)) { x =-b1; // Omega[i, j] = b1 + Lij * Dj = 0 zero_idx +=1; } else {real b2 =sqrt(r * D[j]);real low =max({-b2, -1- b1});real up =min({b2, 1- b1}); x =lower_upper_bound_jacobian(raw[raw_idx], low, up); raw_idx +=1; } L[i, j] = x / D[j];// free entries: change of variables x -> L_chol[i, j] = x / sqrt(D[j])// zero entries: Carpenter's correction -log(L_chol[j, j])jacobian+=-0.5*log(D[j]); r -= D[j] * L[i, j]^2; } D[i] = r; }returndiag_post_multiply(L, sqrt(D)); }}data {int<lower=2> K; // dimension of correlation matrixreal<lower=0> eta; // concentration in LKJ Choleskyint<lower=0, upper=choose(K, 2)> N_zero; // # structural zero correlationsarray[N_zero, 2] int zeros; // lower triangular, row major order}parameters {vector[choose(K, 2) - N_zero] raw;}transformed parameters {matrix[K, K] L_Omega =cholesky_corr_constrain_ldl3_jacobian(K, raw, zeros);}model { L_Omega ~ lkj_corr_cholesky(eta);}generated quantities {matrix[K, K] Omega = multiply_lower_tri_self_transpose(L_Omega);}
cvine-corr-zeros.stan
functions {// traversal here is tree-by-tree (column major), so scan instead of the// merge-join used by the row-major modelsint is_zero_scan(int row_idx, int col_idx, array[,] int zeros) {for (n in1:size(zeros)) {if (zeros[n, 1] == row_idx && zeros[n, 2] == col_idx) {return1; } }return0; }matrixcorr_constrain_cvine_jacobian(int K, vector raw,array[,] int zeros, real eta) {matrix[K, K] C =identity_matrix(K);matrix[K, K] P =identity_matrix(K);// LKJ in C-vine form: partial corr in tree i ~ Beta(a - i/2 adjustments);// constant base a with the -0.5 * i * log1m(p^2) correction belowreal a = eta +0.5* (K -1);int raw_idx =1;// tree 1: marginal correlations with variable 1for (k in2:K) {if (is_zero_scan(k, 1, zeros)) { P[1, k] =0; // prior terms at p = 0 are constants } else { P[1, k] =lower_upper_bound_jacobian(raw[raw_idx], -1, 1); raw_idx +=1;jacobian+= beta_lpdf(0.5* (P[1, k] +1) | a, a)-0.5*log1m(P[1, k]^2); } C[1, k] = P[1, k]; C[k, 1] = P[1, k]; }for (i in2:(K -1)) {for (j in (i +1):K) {vector[i -1] b1;vector[i -1] b2;int m = i;for (k in1:(i -1)) { m -=1; b1[k] = P[m, i] * P[m, j]; b2[k] =sqrt((1- P[m, i]^2) * (1- P[m, j]^2)); }// unwinding the partial correlation recursion is affine:// C[i, j] = A + B * P[i, j]real A =0;real B =1;for (k in1:(i -1)) { A = b1[k] + A * b2[k]; B *= b2[k]; }real p_ij;if (is_zero_scan(j, i, zeros)) { p_ij =-A / B; // C[i, j] = 0jacobian+=-log(B); // delta in Omega coords -> partial coords } else { p_ij =lower_upper_bound_jacobian(raw[raw_idx], -1, 1); raw_idx +=1; }// LKJ prior on the tree-i partial (evaluated at p* for zeros)jacobian+= beta_lpdf(0.5* (p_ij +1) | a, a)-0.5* i *log1m(p_ij^2); P[i, j] = p_ij; C[i, j] = A + B * p_ij; C[j, i] = C[i, j]; } }return C; }}data {int<lower=2> K; // dimension of correlation matrixreal<lower=0> eta; // concentration in LKJint<lower=0, upper=choose(K, 2)> N_zero; // # structural zero correlationsarray[N_zero, 2] int zeros; // lower triangular index pairs}parameters {vector[choose(K, 2) - N_zero] raw;}transformed parameters {// prior is included via the beta terms in the constraint, so the// model block stays emptymatrix[K, K] Omega =corr_constrain_cvine_jacobian(K, raw, zeros, eta);}model {}
pinkney-corr-zeros-rowscale.stan
functions {int is_zero(int row_idx, int col_idx, array[,] int zeros, int zero_idx) {return zero_idx <=size(zeros)&& zeros[zero_idx, 1] == row_idx&& zeros[zero_idx, 2] == col_idx; }matrixcholesky_corr_constrain_rowscale_jacobian(int K, vector raw,array[,] int zeros) {// Samples L[i, j] directly (bounds pre-divided by L[j, j]) and keeps the// remaining radius in the untouched tail of the row: each entry of// L[i, j:i] holds the current radius until column j is sampled, then the// tail is scaled down. L[i, i] ends up as the diagonal automatically.matrix[K, K] L =rep_matrix(0, K, K);int raw_idx =1;int zero_idx =1; L[1, 1] =1;for (i in2:K) { L[i, 1:i] =rep_row_vector(1, i);for (j in1:(i -1)) {real l_ij_old = L[i, j]; // remaining radius of row ireal b1 =dot_product(L[j, 1:(j -1)], L[i, 1:(j -1)]);if (is_zero(i, j, zeros, zero_idx)) { L[i, j] =-b1 / L[j, j]; // Omega[i, j] = b1 + Lij * Ljj = 0jacobian+=-log(L[j, j]); // Carpenter's correction zero_idx +=1; } else {// |L[i, j]| <= radius and Omega[i, j] = b1 + Lij * Ljj in (-1, 1);// sampling L directly, the 1 / L[j, j] factor is inside the// bound width, so no separate jacobian termreal low =max({-l_ij_old, (-1- b1) / L[j, j]});real up =min({l_ij_old, (1- b1) / L[j, j]}); L[i, j] =lower_upper_bound_jacobian(raw[raw_idx], low, up); raw_idx +=1; } L[i, (j +1):i] *=sqrt(1- (L[i, j] / l_ij_old)^2); } }return L; }}data {int<lower=2> K; // dimension of correlation matrixreal<lower=0> eta; // concentration in LKJ Choleskyint<lower=0, upper=choose(K, 2)> N_zero; // # structural zero correlationsarray[N_zero, 2] int zeros; // lower triangular, row major order}parameters {vector[choose(K, 2) - N_zero] raw;}transformed parameters {matrix[K, K] L_Omega =cholesky_corr_constrain_rowscale_jacobian(K, raw, zeros);}model { L_Omega ~ lkj_corr_cholesky(eta);}generated quantities {matrix[K, K] Omega = multiply_lower_tri_self_transpose(L_Omega);}
pinkney-corr-zeros-schur.stan
functions {int is_zero(int row_idx, int col_idx, array[,] int zeros, int zero_idx) {return zero_idx <=size(zeros)&& zeros[zero_idx, 1] == row_idx&& zeros[zero_idx, 2] == col_idx; }matrixcholesky_corr_constrain_schur_jacobian(int K, vector raw,array[,] int zeros) {// Canonical partial correlation construction: each free entry is the// remaining radius times tanh(raw), so |L[i, j]| < radius automatically// and no correlation bounds are needed. The untouched tail of each row// holds the current radius (row-scaling trick).matrix[K, K] L =rep_matrix(0, K, K);int raw_idx =1;int zero_idx =1; L[1, 1] =1;for (i in2:K) { L[i, 1:i] =rep_row_vector(1, i);for (j in1:(i -1)) {real r = L[i, j]; // remaining radius of row iif (is_zero(i, j, zeros, zero_idx)) {real b1 =dot_product(L[j, 1:(j -1)], L[i, 1:(j -1)]); L[i, j] =-b1 / L[j, j]; // Omega[i, j] = b1 + Lij * Ljj = 0jacobian+=-log(L[j, j]); // Carpenter's correction zero_idx +=1; L[i, (j +1):i] *=sqrt(1- (L[i, j] / r)^2); } else {real x = raw[raw_idx];real cosh_x =cosh(x);// tanh jacobian and x -> L[i, j] = r * tanh(x);// the radius update sqrt(1 - tanh^2) is 1 / coshjacobian+=-2*log(cosh_x) +log(r); L[i, j] = r *tanh(x); raw_idx +=1; L[i, (j +1):i] /= cosh_x; } } }return L; }}data {int<lower=2> K; // dimension of correlation matrixreal<lower=0> eta; // concentration in LKJ Choleskyint<lower=0, upper=choose(K, 2)> N_zero; // # structural zero correlationsarray[N_zero, 2] int zeros; // lower triangular, row major order}parameters {vector[choose(K, 2) - N_zero] raw;}transformed parameters {matrix[K, K] L_Omega =cholesky_corr_constrain_schur_jacobian(K, raw, zeros);}model { L_Omega ~ lkj_corr_cholesky(eta);}generated quantities {matrix[K, K] Omega = multiply_lower_tri_self_transpose(L_Omega);}
qr-corr-zeros.stan
functions {matrixcholesky_corr_constrain_unitvec_jacobian(int K, vector raw,array[,] int zeros) {// Row i of a Cholesky correlation factor is a unit vector in R^i with// positive last coordinate. Omega[i, j] = 0 means row i is orthogonal// to row j, so row i lives on the unit sphere in the orthogonal// complement of its zero-partner rows: the constraint is satisfied// exactly by construction and no proposal can ever be infeasible.//// Chart per row: unit vector u = (C * y, 1)' / s with s = sqrt(1 + y'y),// C an orthonormal basis of the feasible subspace. Jacobian onto the// subsphere is -(d + 2) * log(s); the joint delta over the row's zeros// contributes -0.5 * log det(Gram) = -sum(log|diag(R)|) from the QR.matrix[K, K] L =rep_matrix(0, K, K);int raw_idx =1;int zero_idx =1; L[1, 1] =1;for (i in2:K) {int n_z =0;while (zero_idx + n_z <=size(zeros)&& zeros[zero_idx + n_z, 1] == i) { n_z +=1; }int d = i -1- n_z;if (n_z ==0) {vector[d] y = raw[raw_idx:(raw_idx + d -1)];real s =sqrt(1+dot_self(y)); L[i, 1:(i -1)] = y'/ s; L[i, i] =1/ s;jacobian+=-(d +2) *log(s); raw_idx += d; } else {// columns are the zero-partner rows restricted to 1:(i - 1)matrix[i -1, n_z] A;for (n in1:n_z) { A[, n] = L[zeros[zero_idx + n -1, 2], 1:(i -1)]'; }// full qr: the complement basis lives in Q's last d columns and// diagonal(R) supplies the Gram term from one decompositionmatrix[i -1, i -1] Q;matrix[i -1, n_z] R; (Q, R) =qr(A);jacobian+=-sum(log(abs(diagonal(R))));if (d >0) {vector[d] y = raw[raw_idx:(raw_idx + d -1)];real s =sqrt(1+dot_self(y)); L[i, 1:(i -1)] = (Q[, (n_z +1):(i -1)] * y)'/ s; L[i, i] =1/ s;jacobian+=-(d +2) *log(s); raw_idx += d; } else { L[i, i] =1; // row fully constrained: e_i } zero_idx += n_z; } }return L; }}data {int<lower=2> K; // dimension of correlation matrixreal<lower=0> eta; // concentration in LKJ Choleskyint<lower=0, upper=choose(K, 2)> N_zero; // # structural zero correlationsarray[N_zero, 2] int zeros; // lower triangular, row major order}parameters {vector[choose(K, 2) - N_zero] raw;}transformed parameters {matrix[K, K] L_Omega =cholesky_corr_constrain_unitvec_jacobian(K, raw, zeros);}model { L_Omega ~ lkj_corr_cholesky(eta);}generated quantities {matrix[K, K] Omega = multiply_lower_tri_self_transpose(L_Omega);}
qr2-corr-zeros.stan
functions {matrixcholesky_corr_constrain_unitvec2_jacobian(int K, vector raw,array[,] int zeros) {// Same exact-constraint geometry as the unitvec version: row i lives on// the unit sphere in the orthogonal complement of its zero-partner rows,// so every raw value maps to a valid constrained matrix. The chart on// the subsphere is the canonical-partial-correlation (tanh) stick// construction instead of the gnomonic map, to lighten the tails.matrix[K, K] L =rep_matrix(0, K, K);int raw_idx =1;int zero_idx =1; L[1, 1] =1;for (i in2:K) {int n_z =0;while (zero_idx + n_z <=size(zeros)&& zeros[zero_idx + n_z, 1] == i) { n_z +=1; }int d = i -1- n_z;// subspace coordinates via tanh sticks: w_k = tanh(x_k) * radius,// radius update sqrt(1 - tanh^2) = 1 / cosh, so the whole chart// jacobian collapses to accumulated powers of log(cosh)vector[d] w;real r =1;for (k in1:d) {real x = raw[raw_idx];real cosh_x =cosh(x); w[k] = r *tanh(x);jacobian+=-(d - k +2) *log(cosh_x); r /= cosh_x; raw_idx +=1; }if (n_z ==0) {if (d >0) { L[i, 1:d] = w'; } L[i, i] = r; } else {// columns are the zero-partner rows restricted to 1:(i - 1);// -0.5 * log det(Gram) from the joint delta over this row's zerosmatrix[i -1, n_z] A;for (n in1:n_z) { A[, n] = L[zeros[zero_idx + n -1, 2], 1:(i -1)]'; }// full qr: the complement basis lives in Q's last d columns and// diagonal(R) supplies the Gram term from one decompositionmatrix[i -1, i -1] Q;matrix[i -1, n_z] R; (Q, R) =qr(A);jacobian+=-sum(log(abs(diagonal(R))));if (d >0) { L[i, 1:(i -1)] = (Q[, (n_z +1):(i -1)] * w)'; } L[i, i] = r; zero_idx += n_z; } }return L; }}data {int<lower=2> K; // dimension of correlation matrixreal<lower=0> eta; // concentration in LKJ Choleskyint<lower=0, upper=choose(K, 2)> N_zero; // # structural zero correlationsarray[N_zero, 2] int zeros; // lower triangular, row major order}parameters {vector[choose(K, 2) - N_zero] raw;}transformed parameters {matrix[K, K] L_Omega =cholesky_corr_constrain_unitvec2_jacobian(K, raw, zeros);}model { L_Omega ~ lkj_corr_cholesky(eta);}generated quantities {matrix[K, K] Omega = multiply_lower_tri_self_transpose(L_Omega);}
Carpenter, Bob, Andrew Gelman, Matthew D. Hoffman, et al. 2017. “Stan: A Probabilistic Programming Language.”Journal of Statistical Software 76 (1): 1–32. https://doi.org/10.18637/jss.v076.i01.
Joe, Harry. 2006. “Generating Random Correlation Matrices Based on Partial Correlations.”Journal of Multivariate Analysis 97 (10): 2177–89. https://doi.org/10.1016/j.jmva.2005.05.010.
Lewandowski, Daniel, Dorota Kurowicka, and Harry Joe. 2009. “Generating Random Correlation Matrices Based on Vines and Extended Onion Method.”Journal of Multivariate Analysis 100 (9): 1989–2001. https://doi.org/10.1016/j.jmva.2009.04.008.
Pinkney, Sean. 2024a. A Short Note on a Flexible Cholesky Parameterization of Correlation Matrices. https://arxiv.org/abs/2405.07286.
@ethothepie.bsky.social corrected the original here where I incorrectly implied the zeros in the correlation matrix are conditionally unrelated when this is marginal↩︎