JIVE: Joint and Individual Variation Explained

Joint and Individual Variation Explained or JIVE is a matrix decomposition technique. If we have several blocks of data sharing the same observations, IVE decomposes them into three parts: a joint structure which is common to all blocks, an individual structure which is specific to each block, and residual noise. The main goal of JIVE is to separate the variation the measurements share from the variation unique to each.

In this documentation, we will reproduce the analysis from the r.jive package vignette using BigRiverEssence.jive where we will consider three genomic data blocks (gene expression, methylation, and miRNA) measured on the same breast cancer tumor samples and we will decomposed into their joint and individual structure.

The method

Let us consider $k$ data blocks $X_1, \dots, X_k$. Each of the data blocks have the same $n$ samples but with different features (so $X_i$ is $p_i \times n$). We can obtain total variation by stacking them. JIVE splits each block as $X_i = J_i + A_i + \varepsilon_i,$ where $J_i$ is the joint structure, $A_i$ the individual structure, and $\varepsilon_i$ is the noise. The joint structures $J_1, \dots, J_k$ share a common row space where a set of $r$ patterns across samples that appear in every block, whereas each individual structure $A_i$ lies in a space orthogonal to the joint one, capturing what is specific to block $i$.

Similar to r.jive, BigRiverEssence.jive finds this decomposition by alternating between two steps. Given the individual structures, the joint structure can be considered as the ranked $r$ approximation of the stacked residual (which is data minus individual). Now, given the joint structure, individual structure of each block is the ranked $r_i$ approximation of residual of that block, projected off the joint row space and off the other blocks' individual spaces. it iterates between these two estimates untill convergence, following the "orthIndiv" algorithm of Lock et al. (2013).

The joint rank $r$ and individual ranks $r_i$ can either be supplied directly or they can also be estimated from the data by a permutation test where we consider a componet as a real signal only if the singular value of that component exceeds what is seen after randomly permuting the data to destroy structure.

Implementation note. This implementation adapts and streamlines the algorithms provided by the R package r.jive[1]. It preserves the alternating estimation of joint and individual structure, orthogonality handling, and permutation-based rank selection while re-engineering the computation in Julia to compress wide blocks and reduce intermediate memory allocation.

The data

We will consider the BRCA dataset. The BRCA dataset contains gene expression (654 genes), DNA methylation (574 CpG sites), and miRNA expression (423 miRNAs) measured on the same 348 breast tumor samples from The Cancer Genome Atlas [2]. It is obtained via the r.jive R package [1], which implements the JIVE method [3].

using BigRiverEssence, Plots, StatsPlots, Clustering, Distances
using LinearAlgebra, Statistics, DelimitedFiles, Random
datadir = joinpath(pkgdir(BigRiverEssence), "reference_Data", "brcadata")
X1 = readdlm(joinpath(datadir, "expression.csv"),  ',', Float64)   # Expression block
X2 = readdlm(joinpath(datadir, "methylation.csv"), ',', Float64)   # Methylation block
X3 = readdlm(joinpath(datadir, "mirna.csv"),       ',', Float64)   # miRNA block
cl = Int.(vec(readdlm(joinpath(datadir, "clusts.csv"), ',')))      # cluster labels

nm = ["Expression", "Methylation", "miRNA"]
size.((X1, X2, X3), 1), size(X1, 2)     # features per block, shared sample count
((645, 574, 423), 348)

We see from the above output that, each block has the same number of columns or columns but a different number of rows or features. This is the exact layout jive expects where each block is a $p_i \times n$ matrix, sharing $n$.

Fitting the model

Now we fit jive by passing the three blocks with specifying the ranks. This lets jive estimate the ranks by permutation test.

Random.seed!(1234)
res = jive([X1, X2, X3])
rJ  = res.r
println("Estimated ranks: joint = $rJ, individual = $(res.ri)")
Estimating ranks via permutation test...
Estimated joint rank: 2, individual ranks: [27, 26, 25]
Estimated ranks: joint = 2, individual = [27, 26, 25]

We note that the permutation test finds a joint rank of $2$ which implies that two patterns are shared across all the three data types. We also found that the individual ranks are in the mid-$20$s for each block. The returned Jive holds the joint (J, S, U) and individual (A, Si, Wi) parts along with the ranks.

Preparing for the figures

We scale the data for the variance and heatmap figure exactly as jive scales it internally (row-centered, then Frobenius-normalized so no block dominates).

nel = [size(X, 1) * size(X, 2) for X in (X1, X2, X3)]; sum_n = sum(nel)
Dat = [ let Xi = X .- mean(X, dims = 2); Xi ./ (norm(Xi) * sqrt(sum_n)); end
        for X in (X1, X2, X3) ]

clusters = Int.(cl)
pal = [:black, :green, :purple]
colors = [pal[c] for c in clusters]
348-element Vector{Symbol}:
 :green
 :green
 :black
 :black
 :green
 :black
 :black
 :purple
 :purple
 :purple
 ⋮
 :green
 :purple
 :black
 :green
 :green
 :black
 :green
 :green
 :purple

The variation explained

Now we measure the fraction of the variance captured by the joint structure, the individual structure, and left as residual for each block. We plot them as a stacked bar plot.

VarJ = [norm(res.J[i])^2 / norm(Dat[i])^2 for i in 1:3]
VarI = [norm(res.A[i])^2 / norm(Dat[i])^2 for i in 1:3]
VarR = 1 .- VarJ .- VarI

fig1 = groupedbar(nm, [VarR VarI VarJ];
    bar_position = :stack,
    label = ["Residual" "Individual" "Joint"],
    color = [RGB(0.65,0.65,0.65) RGB(0.43,0.43,0.43) RGB(0.2,0.2,0.2)],
    title = "Variation Explained", legend = :outertopright,
    lw = 0.3, linecolor = :black, bar_width = 0.7, ylims = (0,1))
Example block output

In the above bar plot, each bar shows how variation of one block divides between shared (joint), block-specific (individual), and noise. This is the main result of JIVE which shows how much of each data type is explained by structure common to all three versus structure unique to itself.

Heatmaps: Data = Joint + Individual + Noise

Now we visualize the decomposition directly by displaying each block as the original data along with the joint, individual, and noise components. We order samples by clustering their joint structure, so shared patterns line up across all three blocks.

Jstack = vcat(res.J...)
col_order = hclust(pairwise(Euclidean(), Jstack, dims = 2), linkage = :complete).order
row_orders = [hclust(pairwise(Euclidean(), res.J[i], dims = 1), linkage = :complete).order
              for i in 1:3]

Wlab = 0.05
Wcontent = [0.22, 0.04, 0.22, 0.04, 0.22, 0.04, 0.22]
Wcontent = Wcontent .* (1 - Wlab)           # rescale so everything still sums to 1
W = vcat(Wlab, Wcontent)                     # 8 columns total

function panel(M, ro; title = "")
    Mo = M[ro, col_order]
    m = mean(Mo); s = std(Mo); lo, hi = m - 3s, m + 3s
    heatmap(clamp.(Mo, lo, hi); c = :bwr, clim = (lo, hi), title = title,
        cbar = false, xticks = false, yticks = false,
        titlefontsize = 11, guidefontsize = 9)
end

function txt(s; fs = 20, rot = 0)
    p = plot(framestyle = :none, xlims = (0,1), ylims = (0,1), legend = false)
    annotate!(p, 0.5, 0.5, text(s, fs, rotation = rot)); return p
end

function source_row(i)
    ro = row_orders[i]
    lab = txt(nm[i]; fs = 14, rot = 90)      # vertical block name, far left
    D = panel(Dat[i], ro)
    J = panel(res.J[i], ro)
    A = panel(res.A[i], ro)
    N = panel(Dat[i] .- res.J[i] .- res.A[i], ro)
    plot(lab, D, txt("="), J, txt("+"), A, txt("+"), N;
        layout = grid(1, 8, widths = W), size = (1500, 300))
end

header = plot(txt(""), txt("Data"; fs=16), txt(""), txt("Joint"; fs=16), txt(""),
    txt("Individual"; fs=16), txt(""), txt("Noise"; fs=16);
    layout = grid(1, 8, widths = W), size = (1500, 60))

fig2 = plot(header, source_row(1), source_row(2), source_row(3);
    layout = grid(4, 1, heights = [0.08, 0.307, 0.307, 0.306]), size = (1500, 950))
Example block output

Joint structure by cluster

Now, we project the joint structure onto its principal components and color by the sample clusters. This shows us whether the shared variation separates the known tumor subtypes.

Fj = svd(vcat(res.J...))
fmt = v -> string(round(v * 1e4, digits = 1))

PCs = Diagonal(Fj.S[1:rJ]) * Fj.Vt[1:rJ, :]

# plot each cluster as its own labeled series, so the legend maps color → cluster
fig3 = plot(; xlabel = "Joint 2  (×10⁻⁴)", ylabel = "Joint 1  (×10⁻⁴)",
    title = "Joint Structure, colored by cluster",
    legend = :outerright, size = (700, 500),
    xformatter = fmt, yformatter = fmt)

cluster_ids = sort(unique(clusters))
for (i, c) in enumerate(cluster_ids)
    idx = clusters .== c
    scatter!(fig3, PCs[2, idx], PCs[1, idx];
        color = pal[c], markershape = :circle, markersize = 4,
        markerstrokewidth = 1.2, markerstrokecolor = pal[c], markercolor = :white,
        label = "Cluster $c")
end
fig3
Example block output

We can see from the above plot, the joint structures (the variation shared across expression, methylation, and miRNA) were able to separate the sample clusters. This shows that the tumor subtypes differ in ways that are consistently reflected across all three molecular data types.

Joint and individual components together

Now we construct a matrix of scatterplots where in each of them, we plot the first joint component against the first individual component of each block. This will shows us how joint and block-specific structure relate.

j1   = (Diagonal(Fj.S[1:1]) * Fj.Vt[1:1, :])[1, :]
ind1 = [ (let F = svd(res.A[i]); (Diagonal(F.S[1:1]) * F.Vt[1:1, :])[1, :]; end)
         for i in 1:length(res.A) ]

comps  = [j1, ind1...]
names4 = ["Joint 1"; ["$(nm[i]) Indiv 1" for i in 1:length(ind1)]]
nPC = length(comps)

panels = Matrix{Any}(undef, nPC-1, nPC-1)
for i in 2:nPC, j in 1:(nPC-1)
    r = i-1; c = j
    if j >= i
        panels[r,c] = plot(framestyle = :none)
    else
        panels[r,c] = scatter(comps[i], comps[j];
            color = colors, markershape = :circle, markersize = 3,
            markerstrokewidth = 1.0, markerstrokecolor = colors, markercolor = :white,
            xlabel = "$(names4[i])  (×10⁻⁴)", ylabel = "$(names4[j])  (×10⁻⁴)",
            xformatter = fmt, yformatter = fmt,
            legend = false, tickfontsize = 7, guidefontsize = 8)
    end
end
fig4 = plot(permutedims(panels)...; layout = (nPC-1, nPC-1), size = (1100, 1100))
Example block output

We note in the above plot, the joint component is able to separats the clusters (as we saw in Figure 3), while the individual components are able to capture the block-specific variation. This portrays the distinction JIVE draws between shared and unique structure.

Summary

We saw in this example, given three molecular data blocks on the same tumor samples, jive estimated a joint rank of 2 and separated each block into shared structure, block-specific structure, and noise. The joint structure recovered variation reflected across all three data types, while the individual structures isolated what is unique to each. This reproduced the analysis of the r.jive vignette, on the same data.

References

[1] O'Connell, M. J., & Lock, E. F. (2020). r.jive: Perform JIVE Decomposition for Multi-Source Data. 10.32614/CRAN.package.r. jive [2] Cancer Genome Atlas Network (2012). Comprehensive molecular portraits of human breast tumours. Nature, 490(7418), 61–70.

[3] Lock, E. F., Hoadley, K. A., Marron, J. S., & Nobel, A. B. (2013). Joint and Individual Variation Explained (JIVE) for integrated analysis of multiple data types. The Annals of Applied Statistics, 7(1), 523–542.