Skip to content

Commit 9b682fa

Browse files
committed
feat: user personalized MCDA with custom weights
1 parent 902c272 commit 9b682fa

6 files changed

Lines changed: 275 additions & 33 deletions

File tree

src/sum_impact_assessment/api/routes/jobs.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,25 @@ def trigger_job(
7070
}
7171
}
7272
},
73+
"mcda_qualitative_user_personalized": {
74+
"summary": "Qualitative MCDA Analysis for user-personalized perspective",
75+
"description": "Run MCDA analysis with user-personalized stakeholder weights, from qualitative data from expert surveys",
76+
"value": {
77+
"params": {
78+
"perspective": "user_personalized",
79+
"goals_weights": {
80+
"Improve Accessibility": 0.12,
81+
"Improve Mobility Service": 0.16,
82+
"Improve Multimodality": 0.13,
83+
"Noise Hinderance": 0.07,
84+
"Improve Public Transport": 0.12,
85+
"Reduction of Congestion": 0.15,
86+
"Reduction of Emission": 0.14,
87+
"Improve Safety": 0.11
88+
}
89+
}
90+
}
91+
},
7392
"mcda_qualitative_regulatory_perspective": {
7493
"summary": "Qualitative MCDA Analysis for regulatory perspective",
7594
"description": "Run MCDA analysis with regulatory stakeholder weights, from qualitative data from expert surveys",
@@ -97,6 +116,26 @@ def trigger_job(
97116
}
98117
}
99118
},
119+
"mcda_quantitative_user_personalized": {
120+
"summary": "Quantitative MCDA Analysis for user-personalized perspective",
121+
"description": "Run MCDA analysis with user-personalized stakeholder weights, from Quantitative data from KPI/measures impact analysis",
122+
"value": {
123+
"params": {
124+
"perspective": "user_personalized",
125+
"kpi_group_type": "MCDA_GOALS",
126+
"goals_weights": {
127+
"Improve Accessibility": 0.12,
128+
"Improve Mobility Service": 0.16,
129+
"Improve Multimodality": 0.13,
130+
"Noise Hinderance": 0.07,
131+
"Improve Public Transport": 0.12,
132+
"Reduction of Congestion": 0.15,
133+
"Reduction of Emission": 0.14,
134+
"Improve Safety": 0.11
135+
}
136+
}
137+
}
138+
},
100139
"mcda_quantitative_regulatory_perspective": {
101140
"summary": "Quantitative MCDA Analysis for regulatory perspective",
102141
"description": "Run MCDA analysis with regulatory stakeholder weights, from quantitative data form KPI/measures impact analysis",

src/sum_impact_assessment/services/mcda_qualitative_job.py

Lines changed: 35 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from ..schemas.job import JobStatusEnum
1010
from ..schemas.mcda import Goal, Alternative
1111
from ..utils.logger import get_logger
12-
from ..utils.data_loaders import get_goal_weights_for_perspective, load_mcda_config
12+
from ..utils.data_loaders import get_goal_weights_for_perspective, load_mcda_config, normalize_goal_weights
1313

1414
# Initialize logger
1515
logger = get_logger(__name__)
@@ -26,19 +26,11 @@ class McdaQualitativeJob:
2626
4. Saves structured MCDA input and output snapshots
2727
"""
2828

29-
@staticmethod
30-
def _normalize_goal_weights(goal_weights: Dict[str, float]) -> Dict[str, float]:
31-
"""Normalize all goal weight keys to lower-case."""
32-
return {
33-
goal_name : weight
34-
for goal_name, weight in goal_weights.items()
35-
}
36-
3729
@staticmethod
3830
def _normalize_goal_scores(goal_scores: Dict[str, float]) -> Dict[str, float]:
3931
"""Normalize all goal score keys to lower-case."""
4032
return {
41-
goal_name : score
33+
goal_name: score
4234
for goal_name, score in goal_scores.items()
4335
}
4436

@@ -108,7 +100,8 @@ def build_alternatives(config: Dict[str, Any]) -> list[Alternative]:
108100

109101
alternatives = []
110102
for activity_key, activity_goal_scores in goals_score.items():
111-
values = McdaQualitativeJob._normalize_goal_scores(activity_goal_scores)
103+
values = McdaQualitativeJob._normalize_goal_scores(
104+
activity_goal_scores)
112105
alternative = Alternative(
113106
name=activity_labels.get(activity_key, activity_key),
114107
values=values
@@ -136,11 +129,6 @@ def build_goals(alternatives: list[Alternative], goal_weights: Optional[Dict[str
136129
if not alternatives:
137130
return []
138131

139-
normalized_goal_weights = (
140-
McdaQualitativeJob._normalize_goal_weights(goal_weights)
141-
if goal_weights else None
142-
)
143-
144132
goal_names = list(alternatives[0].values.keys())
145133
if not goal_names:
146134
return []
@@ -150,7 +138,7 @@ def build_goals(alternatives: list[Alternative], goal_weights: Optional[Dict[str
150138

151139
for goal_name in goal_names:
152140
weight = McdaQualitativeJob._get_weight_by_goal(
153-
goal_name, normalized_goal_weights, default_weight)
141+
goal_name, goal_weights, default_weight)
154142

155143
min_value, max_value = McdaQualitativeJob._get_min_max_values_per_goal(
156144
goal_name, alternatives)
@@ -196,7 +184,7 @@ def get_goal_weights(perspective: Optional[str]) -> Optional[Dict[str, float]]:
196184
goal_weights = get_goal_weights_for_perspective(perspective)
197185
logger.info(
198186
f"Using qualitative goal weights for perspective: {perspective}")
199-
return McdaQualitativeJob._normalize_goal_weights(goal_weights)
187+
return goal_weights
200188
except ValueError as e:
201189
logger.warning(
202190
f"Failed to load perspective weights: {e}. Using equal weights.")
@@ -227,9 +215,16 @@ def run(job_id: str, db: Session, params: Optional[Dict] = None) -> None:
227215
)
228216

229217
perspective = params.get("perspective") if params else None
218+
analysis_name = params.get("name") if params else None
219+
personalized_goal_weights = params.get(
220+
"goals_weights") if params else None
230221
logger.debug(
231222
"Qualitative MCDA analysis parameters",
232-
extra={"perspective": perspective}
223+
extra={
224+
"perspective": perspective,
225+
"analysis_name": analysis_name,
226+
"personalized_goal_weights": bool(personalized_goal_weights)
227+
}
233228
)
234229

235230
config = load_mcda_config()
@@ -238,16 +233,25 @@ def run(job_id: str, db: Session, params: Optional[Dict] = None) -> None:
238233
"perspectives": config.get("perspectives", {}),
239234
"business_activities": config.get("business_activities", {})
240235
}
241-
job_repo.update_job_data(job_id=job_id, input_data=input_data_snapshot)
236+
job_repo.update_job_data(
237+
job_id=job_id, input_data=input_data_snapshot)
238+
239+
if perspective == "user_personalized" and personalized_goal_weights:
240+
goal_weights = normalize_goal_weights(
241+
personalized_goal_weights)
242+
logger.info("Using user-personalized qualitative goal weights")
243+
else:
244+
goal_weights = McdaQualitativeJob.get_goal_weights(perspective)
242245

243-
goal_weights = McdaQualitativeJob.get_goal_weights(perspective)
244246
alternatives = McdaQualitativeJob.build_alternatives(config)
245247
goals = McdaQualitativeJob.build_goals(alternatives, goal_weights)
246248

247249
if not alternatives:
248-
raise Exception("No business activities were found. Cannot proceed with qualitative MCDA.")
250+
raise Exception(
251+
"No business activities were found. Cannot proceed with qualitative MCDA.")
249252
if not goals:
250-
raise Exception("No goals were built from business activity scores. Cannot proceed with qualitative MCDA.")
253+
raise Exception(
254+
"No goals were built from business activity scores. Cannot proceed with qualitative MCDA.")
251255

252256
mcda_analyzer = PrometheeGaiaAnalyzer(
253257
goals=goals,
@@ -256,22 +260,26 @@ def run(job_id: str, db: Session, params: Optional[Dict] = None) -> None:
256260

257261
mcda_input_data_snapshot = {
258262
"perspective": perspective,
263+
"name": analysis_name,
259264
"goals": [goal.model_dump() for goal in goals],
260265
"alternatives": [alt.model_dump() for alt in alternatives],
261266
"timestamp": datetime.utcnow().isoformat()
262267
}
263268
input_data_snapshot.update(mcda_input_data_snapshot)
264-
job_repo.update_job_data(job_id=job_id, input_data=input_data_snapshot)
269+
job_repo.update_job_data(
270+
job_id=job_id, input_data=input_data_snapshot)
265271

266272
mcda_output = mcda_analyzer.run_analysis(run_visualizations=False)
267273

268274
output_data_snapshot = {
275+
"name": analysis_name,
269276
"kpi_impact_results": [],
270277
"kpi_impact_errors": [],
271278
"mcda_results": mcda_output.model_dump(),
272279
"timestamp": datetime.utcnow().isoformat()
273280
}
274-
job_repo.update_job_data(job_id=job_id, output_data=output_data_snapshot)
281+
job_repo.update_job_data(
282+
job_id=job_id, output_data=output_data_snapshot)
275283

276284
top_alt_key = mcda_output.ranking[0] if mcda_output.ranking else "N/A"
277285
top_alt_name = mcda_output.alternative_labels.get(
@@ -291,7 +299,8 @@ def run(job_id: str, db: Session, params: Optional[Dict] = None) -> None:
291299
message=success_message,
292300
completed_at=datetime.utcnow()
293301
)
294-
logger.info(f"Qualitative MCDA analysis job completed successfully: {job_id}")
302+
logger.info(
303+
f"Qualitative MCDA analysis job completed successfully: {job_id}")
295304

296305
except Exception as e:
297306
error_message = f"McdaQualitativeJob failed: {str(e)}"

src/sum_impact_assessment/services/mcda_quantitative_job.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from ..schemas.mcda import Goal, Alternative
1212
from ..schemas.core import Measure
1313
from ..utils.logger import get_logger
14-
from ..utils.data_loaders import get_goal_weights_for_perspective
14+
from ..utils.data_loaders import get_goal_weights_for_perspective, normalize_goal_weights
1515

1616
# Initialize logger
1717
logger = get_logger(__name__)
@@ -232,11 +232,16 @@ def run(job_id: str, db: Session, params: Optional[Dict] = None) -> None:
232232
# Extract optional parameters
233233
kpi_group_filter = params.get("kpi_group_type") if params else None
234234
perspective = params.get("perspective") if params else None
235+
analysis_name = params.get("name") if params else None
236+
personalized_goal_weights = params.get(
237+
"goals_weights") if params else None
235238
logger.debug(
236239
f"MCDA analysis parameters",
237240
extra={
238241
"kpi_group_filter": kpi_group_filter,
239-
"perspective": perspective
242+
"perspective": perspective,
243+
"analysis_name": analysis_name,
244+
"personalized_goal_weights": bool(personalized_goal_weights)
240245
}
241246
)
242247

@@ -269,7 +274,14 @@ def run(job_id: str, db: Session, params: Optional[Dict] = None) -> None:
269274

270275
# Build Goals from KPI groups with perspective-based weights
271276
# Load weights from perspective if provided, otherwise use equal weights
272-
goal_weights = McdaQuantitativeJob.get_goal_weights(perspective)
277+
if perspective == "user_personalized" and personalized_goal_weights:
278+
goal_weights = normalize_goal_weights(
279+
personalized_goal_weights)
280+
logger.info(
281+
"Using user-personalized quantitative goal weights")
282+
else:
283+
goal_weights = McdaQuantitativeJob.get_goal_weights(
284+
perspective)
273285

274286
# Build Alternatives from measures
275287
alternatives = McdaQuantitativeJob.build_alternatives(
@@ -289,6 +301,7 @@ def run(job_id: str, db: Session, params: Optional[Dict] = None) -> None:
289301
# save MCDA input data snapshot, added to previous input_data_snapshot
290302
mcda_input_data_snapshot = {
291303
"perspective": perspective,
304+
"name": analysis_name,
292305
"goals": [goal.model_dump() for goal in goals],
293306
"alternatives": [alt.model_dump() for alt in alternatives],
294307
"timestamp": datetime.utcnow().isoformat()
@@ -313,6 +326,7 @@ def run(job_id: str, db: Session, params: Optional[Dict] = None) -> None:
313326
# PHASE 5: Save output data
314327
logger.debug("Phase 5: Saving MCDA output")
315328
output_data_snapshot = {
329+
"name": analysis_name,
316330
"kpi_impact_results": kpi_impact_results,
317331
"kpi_impact_errors": error_results,
318332
"mcda_results": mcda_output.model_dump(),

src/sum_impact_assessment/utils/data_loaders.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"""
44
import json
55
from pathlib import Path
6-
from typing import Dict, Any
6+
from typing import Dict, Any, Optional
77

88
# Path to data directory (relative to this file)
99
DATA_DIR = Path(__file__).parent.parent / "data"
@@ -60,6 +60,7 @@ def load_mcda_goal_weights() -> Dict[str, Dict[str, float]]:
6060
config = load_mcda_config()
6161
return config.get("perspectives", {}).get("weights", {})
6262

63+
6364
def get_mcda_perspectives_labels() -> Dict[str, Dict[str, str]]:
6465
"""
6566
Get the labels for MCDA perspectives and their goals.
@@ -90,7 +91,6 @@ def get_mcda_perspectives_labels() -> Dict[str, Dict[str, str]]:
9091
return config.get("perspectives", {}).get("labels", {})
9192

9293

93-
9494
def get_goal_weights_for_perspective(perspective: str) -> Dict[str, float]:
9595
"""
9696
Get goal weights for a specific perspective.
@@ -114,3 +114,32 @@ def get_goal_weights_for_perspective(perspective: str) -> Dict[str, float]:
114114
)
115115

116116
return all_weights[perspective]
117+
118+
119+
def normalize_goal_weights(goal_weights: Optional[Dict[str, float]]) -> Optional[Dict[str, float]]:
120+
"""
121+
Normalize goal weights so the total equals 1.0 (100%).
122+
123+
Args:
124+
goal_weights: Dictionary mapping goal names to non-negative weights.
125+
126+
Returns:
127+
Normalized weights dictionary, or None if goal_weights is None.
128+
129+
Raises:
130+
ValueError: If goal_weights is empty or total weight is zero.
131+
"""
132+
if goal_weights is None:
133+
return None
134+
135+
if not goal_weights:
136+
raise ValueError("Goal weights are empty and cannot be normalized")
137+
138+
total_weight = sum(goal_weights.values())
139+
if total_weight == 0:
140+
raise ValueError("Goal weights sum to zero and cannot be normalized")
141+
142+
return {
143+
goal_name: weight / total_weight
144+
for goal_name, weight in goal_weights.items()
145+
}

0 commit comments

Comments
 (0)