Skip to content

Commit bec14ba

Browse files
committed
fix: do not return invalid coef measures and add times_implemented
1 parent 4b9286a commit bec14ba

3 files changed

Lines changed: 339 additions & 65 deletions

File tree

src/sum_impact_assessment/models/impact_analysis/kpi_impact_analysis.py

Lines changed: 82 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -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

tests/test_jobs/test_kpi_measures_analysis_job.py

Lines changed: 55 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -743,49 +743,55 @@ def test_run_kpi_impact_analysis_modal_split_all_transport_modes_filters_output_
743743
],
744744
)
745745

746-
living_lab = LivingLab(
747-
id="lab1",
748-
name="Lab 1",
749-
measures=[Measure(id="m1", name="Measure 1", times_implemented=1)],
750-
kpis=[
751-
KPILivingLabResult(
752-
id="15",
753-
name="Modal Split KPI",
754-
progression_target=1,
755-
value_type="percentage",
756-
value_min=0,
757-
value_max=1,
758-
living_lab_id="lab1",
759-
transport_mode_type="NSM",
760-
value_before=0.3,
761-
value_after=0.4,
762-
),
763-
KPILivingLabResult(
764-
id="15",
765-
name="Modal Split KPI",
766-
progression_target=1,
767-
value_type="percentage",
768-
value_min=0,
769-
value_max=1,
770-
living_lab_id="lab1",
771-
transport_mode_type="PRIVATE",
772-
value_before=0.5,
773-
value_after=0.45,
774-
),
775-
KPILivingLabResult(
776-
id="15",
777-
name="Modal Split KPI",
778-
progression_target=1,
779-
value_type="percentage",
780-
value_min=0,
781-
value_max=1,
782-
living_lab_id="lab1",
783-
transport_mode_type="PUBLIC_TRANSPORT",
784-
value_before=0.2,
785-
value_after=0.15,
786-
),
787-
],
788-
)
746+
def _make_living_lab(lab_id: str) -> LivingLab:
747+
return LivingLab(
748+
id=lab_id,
749+
name=f"Lab {lab_id}",
750+
measures=[Measure(id="m1", name="Measure 1", times_implemented=1)],
751+
kpis=[
752+
KPILivingLabResult(
753+
id="15",
754+
name="Modal Split KPI",
755+
progression_target=1,
756+
value_type="percentage",
757+
value_min=0,
758+
value_max=1,
759+
living_lab_id=lab_id,
760+
transport_mode_type="NSM",
761+
value_before=0.3,
762+
value_after=0.4,
763+
),
764+
KPILivingLabResult(
765+
id="15",
766+
name="Modal Split KPI",
767+
progression_target=1,
768+
value_type="percentage",
769+
value_min=0,
770+
value_max=1,
771+
living_lab_id=lab_id,
772+
transport_mode_type="PRIVATE",
773+
value_before=0.5,
774+
value_after=0.45,
775+
),
776+
KPILivingLabResult(
777+
id="15",
778+
name="Modal Split KPI",
779+
progression_target=1,
780+
value_type="percentage",
781+
value_min=0,
782+
value_max=1,
783+
living_lab_id=lab_id,
784+
transport_mode_type="PUBLIC_TRANSPORT",
785+
value_before=0.2,
786+
value_after=0.15,
787+
),
788+
],
789+
)
790+
791+
# Two labs are required so m1 is implemented by ≥2 labs and ridge
792+
# regression is actually called (single-lab measures are excluded).
793+
living_lab1 = _make_living_lab("lab1")
794+
living_lab2 = _make_living_lab("lab2")
789795

790796
mock_data_service = mock_data_service_class.return_value
791797
mock_data_service.get_analysis_input_data.return_value = (
@@ -802,14 +808,14 @@ def test_run_kpi_impact_analysis_modal_split_all_transport_modes_filters_output_
802808
],
803809
[Measure(id="m1", name="Measure 1")],
804810
[modal_group],
805-
[living_lab],
811+
[living_lab1, living_lab2],
806812
)
807813

808814
mock_run_ridge_regression.return_value = (
809815
np.zeros(1),
810816
0.0,
811817
0.0,
812-
np.zeros(1),
818+
np.zeros(2), # sqe_per_sample — one entry per feasible lab
813819
)
814820

815821
_, successful_results, error_results = KpiMeasuresAnalysisJob.run_kpi_impact_analysis(
@@ -858,11 +864,13 @@ def test_run_kpi_impact_analysis_modal_split_all_transport_modes_filters_output_
858864
_, nsm_call_X, nsm_call_y = mock_run_ridge_regression.call_args_list[0].args[:3]
859865

860866
# NSM subgroup should use only NSM KPI variations (no PRIVATE/PUBLIC_TRANSPORT contribution).
861-
assert tuple(np.round(nsm_call_y, 5).tolist()) == (10.0,)
867+
# Two identical labs → two identical y entries of 10.0 each.
868+
assert tuple(np.round(nsm_call_y, 5).tolist()) == (10.0, 10.0)
862869

863870
# All subgroup analyses use same living-lab measure input design matrix in this fixture.
871+
# Two labs × one measure → each flattened X column is (1.0, 1.0).
864872
ridge_x_vectors = [
865873
tuple(np.round(call.args[1].flatten(), 5).tolist())
866874
for call in mock_run_ridge_regression.call_args_list
867875
]
868-
assert ridge_x_vectors.count((1.0,)) == 4
876+
assert ridge_x_vectors.count((1.0, 1.0)) == 4

0 commit comments

Comments
 (0)