@@ -226,11 +226,19 @@ def get_cv(self, weights):
226226 mu = weights_arr .mean (axis = 0 )
227227
228228 with np .errstate (divide = 'ignore' , invalid = 'ignore' ):
229- cv = (std ** 2 ) / (mu ** 2 )
230- cv [mu == 0 ] = 0.0
229+ cv = (std ** 2 ) / (mu ** 2 + std ** 2 )
231230
232231 return np .nan_to_num (cv )
233232
233+ # def get_cv(self, weights):
234+ # weights_arr = np.asarray(weights)
235+ # q1, q3 = np.percentile(weights_arr, [25, 75], axis=0)
236+ # spread = (q3 - q1) / 1.349 # IQR/1.349 ≈ σ for Gaussian
237+ # center = np.median(weights_arr, axis=0)
238+ # with np.errstate(divide='ignore', invalid='ignore'):
239+ # cv = spread ** 2 / (center ** 2 + spread ** 2)
240+ # return np.nan_to_num(cv)
241+
234242 def fit (self , X , y , sample_weights = None ):
235243 n_samples , n_features = X .shape
236244
@@ -244,8 +252,7 @@ def fit(self, X, y, sample_weights=None):
244252
245253 # Precompute static operations for speed
246254 norm_sq_features = np .sum (X_aug ** 2 , axis = 0 )
247- X_T_y = X_aug .T @ y
248- max_corr = np .max (np .abs (X_T_y )) # Global max correlation anchors the penalty
255+ X_T_y = X_aug .T @ y # Cached once; slice by active_mask each outer iter.
249256
250257 outer_iteration = 0
251258 max_outer_iters = total_features # Max possible eliminations
@@ -267,27 +274,36 @@ def fit(self, X, y, sample_weights=None):
267274 grid_shape = self .grid_shape ,
268275 fit_intercept = intercept_is_active
269276 )
270- self .cached_weights_ = weights
277+
278+ # Slice data for the CD run
279+ X_active = X_aug [:, active_mask ]
280+ norm_sq_active = norm_sq_features [active_mask ]
281+
282+ # Anchor the penalty to the max correlation on the SURVIVING subspace
283+ # so threshold scale tracks the current problem as features drop.
284+ max_corr = np .max (np .abs (X_T_y [active_mask ]))
271285
272286 # 3. CV performs as adaptive alpha
273287 active_cv = self .get_cv (weights )
288+ # Tackle the most physically unstable feature first so unstable
289+ # terms get shrunk to zero before they pollute the residual.
290+ cv_order = np .argsort (active_cv )[::- 1 ]
274291 active_thresholds = active_cv * max_corr
275292
276- # Initialize coefficients and slice data for the CD run
293+ # Initialize coefficients
277294 active_coef = weights .mean (axis = 0 )
278- X_active = X_aug [:, active_mask ]
279- norm_sq_active = norm_sq_features [active_mask ]
280295
281296 residual = y - (X_active @ active_coef )
282297
283298 # =================================================================
284299 # INNER LOOP: Pure Coordinate Descent on the Stabilized Library
285300 # =================================================================
286301 cd_iteration = 0
302+ killed_feature = False
287303 while cd_iteration < self .max_iter :
288304 max_change = 0.0
289305
290- for j in range ( len ( active_coef )) :
306+ for j in cv_order :
291307 old_coef = active_coef [j ]
292308 norm_sq = norm_sq_active [j ]
293309
@@ -301,13 +317,23 @@ def fit(self, X, y, sample_weights=None):
301317 residual -= (new_coef - old_coef ) * X_active [:, j ]
302318 active_coef [j ] = new_coef
303319
320+ if new_coef == 0 and old_coef != 0 :
321+ # A feature just died — hand control back to the outer
322+ # loop so CVs/anchor/thresholds get recomputed on the
323+ # smaller library before doing any more CD work.
324+ killed_feature = True
325+ break
326+
304327 with np .errstate (divide = 'ignore' , invalid = 'ignore' ):
305328 change = abs (new_coef - old_coef )
306329 if old_coef != 0 :
307330 change /= abs (old_coef )
308331 if change > max_change :
309332 max_change = change
310333
334+ if killed_feature :
335+ break
336+
311337 # Inner loop convergence check
312338 if max_change <= self .tol :
313339 # You can add your Dual Gap check here if desired,
@@ -334,10 +360,14 @@ def fit(self, X, y, sample_weights=None):
334360 active_mask = new_active_mask
335361 outer_iteration += 1
336362
337- # Emergency break if everything died
363+ # Emergency break if everything died. `weights` still references
364+ # the prior (now-stale) mask, so drop it instead of caching.
338365 if not np .any (active_mask ):
366+ weights = None
339367 break
340368
369+ self .cached_weights_ = weights
370+
341371 # Map back to standard sklearn attributes
342372 self .coef_ = self .full_coef_ [:- 1 ]
343373 self .intercept_ = self .full_coef_ [- 1 ]
0 commit comments