@@ -184,24 +184,69 @@ def add_living_lab_results(self, output_group:KPIGroupImpactOutput, kpi_group:KP
184184
185185 return output_group
186186
187- def add_measure_results (self , output_group :KPIGroupImpactOutput ,
188- kpi_group :KPIGroup ,
189- coef :np .array
190- ) -> KPIGroupImpactOutput :
187+ def filter_measures_with_min_implementations (
188+ self , X : np .array , measures : list , min_labs : int = 2
189+ ) -> tuple [np .array , list ]:
190+ """
191+ Remove measure columns from X that are implemented in fewer than `min_labs`
192+ distinct feasible living labs (i.e. fewer than `min_labs` non-zero column entries).
193+
194+ A measure implemented by only one lab produces a coefficient that is
195+ statistically meaningless (the regression cannot separate its contribution
196+ from other effects), so it is excluded from the results entirely.
197+
198+ Parameters:
199+ - X (numpy array): data matrix with shape (n_feasible_labs, n_measures)
200+ - measures (list): measure objects corresponding to each column of X
201+ - min_labs (int): minimum number of feasible labs that must implement a
202+ measure for it to be kept (default: 2)
203+
204+ Returns:
205+ - X_filtered (numpy array): X with single-lab-or-never columns removed
206+ - kept_measures (list): measure objects for the retained columns
207+ """
208+ # Count non-zero entries per column (number of labs implementing each measure)
209+ labs_per_measure = np .count_nonzero (X , axis = 0 )
210+ keep_mask = labs_per_measure >= min_labs
211+
212+ X_filtered = X [:, keep_mask ]
213+ kept_measures = [m for m , keep in zip (measures , keep_mask ) if keep ]
214+
215+ return X_filtered , kept_measures
216+
217+ def add_measure_results (
218+ self ,
219+ output_group : KPIGroupImpactOutput ,
220+ kpi_group : KPIGroup ,
221+ measures : list ,
222+ coef : np .array ,
223+ times_implemented_per_measure : list [int ]
224+ ) -> KPIGroupImpactOutput :
191225 """
192226 Add the MeasureImpactCoefficient results to the KPIGroupImpactOutput object.
227+
228+ Parameters:
229+ - output_group: object to populate
230+ - kpi_group: KPI group being analyzed
231+ - measures: list of Measure objects corresponding to entries in `coef`
232+ (may be a subset of self.measures after single-lab filtering)
233+ - coef: ridge regression coefficients aligned with `measures`
234+ - times_implemented_per_measure: total times each measure was implemented
235+ across the feasible living labs analyzed (column sums of the filtered X)
193236 """
194237 results = []
195- for measure in self .measures :
196- index = self .measures .index (measure )
197- result = MeasureImpactCoefficient (id = measure .id ,
198- name = measure .name ,
199- kpi_group_id = kpi_group .id ,
200- coefficient = round (coef [index ], 5 ))
238+ for i , measure in enumerate (measures ):
239+ result = MeasureImpactCoefficient (
240+ id = measure .id ,
241+ name = measure .name ,
242+ kpi_group_id = kpi_group .id ,
243+ coefficient = round (coef [i ], 5 ),
244+ times_implemented = times_implemented_per_measure [i ]
245+ )
201246 results .append (result )
202247
203248 # sort results by coefficient descending
204- results .sort (key = lambda x : x .coefficient , reverse = True )
249+ results .sort (key = lambda x : x .coefficient , reverse = True )
205250 output_group .measure_coefficients = results
206251
207252 return output_group
@@ -232,22 +277,41 @@ def run_analysis_group(self, kpi_group: KPIGroup) -> KPIGroupImpactOutput:
232277 # Initialise data matrix X and target vector y
233278 X , y , feasible_ll , kpi_group = self .compute_X_y_input (kpi_group )
234279
280+ # Remove measures implemented by fewer than 2 feasible living labs;
281+ # their coefficients would be statistically meaningless.
282+ X_filtered , kept_measures = self .filter_measures_with_min_implementations (X , self .measures )
283+
284+ # Initialise output object (populated regardless of whether measures remain)
285+ output_group = KPIGroupImpactOutput (
286+ id = kpi_group .id ,
287+ name = kpi_group .name ,
288+ kpi_ids = kpi_group .kpi_ids
289+ )
290+ output_group = self .add_living_lab_results (output_group , kpi_group , feasible_ll ,
291+ np .zeros (len (feasible_ll )))
292+
293+ if len (kept_measures ) == 0 :
294+ # No meaningful coefficients can be estimated; return empty results.
295+ output_group .measure_coefficients = []
296+ return output_group
297+
235298 # Normalise target vector y
236299 max_variation = self .compute_max_variation (kpi_group )
237300 y = self .normalize_variation (y = y , max_variation = max_variation , target_range = 100.0 )
238301
239302 # Run Ridge Regression & compute Mean Squared Error (MSE)
240- coef , intercept , msqe , sqe_per_sample = self .run_ridge_regression (X , y )
303+ coef , intercept , msqe , sqe_per_sample = self .run_ridge_regression (X_filtered , y )
241304
242- # Update KPIGroup object with analysis results
243- output_group = KPIGroupImpactOutput (id = kpi_group .id ,
244- name = kpi_group .name ,
245- kpi_ids = kpi_group .kpi_ids )
246-
305+ # Update living lab results with actual squared errors
247306 output_group = self .add_living_lab_results (output_group , kpi_group , feasible_ll , sqe_per_sample )
248307 output_group .msqe = msqe
249308 output_group .variation_under_no_measures = intercept
250- output_group = self .add_measure_results (output_group , kpi_group , coef )
309+
310+ # Column sums of filtered X = total times each kept measure was implemented
311+ times_implemented_per_measure = X_filtered .sum (axis = 0 ).tolist ()
312+ output_group = self .add_measure_results (
313+ output_group , kpi_group , kept_measures , coef , times_implemented_per_measure
314+ )
251315
252316 return output_group
253317
0 commit comments