Skip to content

Commit 04ae6ec

Browse files
torwagerclaude
andcommitted
MATLAB: per-category factor extraction + document the corpus struct
- extractCategoryFactors: runs factor analysis SEPARATELY within each of the 13 multivariate model outputs (SigLIP/DINOv2/CLAP embeddings, AudioSet, action, EmoNet, text-emotion, probes, MFCC, chroma, facial affect, text sentiment) plus the interpretable-scalar block; attaches per-model factor time series as C.extracted_factors and can save them to a .mat. factoran where dof allow; PCA fallback for the high-dim opaque embeddings. - walkthrough.m section 5: explain the corpus struct (C.X rows = concatenated timepoints not seconds; 65 SCALAR channels only; how to reach the embeddings via readAnnotationCorpusFull). New section 9 demos extractCategoryFactors. - CONTENTS.md: add extractCategoryFactors to the MATLAB table. - .gitignore: exclude the large regenerable analysis/extracted_factors.mat. Verified end-to-end on the 83-clip corpus: 14 categories -> 129 factors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 169f067 commit 04ae6ec

4 files changed

Lines changed: 243 additions & 6 deletions

File tree

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ __pycache__/
1515
# run logs
1616
/logs/
1717

18+
# Large derived analysis artifact (per-model factor scores over all 28k timepoints,
19+
# ~36 MB). Regenerate in MATLAB with extractCategoryFactors; kept Dropbox-only.
20+
/analysis/extracted_factors.mat
21+
1822
# Reference PDFs — copyrighted publisher/preprint PDFs of the papers behind each
1923
# model. Kept in Dropbox only, excluded from git (the docs link to public arXiv/DOI
2024
# URLs instead, which resolve on the web book). See PDFs/README.md.

docs/CONTENTS.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,8 @@ Details: [`../analysis/web/README.md`](../analysis/web/README.md).
153153
| `featuresToTable(ann)` | one clip → wide table with **every vector expanded** into per-component columns (~2.7k vars) |
154154
| `readAnnotationCorpusFull(folder)` | stack the whole corpus with vectors expanded → `C.X [timepoints × ~2.7k vars]` + `C.info` |
155155
| `plotFeatureMatrix(C)` | heatmap of the full feature time series, color-coded by category |
156-
| `factorAnalysisCorpus(C)` | exploratory factor analysis (EFA / `factoran`) + color-coded loadings plot |
156+
| `factorAnalysisCorpus(C)` | exploratory factor analysis (EFA / `factoran`) across all features + color-coded loadings plot |
157+
| `extractCategoryFactors(C)` | factor analysis **within each model/category**`C.extracted_factors` (per-model factor time series), saveable to `.mat` |
157158

158159
---
159160

docs/walkthrough.m

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@
55
% See docs/CONTENTS.md for the full guide.
66

77
%% 0. Setup — put matlab/ on the path and cd to the project root
8-
here = fileparts(mfilename("fullpath"));
9-
if isempty(here); here = pwd; end % "Run Section" mode: mfilename is empty
8+
clear here proj
9+
here = pwd;
1010
proj = fileparts(here); % docs/ -> project root
11+
1112
if ~isfolder(fullfile(proj, "matlab")) && isfolder(fullfile(here, "matlab"))
1213
proj = here; % already at the project root (common cwd)
1314
end
15+
1416
assert(isfolder(fullfile(proj, "matlab")), ...
1517
"Run from the project root or docs/ (matlab/ not found from here).");
1618
addpath(fullfile(proj, "matlab"));
@@ -57,10 +59,26 @@
5759
% ["audio/low_level/rms","visual/dynamic_motion/flow_magnitude"], "Speed", 1.5)
5860

5961
%% 5. Load the WHOLE corpus (all annotated stimuli) into one structure
62+
% C holds ALL clips concatenated, NOT a single movie/story. Structure of C:
63+
% C.X [totalTimepoints x channels] the stacked feature matrix. Here 28237 x 65.
64+
% ROWS are timepoints (1 per second of the common grid) from every clip
65+
% stacked end-to-end -- they are NOT seconds. Use C.time_sec for the
66+
% within-clip time and C.stim to know which clip a row belongs to.
67+
% C.stim [totalTimepoints x 1] categorical stimulus id for each row of C.X
68+
% C.time_sec [totalTimepoints x 1] within-clip time (s) for each row
69+
% C.channels [1 x 65] the column names of C.X
70+
% C.ids [1 x 83] stimulus ids ; C.nT samples per clip ; C.ann full structs
71+
% IMPORTANT: readAnnotationCorpus returns ONLY the 65 scalar channels (luminance, RMS,
72+
% word_rate, valence, ...). The multivariate model outputs -- the SigLIP / DINOv2 / CLAP
73+
% embeddings, AudioSet tags, VideoMAE action posteriors, EmoNet, MFCC, etc. -- are NOT
74+
% in this C.X. To get every variable (all ~2768, embeddings included) use the FULL
75+
% reader in section 8 (readAnnotationCorpusFull), whose F.info gives, per column, the
76+
% class/subclass/level/model and whether it is an embedding (F.info.IsEmbedding).
6077
C = readAnnotationCorpus("annotations/corpus");
61-
% C.X is [totalTimepoints x channels]; C.stim / C.time_sec label each row.
62-
fprintf("corpus: %d stimuli, %d channels, %d timepoints\n", ...
78+
fprintf("corpus: %d stimuli, %d scalar channels, %d timepoints\n", ...
6379
numel(C.ids), numel(C.channels), size(C.X,1));
80+
% Rows for one clip: pick them with the stimulus id, e.g.
81+
% r = C.stim == "BigBuckBunny"; plot(C.time_sec(r), C.X(r, 1));
6482

6583
%% 6. Cross-feature STRUCTURE: correlation, PCA, network graphs
6684
% Saves figures to analysis/figures/ and returns a results struct.
@@ -99,7 +117,32 @@
99117
figure; plot(F.time_sec(r), fa.scores(r, 1:3)); legend("F1","F2","F3");
100118
xlabel("time (s)"); ylabel("factor score"); title("Factor time series — BigBuckBunny");
101119

102-
%% 9. Where to go next
120+
%% 9. PER-CATEGORY factors: reduce each model's output to a few factors
121+
% Runs factor analysis SEPARATELY within each category -- each of the 13 multivariate
122+
% model outputs (SigLIP/DINOv2/CLAP embeddings, AudioSet, action, EmoNet, text-emotion,
123+
% probes, MFCC, chroma, facial affect, text sentiment) AND the interpretable-scalar
124+
% block -- and attaches the factor time series to F as F.extracted_factors. FACTORAN is
125+
% used where a block's degrees of freedom allow; the high-dimensional opaque embeddings
126+
% fall back to PCA. The .mat is saved for reuse (regenerable; kept in Dropbox only).
127+
F = extractCategoryFactors(F, "NumFactors", 10, "Save", "analysis/extracted_factors.mat");
128+
EF = F.extracted_factors;
129+
fprintf("extracted %d factors across %d categories\n", size(EF.scores,2), numel(EF.byCategory));
130+
disp(EF.labels(1:6, :)) % FactorName, Category, Model, Class, Method, VarExpl
131+
132+
% Access one model's factors as a time series (e.g. the SigLIP visual embedding):
133+
s = EF.byCategory;
134+
si = s(strcmp(string({s.name}), "siglip_embedding")); % 768 dims -> 10 PCA factors
135+
r = F.stim == "BigBuckBunny";
136+
figure; plot(F.time_sec(r), si.scores(r, 1:3));
137+
legend("F1","F2","F3"); xlabel("time (s)"); ylabel("factor score");
138+
title("SigLIP embedding factors — BigBuckBunny");
139+
140+
% Reload later without recomputing, and merge back into a corpus struct:
141+
% S = load("analysis/extracted_factors.mat"); % -> S.extracted_factors
142+
% F = readAnnotationCorpusFull("annotations/corpus");
143+
% F.extracted_factors = S.extracted_factors; % rows align with F.X / F.stim
144+
145+
%% 10. Where to go next
103146
% - Inspect any other stimulus: change `stimId` in section 0.
104147
% - Annotate a NEW movie (Python):
105148
% PYTHONPATH=src .venv/bin/python -m nfe.run <movie> --vision --audio-hl --events \

matlab/extractCategoryFactors.m

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
function C = extractCategoryFactors(C, opts)
2+
%EXTRACTCATEGORYFACTORS Reduce each model/category to a few factors and store them.
3+
% C = EXTRACTCATEGORYFACTORS(C) takes the FULL expanded corpus from
4+
% READANNOTATIONCORPUSFULL and runs factor analysis SEPARATELY within each category:
5+
% each of the 13 multivariate model outputs (SigLIP embedding, DINOv2 embedding,
6+
% CLAP embedding, AudioSet tags, action posteriors, EmoNet, GoEmotions text-emotion,
7+
% SigLIP/CLAP probes, MFCC, chroma, facial affect, text sentiment) AND the block of
8+
% interpretable scalar features. For every category it extracts factor-score time
9+
% series (one per factor, over all timepoints) and attaches them to C as
10+
% C.extracted_factors, so the ~2.7k raw variables collapse to a compact, labeled set
11+
% of per-model factors usable as fMRI regressors.
12+
%
13+
% Method: features are z-scored; the factor model is fit from the pairwise
14+
% correlation matrix (nearest-positive-definite repaired), so audio-only clips still
15+
% contribute their audio/language factors. FACTORAN (max-likelihood, varimax) is used
16+
% where the block's degrees of freedom allow; otherwise it falls back to PCA (typical
17+
% for the high-dimensional opaque embeddings). Per-timepoint scores use the regression
18+
% method with mean-imputation, so every timepoint gets a score.
19+
%
20+
% Name-value options:
21+
% "NumFactors" (10) target factors per category (capped by block dof/rank)
22+
% "Rotate" ("varimax") rotation for the FACTORAN path
23+
% "Method" ("auto") "auto" | "factoran" | "pca"
24+
% "Save" ("") if set, save the extracted_factors struct to this .mat
25+
% "Verbose" (true)
26+
%
27+
% Adds C.extracted_factors with fields:
28+
% .scores [T x F] all factor scores concatenated across categories
29+
% .labels [F x k] table: FactorName, Category, Model, Class, Method, VarExpl
30+
% .byCategory struct array, one per category, with:
31+
% name, model, class, kind, nVars, nFactors, method,
32+
% loadings [nVars x m], varExplained [1 x m], scores [T x m],
33+
% factorNames [1 x m], vars (FEATUREINFO subset)
34+
% .stim, .time_sec, .ids row keys (same rows as C.X)
35+
%
36+
% Example:
37+
% C = readAnnotationCorpusFull("annotations/corpus");
38+
% C = extractCategoryFactors(C, "NumFactors", 10, "Save", "analysis/extracted_factors.mat");
39+
% % SigLIP visual factors as a time series for one clip:
40+
% r = C.stim=="BigBuckBunny"; s = C.extracted_factors.byCategory;
41+
% si = s(strcmp({s.name},"siglip_embedding"));
42+
% plot(C.time_sec(r), si.scores(r,1:3));
43+
%
44+
% See also READANNOTATIONCORPUSFULL, FACTORANALYSISCORPUS, FEATUREINFO.
45+
46+
arguments
47+
C (1,1) struct
48+
opts.NumFactors (1,1) double {mustBePositive, mustBeInteger} = 10
49+
opts.Rotate (1,1) string = "varimax"
50+
opts.Method (1,1) string {mustBeMember(opts.Method, ["auto","factoran","pca"])} = "auto"
51+
opts.Save (1,1) string = ""
52+
opts.Verbose (1,1) logical = true
53+
end
54+
55+
info = C.info; X = C.X; T = size(X,1);
56+
57+
% ---- define categories: the 13 multivariate channels + the interpretable-scalar block
58+
vecLeaves = unique(info.Leaf(info.Dtype == "vector"), "stable");
59+
cats = struct("name",{}, "model",{}, "class",{}, "kind",{}, "cols",{});
60+
for i = 1:numel(vecLeaves)
61+
idx = find(info.Leaf == vecLeaves(i));
62+
kind = "interpretable-vector";
63+
if info.IsEmbedding(idx(1)), kind = "embedding";
64+
elseif ismember(vecLeaves(i), ["audioset_tags","action_posteriors"]), kind = "taxonomy"; end
65+
cats(end+1) = struct("name", vecLeaves(i), "model", info.Model(idx(1)), ...
66+
"class", info.Class(idx(1)), "kind", kind, "cols", idx(:)'); %#ok<AGROW>
67+
end
68+
sIdx = find(info.Dtype ~= "vector" & info.Numeric);
69+
cats(end+1) = struct("name","interpretable_scalars", "model","(mixed)", ...
70+
"class","(mixed)", "kind","interpretable-scalar", "cols", sIdx(:)');
71+
72+
% ---- run FA/PCA per category
73+
byCat = struct([]); allScores = []; FN=strings(0,1); Cat=strings(0,1);
74+
Mdl=strings(0,1); Cls=strings(0,1); Meth=strings(0,1); VE=zeros(0,1);
75+
for i = 1:numel(cats)
76+
ca = cats(i);
77+
cols = ca.cols;
78+
sub = X(:, cols);
79+
vinfo = info(cols, :);
80+
81+
% z-score; drop zero-variance columns
82+
mu = mean(sub,1,"omitnan"); sd = std(sub,0,1,"omitnan");
83+
keep = sd > 0 & isfinite(sd);
84+
sub = sub(:, keep); vinfo = vinfo(keep,:); mu = mu(keep); sd = sd(keep);
85+
Z = (sub - mu) ./ sd;
86+
d = size(Z,2);
87+
if d == 0, continue; end
88+
89+
% pairwise correlation -> nearest PD; drop fully-collinear columns
90+
R = corr(Z, "rows","pairwise"); R(~isfinite(R)) = 0; R(1:d+1:end) = 1;
91+
[R, vinfo, Z] = localDropCollinear(R, vinfo, Z);
92+
R = localNearestPD(R);
93+
d = size(R,1);
94+
nEff = size(Z,1);
95+
96+
m = min(opts.NumFactors, max(1, d-1));
97+
method = opts.Method;
98+
if method == "auto"
99+
method = "factoran";
100+
if d < 3 || m > localMaxFactoranM(d), method = "pca"; end
101+
end
102+
103+
Zi = Z; Zi(isnan(Zi)) = 0; % standardized mean-imputation
104+
if method == "factoran" && m > localMaxFactoranM(d)
105+
m = max(1, localMaxFactoranM(d));
106+
end
107+
try
108+
if method == "factoran"
109+
[L, ~, ~, ~] = factoran(R, m, "Xtype","covariance", "Nobs",nEff, "Rotate",char(opts.Rotate));
110+
W = R \ L;
111+
scores = Zi * W;
112+
ve = 100 * sum(L.^2,1) / d;
113+
else
114+
error("use pca"); % jump to catch/pca path
115+
end
116+
catch
117+
method = "pca";
118+
[V, ev] = localTopEig(R, m);
119+
L = V .* sqrt(max(ev,0))'; % component loadings
120+
scores = Zi * V; % PC scores on standardized data
121+
ve = 100 * ev' / d;
122+
end
123+
124+
% order factors by variance explained (desc)
125+
[ve, si] = sort(ve, "descend"); L = L(:,si); scores = scores(:,si);
126+
fnames = ca.name + "_F" + string(1:m);
127+
128+
e = numel(byCat) + 1;
129+
byCat(e).name = ca.name; byCat(e).model = ca.model; byCat(e).class = ca.class;
130+
byCat(e).kind = ca.kind; byCat(e).nVars = d; byCat(e).nFactors = m;
131+
byCat(e).method = string(method); byCat(e).loadings = L; byCat(e).varExplained = ve;
132+
byCat(e).scores = scores; byCat(e).factorNames = fnames; byCat(e).vars = vinfo;
133+
134+
allScores = [allScores, scores]; %#ok<AGROW>
135+
FN=[FN; fnames(:)]; Cat=[Cat; repmat(ca.name,m,1)]; Mdl=[Mdl; repmat(string(ca.model),m,1)];
136+
Cls=[Cls; repmat(string(ca.class),m,1)]; Meth=[Meth; repmat(string(method),m,1)]; VE=[VE; ve(:)];
137+
138+
if opts.Verbose
139+
fprintf(" %-22s %-9s d=%4d -> %2d factors (%s) var%%=%s\n", ...
140+
ca.name, "["+ca.kind+"]", d, m, method, join(compose("%.0f",ve),"/"));
141+
end
142+
end
143+
144+
EF.scores = allScores;
145+
EF.labels = table(FN, Cat, Mdl, Cls, Meth, VE, 'VariableNames', ...
146+
{'FactorName','Category','Model','Class','Method','VarExplained'});
147+
EF.byCategory = byCat;
148+
EF.stim = C.stim; EF.time_sec = C.time_sec; EF.ids = C.ids;
149+
C.extracted_factors = EF;
150+
151+
if opts.Verbose
152+
fprintf("Extracted %d factors across %d categories (%d timepoints).\n", ...
153+
size(allScores,2), numel(byCat), T);
154+
end
155+
if opts.Save ~= ""
156+
extracted_factors = EF;
157+
save(opts.Save, "extracted_factors", "-v7.3");
158+
fprintf("wrote %s\n", opts.Save);
159+
end
160+
end
161+
162+
% -------------------------------------------------------------------------
163+
function [R, info, Z] = localDropCollinear(R, info, Z)
164+
p = size(R,1); keep = true(p,1);
165+
for i = 2:p
166+
if any(abs(R(i,1:i-1)) > 0.999 & keep(1:i-1)'), keep(i) = false; end
167+
end
168+
R = R(keep,keep); info = info(keep,:); Z = Z(:,keep);
169+
end
170+
171+
function A = localNearestPD(A)
172+
A = (A + A')/2; [V,D] = eig(A); d = diag(D); d(d < 1e-6) = 1e-6;
173+
A = V*diag(d)*V'; A = (A + A')/2; s = sqrt(diag(A)); A = A ./ (s*s');
174+
A = (A + A')/2; A(1:size(A,1)+1:end) = 1;
175+
end
176+
177+
function [V, ev] = localTopEig(R, m)
178+
[V,D] = eig((R+R')/2); ev = diag(D);
179+
[ev, ix] = sort(ev, "descend"); V = V(:, ix);
180+
m = min(m, size(V,2)); V = V(:,1:m); ev = ev(1:m);
181+
end
182+
183+
function mmax = localMaxFactoranM(d)
184+
% largest m>=1 satisfying FACTORAN's dof rule (d-m)^2 >= d+m
185+
mmax = 0;
186+
for m = 1:d-1
187+
if (d-m)^2 >= d+m, mmax = m; end
188+
end
189+
end

0 commit comments

Comments
 (0)