Skip to content

Commit bd7d998

Browse files
committed
Add picard_python_port.m: faithful MATLAB port of solver.py
Self-contained MATLAB function replicating Python picard v0.8.2 line-by-line: centering, SVD whitening with K-row sign flip, L-BFGS core loop with Tanh density. Achieves AMARI < 5e-7 vs Python on real 64-channel EEG data.
1 parent c82ae7a commit bd7d998

1 file changed

Lines changed: 238 additions & 0 deletions

File tree

matlab_octave/picard_python_port.m

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
function [K, W, Y] = picard_python_port(X, varargin)
2+
% PICARD_PYTHON_PORT Faithful MATLAB port of Python picard solver.
3+
%
4+
% Replicates Python's picard v0.8.2 solver.py + _core_picard.py exactly:
5+
% - Centering: subtract row means
6+
% - Whitening: SVD on data (not covariance), K = (u/d)' * sqrt(T)
7+
% - w_init: identity (deterministic)
8+
% - Core loop: L-BFGS with Tanh density (alpha=1)
9+
% - Standard (non-ortho) Picard
10+
%
11+
% Usage:
12+
% [K, W, Y] = picard_python_port(X)
13+
% [K, W, Y] = picard_python_port(X, 'max_iter', 512, 'tol', 1e-7, ...)
14+
%
15+
% Returns:
16+
% K - whitening matrix [N x N]
17+
% W - unmixing in whitened space [N x N]
18+
% Y - estimated sources [N x T]
19+
%
20+
% Full unmixing: icaweights = W * K
21+
22+
% Defaults matching Python
23+
opt.max_iter = 512;
24+
opt.tol = 1e-7;
25+
opt.m = 10;
26+
opt.lambda_min = 0.01;
27+
opt.ls_tries = 10;
28+
opt.verbose = true;
29+
opt.centering = true;
30+
31+
% Parse varargin
32+
for i = 1:2:length(varargin)
33+
opt.(lower(varargin{i})) = varargin{i+1};
34+
end
35+
36+
X = double(X);
37+
[N, T] = size(X);
38+
39+
% --- Step 1: Centering (solver.py line 170) ---
40+
if opt.centering
41+
X_mean = mean(X, 2);
42+
X = X - repmat(X_mean, 1, T);
43+
end
44+
45+
% --- Step 2: Whitening via SVD on data (solver.py lines 172-177) ---
46+
[u, d_mat, ~] = svd(X, 'econ');
47+
d = diag(d_mat);
48+
K = diag(sqrt(T) ./ d) * u';
49+
K = K(1:N, :);
50+
% enforce fixed-sign for consistency (solver.py lines 178-182, v0.8.2)
51+
% Flip each row of K so max-abs element is positive.
52+
for row = 1:size(K, 1)
53+
[~, j] = max(abs(K(row, :)));
54+
if K(row, j) < 0
55+
K(row, :) = -K(row, :);
56+
end
57+
end
58+
X = K * X;
59+
60+
% --- Step 3: Apply w_init = I (solver.py line 199) ---
61+
w_init = eye(N);
62+
X = w_init * X;
63+
64+
% --- Step 4: Core Picard (_core_picard.py) ---
65+
% Tanh density with alpha=1: score=tanh(Y), der=1-tanh(Y)^2
66+
67+
W_algo = eye(N);
68+
Y = X;
69+
s_list = {};
70+
y_list = {};
71+
r_list = {};
72+
current_loss = compute_loss(Y, W_algo);
73+
G_old = [];
74+
75+
for n_iter = 1:opt.max_iter
76+
% Score function: Tanh (alpha=1)
77+
psiY = tanh(Y);
78+
psidY = 1 - psiY.^2;
79+
80+
% Relative gradient (_core_picard.py line 90)
81+
% np.inner(psiY, Y) for 2D = psiY @ Y.T
82+
G = (psiY * Y') / T;
83+
84+
% h_off for non-ortho (_core_picard.py line 111)
85+
h_off = ones(N, 1);
86+
87+
% Hessian diagonal (_core_picard.py line 119)
88+
Y_square = Y.^2;
89+
h = (psidY * Y_square') / T;
90+
91+
% Regularize hessian (_core_picard.py line 236-242)
92+
h = regularize_hessian(h, h_off, opt.lambda_min);
93+
94+
% Subtract identity for non-ortho (_core_picard.py line 126)
95+
G = G - eye(N);
96+
97+
% Stopping criterion (_core_picard.py line 128)
98+
gradient_norm = max(abs(G(:)));
99+
if gradient_norm < opt.tol
100+
if opt.verbose
101+
fprintf('Converged at iteration %d, gradient norm = %.6g\n', n_iter, gradient_norm);
102+
end
103+
break
104+
end
105+
106+
% Update L-BFGS memory (_core_picard.py lines 131-138)
107+
if n_iter > 1
108+
s_list{end+1} = direction;
109+
y_diff = G - G_old;
110+
y_list{end+1} = y_diff;
111+
r_list{end+1} = 1.0 / sum(sum(direction .* y_diff));
112+
if length(s_list) > opt.m
113+
s_list = s_list(2:end);
114+
y_list = y_list(2:end);
115+
r_list = r_list(2:end);
116+
end
117+
end
118+
G_old = G;
119+
120+
% L-BFGS direction (_core_picard.py line 145)
121+
direction = lbfgs_direction(G, h, h_off, s_list, y_list, r_list);
122+
123+
% Line search (_core_picard.py line 148)
124+
[converged, new_Y, new_W, new_loss, direction] = ...
125+
line_search_fn(Y, W_algo, direction, current_loss, opt.ls_tries, opt.verbose);
126+
127+
if ~converged
128+
direction = -G;
129+
s_list = {};
130+
y_list = {};
131+
r_list = {};
132+
[~, new_Y, new_W, new_loss, direction] = ...
133+
line_search_fn(Y, W_algo, direction, current_loss, 10, false);
134+
end
135+
136+
Y = new_Y;
137+
W_algo = new_W;
138+
current_loss = new_loss;
139+
140+
if opt.verbose
141+
fprintf('iteration %d, gradient norm = %.4g, loss = %.4g\n', ...
142+
n_iter, gradient_norm, current_loss);
143+
end
144+
end
145+
146+
% Final W: compose with w_init (_core_picard.py line 209 in solver.py)
147+
W = W_algo * w_init;
148+
149+
end % picard_python_port
150+
151+
152+
% ===== Nested functions =====
153+
154+
function loss_val = compute_loss(Y, W)
155+
% Loss for standard (non-ortho) Picard with Tanh density (alpha=1)
156+
% _core_picard.py _loss function
157+
N = size(Y, 1);
158+
% -log|det(W)|
159+
loss_val = -log(abs(det(W)));
160+
% Tanh log_lik: |y| + log1p(exp(-2|y|))
161+
for k = 1:N
162+
y = Y(k, :);
163+
loss_val = loss_val + mean(abs(y) + log1p(exp(-2 * abs(y))));
164+
end
165+
end
166+
167+
168+
function h = regularize_hessian(h, h_off, lambda_min)
169+
% _core_picard.py _regularize_hessian
170+
N = size(h, 1);
171+
discr = sqrt((h - h').^2 + 4.0 * (h_off * h_off'));
172+
eigenvalues = 0.5 * (h + h' - discr);
173+
problematic_locs = eigenvalues < lambda_min;
174+
problematic_locs(1:(N+1):N*N) = false; % exclude diagonal
175+
[i_pb, j_pb] = find(problematic_locs);
176+
for idx = 1:length(i_pb)
177+
h(i_pb(idx), j_pb(idx)) = h(i_pb(idx), j_pb(idx)) + ...
178+
lambda_min - eigenvalues(i_pb(idx), j_pb(idx));
179+
end
180+
end
181+
182+
183+
function out = solve_hessian(h, h_off, G)
184+
% _core_picard.py _solve_hessian
185+
det_val = h .* h' - (h_off * h_off');
186+
out = (h' .* G - (h_off * ones(1, size(G, 2))) .* G') ./ det_val;
187+
end
188+
189+
190+
function direction = lbfgs_direction(G, h, h_off, s_list, y_list, r_list)
191+
% _core_picard.py _l_bfgs_direction (non-ortho branch)
192+
q = G;
193+
a_list = {};
194+
for ii = 1:length(s_list)
195+
s = s_list{end - ii + 1};
196+
y = y_list{end - ii + 1};
197+
r = r_list{end - ii + 1};
198+
alpha = r * sum(sum(s .* q));
199+
a_list{end+1} = alpha;
200+
q = q - alpha * y;
201+
end
202+
% Solve hessian (non-ortho)
203+
z = solve_hessian(h, h_off, q);
204+
for ii = 1:length(s_list)
205+
s = s_list{ii};
206+
y = y_list{ii};
207+
r = r_list{ii};
208+
alpha = a_list{end - ii + 1};
209+
beta = r * sum(sum(y .* z));
210+
z = z + (alpha - beta) * s;
211+
end
212+
direction = -z;
213+
end
214+
215+
216+
function [converged, Y_new, W_new, new_loss, rel_step] = ...
217+
line_search_fn(Y, W, direction, current_loss, ls_tries, verbose)
218+
% _core_picard.py _line_search (non-ortho branch, v0.8.2)
219+
N = size(W, 1);
220+
alpha = 1.0;
221+
for tmp = 1:ls_tries
222+
transform = eye(N) + alpha * direction;
223+
Y_new = transform * Y;
224+
W_new = transform * W;
225+
new_loss = compute_loss(Y_new, W_new);
226+
if isfinite(new_loss) && new_loss < current_loss
227+
converged = true;
228+
rel_step = alpha * direction;
229+
return
230+
end
231+
alpha = alpha / 2.0;
232+
end
233+
if verbose
234+
fprintf('line search failed, falling back to gradient.\n');
235+
end
236+
converged = false;
237+
rel_step = alpha * direction;
238+
end

0 commit comments

Comments
 (0)