|
| 1 | +function F = factorAnalysisCorpus(C, opts) |
| 2 | +%FACTORANALYSISCORPUS Exploratory factor analysis of the feature corpus. |
| 3 | +% F = FACTORANALYSISCORPUS(C) runs EFA (MATLAB's FACTORAN, maximum-likelihood with |
| 4 | +% varimax rotation) on the expanded feature matrix from READANNOTATIONCORPUSFULL |
| 5 | +% and returns the factor solution plus a color-coded loadings plot. |
| 6 | +% |
| 7 | +% By default it analyzes the INTERPRETABLE feature set — it excludes the opaque |
| 8 | +% SigLIP/DINOv2/CLAP embeddings and the two large fixed-taxonomy posteriors |
| 9 | +% (AudioSet 527 tags, Kinetics 400 actions), which otherwise dominate the solution |
| 10 | +% with hundreds of near-collinear columns. Set the options below to include them. |
| 11 | +% |
| 12 | +% Missing data: features are z-scored, then the factor MODEL is fit from the |
| 13 | +% pairwise correlation matrix (using all available data per pair, so audio-only |
| 14 | +% clips still contribute their audio/language features) after a nearest |
| 15 | +% positive-definite adjustment. Per-timepoint FACTOR SCORES are then computed by |
| 16 | +% the regression method with mean-imputation (standardized NaN -> 0), so every |
| 17 | +% timepoint gets a score and the factors can be plotted as time series like the |
| 18 | +% raw features. |
| 19 | +% |
| 20 | +% Name-value options: |
| 21 | +% "NumFactors" (8) number of factors to extract |
| 22 | +% "ExcludeEmbeddings" (true) drop SigLIP/DINOv2/CLAP dimensions |
| 23 | +% "ExcludeTaxonomies" (true) drop audioset_tags (527) + action_posteriors (400) |
| 24 | +% "Rotate" ("varimax") any FACTORAN rotation ("none","promax",...) |
| 25 | +% "MaxNaNFrac" (0.8) drop variables missing in > this fraction of rows |
| 26 | +% (0.8 keeps visual features, which are absent on the |
| 27 | +% ~67% of timepoints that come from audio-only clips) |
| 28 | +% "Plot" (true) draw the loadings heatmap + scree |
| 29 | +% "SavePng" ("") export the loadings figure to this path |
| 30 | +% |
| 31 | +% Returns struct F with: |
| 32 | +% F.loadings [p x m] rotated factor loadings |
| 33 | +% F.vars [p x k] FEATUREINFO subset for the retained variables (row i <-> loadings row i) |
| 34 | +% F.scores [T x m] per-timepoint factor scores (regression method) |
| 35 | +% F.specificVar[p x 1] uniquenesses; F.stats FACTORAN stats struct |
| 36 | +% F.eigs eigenvalues of the correlation matrix (for the scree plot) |
| 37 | +% F.varExplained [1 x m] % variance each rotated factor accounts for |
| 38 | +% F.m, F.rotation, F.nEff |
| 39 | +% |
| 40 | +% Example: |
| 41 | +% C = readAnnotationCorpusFull("annotations/corpus"); |
| 42 | +% F = factorAnalysisCorpus(C, "NumFactors", 8); |
| 43 | +% % plot factor-3 time series for one clip: |
| 44 | +% r = C.stim=="BigBuckBunny"; plot(C.time_sec(r), F.scores(r,3)); |
| 45 | +% |
| 46 | +% See also READANNOTATIONCORPUSFULL, PLOTFEATUREMATRIX, FACTORAN, PCA. |
| 47 | + |
| 48 | +arguments |
| 49 | + C (1,1) struct |
| 50 | + opts.NumFactors (1,1) double {mustBePositive, mustBeInteger} = 8 |
| 51 | + opts.ExcludeEmbeddings (1,1) logical = true |
| 52 | + opts.ExcludeTaxonomies (1,1) logical = true |
| 53 | + opts.Rotate (1,1) string = "varimax" |
| 54 | + opts.MaxNaNFrac (1,1) double = 0.8 |
| 55 | + opts.Plot (1,1) logical = true |
| 56 | + opts.SavePng (1,1) string = "" |
| 57 | +end |
| 58 | + |
| 59 | +info = C.info; X = C.X; |
| 60 | + |
| 61 | +% ---- column selection ---- |
| 62 | +keep = info.Numeric; |
| 63 | +if opts.ExcludeEmbeddings, keep = keep & ~info.IsEmbedding; end |
| 64 | +if opts.ExcludeTaxonomies |
| 65 | + keep = keep & ~ismember(info.Leaf, ["audioset_tags", "action_posteriors"]); |
| 66 | +end |
| 67 | +keep = keep & (mean(isnan(X),1)' <= opts.MaxNaNFrac); % not-too-missing |
| 68 | +X = X(:, keep); info = info(keep, :); |
| 69 | + |
| 70 | +% z-score; drop zero-variance columns |
| 71 | +mu = mean(X,1,"omitnan"); sd = std(X,0,1,"omitnan"); |
| 72 | +good = sd > 0 & isfinite(sd); |
| 73 | +X = X(:, good); info = info(good,:); mu = mu(good); sd = sd(good); |
| 74 | +Z = (X - mu) ./ sd; |
| 75 | + |
| 76 | +% ---- correlation matrix (pairwise) + nearest-PD repair ---- |
| 77 | +R = corr(Z, "rows", "pairwise"); |
| 78 | +R(~isfinite(R)) = 0; R(1:size(R,1)+1:end) = 1; |
| 79 | +% drop columns still fully collinear (|r|=1 with an earlier column) |
| 80 | +[R, info, Z] = dropCollinear(R, info, Z); |
| 81 | +R = nearestPD(R); |
| 82 | +p = size(R,1); |
| 83 | +nEff = size(Z,1); |
| 84 | + |
| 85 | +m = min(opts.NumFactors, p-1); |
| 86 | +if m < opts.NumFactors |
| 87 | + warning("factorAnalysisCorpus:m", "Reduced NumFactors to %d (only %d variables).", m, p); |
| 88 | +end |
| 89 | + |
| 90 | +% ---- fit factor model ---- |
| 91 | +% factoran needs complete data; audio-only clips make visual columns NaN, so when |
| 92 | +% any NaNs are present we fit from the (repaired) correlation matrix instead. |
| 93 | +if any(isnan(Z(:))) |
| 94 | + [L, psi, ~, stats] = factoran(R, m, "Xtype","covariance", "Nobs",nEff, ... |
| 95 | + "Rotate",char(opts.Rotate)); |
| 96 | +else |
| 97 | + [L, psi, ~, stats] = factoran(Z, m, "Xtype","data", "Rotate",char(opts.Rotate), ... |
| 98 | + "Maxit",1000); |
| 99 | +end |
| 100 | + |
| 101 | +% ---- per-timepoint factor scores (regression method, mean-imputed) ---- |
| 102 | +Zi = Z; Zi(isnan(Zi)) = 0; % standardized mean-imputation |
| 103 | +W = R \ L; % regression weights (p x m) |
| 104 | +scores = Zi * W; |
| 105 | + |
| 106 | +% variance explained by each rotated factor (sum of squared loadings / p) |
| 107 | +ve = 100 * sum(L.^2, 1) / p; |
| 108 | +[ve, si] = sort(ve, "descend"); L = L(:, si); scores = scores(:, si); |
| 109 | + |
| 110 | +F.loadings = L; F.vars = info; F.scores = scores; F.specificVar = psi; |
| 111 | +F.stats = stats; F.varExplained = ve; F.m = m; F.rotation = string(opts.Rotate); F.nEff = nEff; |
| 112 | +F.eigs = sort(eig(R), "descend"); |
| 113 | + |
| 114 | +fprintf("EFA: %d variables, %d factors (%s), n_eff=%d. Variance explained: %s%%\n", ... |
| 115 | + p, m, opts.Rotate, nEff, join(compose("%.1f", ve), "/")); |
| 116 | + |
| 117 | +if opts.Plot, plotLoadings(F, opts.SavePng); end |
| 118 | +end |
| 119 | + |
| 120 | +% ------------------------------------------------------------------------- |
| 121 | +function [R, info, Z] = dropCollinear(R, info, Z) |
| 122 | +p = size(R,1); keep = true(p,1); |
| 123 | +for i = 2:p |
| 124 | + if any(abs(R(i, 1:i-1)) > 0.999 & keep(1:i-1)'), keep(i) = false; end |
| 125 | +end |
| 126 | +R = R(keep,keep); info = info(keep,:); Z = Z(:,keep); |
| 127 | +end |
| 128 | + |
| 129 | +function A = nearestPD(A) |
| 130 | +A = (A + A')/2; |
| 131 | +[V,D] = eig(A); d = diag(D); |
| 132 | +d(d < 1e-6) = 1e-6; |
| 133 | +A = V*diag(d)*V'; |
| 134 | +A = (A + A')/2; |
| 135 | +s = sqrt(diag(A)); A = A ./ (s*s'); % rescale to unit diagonal (correlation) |
| 136 | +A = (A + A')/2; |
| 137 | +A(1:size(A,1)+1:end) = 1; % exact unit diagonal |
| 138 | +end |
| 139 | + |
| 140 | +function plotLoadings(F, savePng) |
| 141 | +fig = figure("Color","w","Position",[100 100 980 760]); |
| 142 | +% sort variables by class then by dominant factor for a readable block structure |
| 143 | +classes = ["visual","audio","language","social","situation","affect"]; |
| 144 | +key = double(arrayfun(@(c) find(classes==c,1), F.vars.Class)); |
| 145 | +[~, dom] = max(abs(F.loadings), [], 2); |
| 146 | +[~, ord] = sortrows([key, dom]); |
| 147 | +L = F.loadings(ord,:); vinfo = F.vars(ord,:); |
| 148 | + |
| 149 | +ax = axes(fig, "Position",[0.30 0.08 0.60 0.84]); |
| 150 | +imagesc(ax, L); set(ax, "CLim",[-1 1]); |
| 151 | +colormap(ax, localDiv()); cb = colorbar(ax); cb.Label.String = "loading"; |
| 152 | +ax.XTick = 1:F.m; ax.XTickLabel = compose("F%d", 1:F.m); |
| 153 | +xlabel(ax, sprintf("factors (%s rotation)", F.rotation)); |
| 154 | +title(ax, sprintf("EFA loadings — %d variables x %d factors", size(L,1), F.m)); |
| 155 | +% label a subset of rows (too many to show all) |
| 156 | +step = max(1, round(size(L,1)/60)); |
| 157 | +ax.YTick = 1:step:size(L,1); |
| 158 | +lab = vinfo.Leaf + string(compose(" [%s]", extractBefore(vinfo.Level+" ",4))); |
| 159 | +comp = vinfo.Component; hasC = comp~=""; |
| 160 | +lab(hasC) = vinfo.Leaf(hasC) + ":" + comp(hasC); |
| 161 | +ax.YTickLabel = lab(1:step:end); ax.FontSize = 7; |
| 162 | + |
| 163 | +% class color strip |
| 164 | +strip = axes(fig, "Position",[0.27 0.08 0.02 0.84]); |
| 165 | +image(strip, reshape(vinfo.Color,[height(vinfo) 1 3])); |
| 166 | +strip.XTick=[]; strip.YTick=[]; box(strip,"on"); linkaxes([ax strip],"y"); |
| 167 | + |
| 168 | +% scree inset |
| 169 | +sc = axes(fig, "Position",[0.06 0.62 0.16 0.30]); |
| 170 | +plot(sc, F.eigs(1:min(20,numel(F.eigs))), "-o","MarkerSize",3); grid(sc,"on"); |
| 171 | +title(sc,"scree"); xlabel(sc,"factor"); ylabel(sc,"eigenvalue"); sc.FontSize=7; |
| 172 | + |
| 173 | +if savePng ~= "" |
| 174 | + exportgraphics(fig, savePng, "Resolution",150); fprintf("wrote %s\n", savePng); |
| 175 | +end |
| 176 | +end |
| 177 | + |
| 178 | +function m = localDiv() |
| 179 | +n=256; x=linspace(0,1,n)'; m=[min(1,1.8*x), 1-abs(2*x-1), min(1,1.8*(1-x))]; |
| 180 | +end |
0 commit comments