3333
3434from src .model .causal_config import CausalConfig
3535from src .model .causal_model import CausalDebiaser
36- from src .model .recommendation_history import history_tracker
3736
3837logger = logging .getLogger (__name__ )
3938
@@ -53,29 +52,6 @@ def bayesian_rating(
5352
5453
5554class HybridRecommender :
56- def __init__ (self , content_model , collab_model = None , item_df = None ,
57- alpha = 0.4 , beta = 0.35 , gamma = 0.25 ,
58- normalization = 'minmax' , weight_matrix = None ,
59- use_causal_debiasing = False , causal_lambda = 0.5 , causal_clip = 5.0 ,
60- causal_config = None , model_kwargs = None ,
61- kg_model = None , delta = 0.05 ):
62- """
63- content_model: ContentRecommender instance
64- collab_model: CollaborativeRecommender instance (optional)
65- item_df: DataFrame with 'avg_sentiment', 'rating', 'review_count' columns
66- alpha: weight for content-based score
67- beta: weight for collaborative score
68- gamma: weight for sentiment score
69- use_causal_debiasing: Enable IPS-based causal debiasing on the final hybrid score.
70- When True, a CausalDebiaser is built from item_df and applied
71- after the weighted blend, before final ranking.
72- causal_lambda: Blend factor λ for causal correction (0.0–1.0).
73- 0.0 = no debiasing, 1.0 = full IPS reweighting. Default 0.5.
74- causal_clip: Max IPS weight cap to prevent variance explosion. Default 5.0.
75- causal_config: Optional CausalConfig instance. When provided, takes precedence
76- over use_causal_debiasing / causal_lambda / causal_clip.
77- Use this for structured configuration management.
78- """
7955 """Hybrid recommender combining content + collaborative + sentiment."""
8056
8157 def __init__ (
@@ -148,12 +124,6 @@ def __init__(
148124
149125 self .online_updater = None
150126
151- # Bandit exploration
152- self .epsilon = 0.1
153- self .bandit_arms = [(self .alpha , self .beta , self .gamma )]
154- self .arm_rewards = {0 : 0.0 }
155- self .arm_counts = {0 : 0 }
156-
157127 if item_df is not None :
158128 global_avg = float (item_df ["rating" ].mean ()) if "rating" in item_df .columns else 3.0
159129
@@ -209,58 +179,14 @@ def get_weights(self):
209179 'beta' : self .beta ,
210180 'gamma' : self .gamma ,
211181 'delta' : self .delta ,
212- }
213-
182+ }
214183
215184 def select_bandit_arm (self ):
216185 import random
217186
218187 if random .random () < self .epsilon :
219188 return random .randint (0 , len (self .bandit_arms ) - 1 )
220189
221- review_count = int (review_count )
222- self ._review_count_map [title ] = review_count
223- self ._rating_map [title ] = bayesian_rating (
224- raw_rating , review_count , global_avg
225- )
226- self ._category_map [title ] = row .get ('category' , '' )
227- self ._catalog_map [title ] = row .get ('catalog' , '' )
228-
229- # Popularity rank (0-1 scale, higher = more popular)
230- if 'review_count' in item_df .columns :
231- max_reviews = item_df ['review_count' ].max ()
232- if max_reviews > 0 :
233- for _ , row in item_df .iterrows ():
234- self ._popularity_map [row ['title' ]] = (
235- row ['review_count' ] / max_reviews
236- )
237-
238- # Optional runtime hook for online updates (attachable)
239- self .online_updater = None
240-
241- def set_weights (self , alpha , beta , gamma , delta = 0.05 ):
242- """Update the scoring weights. Normalized to sum to 1.
243-
244- Args:
245- alpha: weight for content_score
246- beta: weight for collab_score
247- gamma: weight for sentiment_score
248- delta: weight for popularity (default 0.05). All four weights are
249- normalized to sum to 1.0, guaranteeing hybrid_score in [0, 1].
250- """
251- if any (math .isnan (w ) for w in [alpha , beta , gamma , delta ]):
252- raise ValueError ("Weights must be finite numbers" )
253- if any (w < 0 for w in [alpha , beta , gamma , delta ]):
254- raise ValueError ("Weights must be non-negative" )
255- total = alpha + beta + gamma + delta
256- if total == 0 :
257- total = 1
258- self .alpha = alpha / total
259- self .beta = beta / total
260- self .gamma = gamma / total
261- self .delta = delta / total
262- def get_weights (self ):
263- return {'alpha' : self .alpha , 'beta' : self .beta , 'gamma' : self .gamma , 'delta' : self .delta }
264190 best_arm = max (
265191 self .arm_rewards ,
266192 key = lambda x : self .arm_rewards [x ] / max (self .arm_counts [x ], 1 )
@@ -271,7 +197,7 @@ def get_weights(self):
271197 def update_bandit_reward (self , arm_id , reward ):
272198 self .arm_counts [arm_id ] += 1
273199 self .arm_rewards [arm_id ] += reward
274-
200+ return { 'alpha' : self . alpha , 'beta' : self . beta , 'gamma' : self . gamma , 'delta' : self . delta }
275201
276202 # ------------------------- fairness helpers -------------------------
277203 def set_fairness (self , enabled = None , key = None , max_share = None ):
@@ -288,7 +214,6 @@ def set_fairness(self, enabled=None, key=None, max_share=None):
288214 def _fair_rerank (self , results : list [dict [str , Any ]], top_n : int , key : str , max_share : float ):
289215 if not results or top_n <= 1 :
290216 return results [:top_n ]
291-
292217 try :
293218 max_share = float (max_share )
294219 except Exception :
@@ -343,31 +268,25 @@ def _normalize_scores(self, scores: list[float]) -> list[float]:
343268
344269 def _get_active_weights (
345270 self ,
346- base_a : float ,
347- base_b : float ,
348- base_g : float ,
349- base_d : float = 0.0 ,
350- user_id : str | None = None ,
351271 candidate_titles : list [str ] | None = None ,
352- ) -> tuple [float , float , float , float ]:
272+ user_id : str | None = None ,
273+ ) -> tuple [float , float , float ]:
353274 """Resolve active weights using weight_matrix and runtime signals."""
354275
355- a , b , g , d = float (base_a ), float (base_b ), float (base_g ), float ( base_d )
276+ a , b , g = float (self . alpha ), float (self . beta ), float (self . gamma )
356277
357- def unpack_weights (val , default_d = 0.0 ):
278+ def unpack_weights (val ):
358279 if isinstance (val , (list , tuple )):
359- if len (val ) >= 4 :
360- return float (val [0 ]), float (val [1 ]), float (val [2 ]), float (val [3 ])
361- if len (val ) == 3 :
362- return float (val [0 ]), float (val [1 ]), float (val [2 ]), default_d
280+ if len (val ) >= 3 :
281+ return float (val [0 ]), float (val [1 ]), float (val [2 ])
363282 if len (val ) == 2 :
364- return float (val [0 ]), float (val [1 ]), 0.0 , default_d
283+ return float (val [0 ]), float (val [1 ]), 0.0
365284 return None
366285
367286 if "default" in self .weight_matrix :
368- w = unpack_weights (self .weight_matrix ["default" ], d )
287+ w = unpack_weights (self .weight_matrix ["default" ])
369288 if w is not None :
370- a , b , g , d = w
289+ a , b , g = w
371290
372291 # category override
373292 if candidate_titles and self .item_df is not None and {"title" , "category" }.issubset (self .item_df .columns ):
@@ -382,42 +301,31 @@ def unpack_weights(val, default_d=0.0):
382301 top_cat = Counter (cats ).most_common (1 )[0 ][0 ]
383302 key = f"category:{ top_cat } "
384303 if key in self .weight_matrix :
385- w = unpack_weights (self .weight_matrix [key ], d )
304+ w = unpack_weights (self .weight_matrix [key ])
386305 if w is not None :
387- a , b , g , d = w
306+ a , b , g = w
388307 except Exception :
389308 logger .warning ("weight_matrix category override failed" , exc_info = True )
390309
391- # user signals
392- if user_id and self .collab_model and hasattr (self .collab_model , 'df' ):
393- try :
394- user_interacts = int (len (self .collab_model .df [self .collab_model .df ['user_id' ] == user_id ]))
395- if 'warm_user' in self .weight_matrix and user_interacts > 10 :
396- w = unpack_weights (self .weight_matrix ['warm_user' ], d )
397- if w is not None :
398- a , b , g , d = w
399- if 'cold_user' in self .weight_matrix and user_interacts < 3 :
400- w = unpack_weights (self .weight_matrix ['cold_user' ], d )
401- if w is not None :
402- a , b , g , d = w
403- except Exception :
404- pass
405-
406310 # feature absence overrides
407311 if self .collab_model is None and "no_collab" in self .weight_matrix :
408- w = unpack_weights (self .weight_matrix ["no_collab" ], d )
312+ w = unpack_weights (self .weight_matrix ["no_collab" ])
409313 if w is not None :
410- a , b , g , d = w
314+ a , b , g = w
411315
412316 if not self ._sentiment_map and "no_sentiment" in self .weight_matrix :
413- w = unpack_weights (self .weight_matrix ["no_sentiment" ], d )
317+ w = unpack_weights (self .weight_matrix ["no_sentiment" ])
414318 if w is not None :
415- a , b , g , d = w
319+ a , b , g = w
416320
417- total = a + b + g + d
321+ if self .kg_model is None and "no_kg" in self .weight_matrix :
322+ # KG weight handled via delta; keep legacy keys but ignore in 3-way blend
323+ pass
324+
325+ total = a + b + g
418326 if total <= 0 :
419- return base_a , base_b , base_g , base_d
420- return a / total , b / total , g / total , d / total
327+ return float ( self . alpha ), float ( self . beta ), float ( self . gamma )
328+ return a / total , b / total , g / total
421329
422330 # ------------------------- main recommend -------------------------
423331 def recommend (
@@ -523,7 +431,7 @@ def recommend(
523431 a , b , g = getattr (self , 'bandit_arms' , [(self .alpha , self .beta , self .gamma )])[arm_id ]
524432
525433 a , b , g , d = self ._get_active_weights (
526- a , b , g , getattr (self , 'delta' , 0.0 ),
434+ a , b , g , getattr (self , 'delta' , 0 ),
527435 user_id = user_id ,
528436 )
529437 d = self .delta if self .kg_model else 0.0
@@ -554,19 +462,6 @@ def recommend(
554462 avg_rating = float (self ._rating_map .get (item ["title" ], 0.0 ) or 0.0 )
555463 category = self ._category_map .get (item ["title" ], "" )
556464
557- # Popularity as a proper fourth component weighted by delta.
558- # Weights a, b, g are re-normalized here to leave room for delta,
559- # guaranteeing hybrid_score in [0, 1].
560- popularity = self ._popularity_map .get (item ['title' ], 0.5 )
561- weight_sum = a + b + g + self .delta
562- if weight_sum <= 0 :
563- weight_sum = 1.0
564- hybrid = (
565- (a * content_scores [i ] +
566- b * collab_scores [i ] +
567- g * sentiment_scores [i ] +
568- self .delta * popularity ) / weight_sum
569- )
570465 popularity_bonus = 0.05 * popularity
571466
572467 # Enforce strict upper bound limit check
@@ -722,7 +617,7 @@ def _build_explanation(
722617 'content' : round (alpha * content_score , 4 ),
723618 'collaborative' : round (beta * collab_score , 4 ),
724619 'sentiment' : round (gamma * sentiment_score , 4 ),
725- 'popularity_bonus' : round (self . delta * popularity , 4 ),
620+ 'popularity_bonus' : round (0.05 * popularity , 4 ),
726621 }
727622 strongest = max (weighted_components , key = weighted_components .get )
728623
0 commit comments