""" MetaProcessor - Tier 4: META Features (52 columns) Long-term patterns and meta-features: - Stability (8 columns): volatility, recent form, win/loss rating - Side Preference (14 columns): CT vs T ratings, balance scores - Opponent Adaptation (12 columns): vs different ELO tiers - Map Specialization (10 columns): best/worst maps, versatility - Session Pattern (8 columns): daily/weekly patterns, streaks """ import sqlite3 from typing import Dict, Any, List from .base_processor import BaseFeatureProcessor, SafeAggregator class MetaProcessor(BaseFeatureProcessor): """Tier 4 META processor - Cross-match patterns and meta-analysis""" MIN_MATCHES_REQUIRED = 15 # Need sufficient history for meta patterns @staticmethod def calculate(steam_id: str, conn_l2: sqlite3.Connection) -> Dict[str, Any]: """ Calculate all Tier 4 META features (52 columns) Returns dict with keys starting with 'meta_' """ features = {} # Check minimum matches if not BaseFeatureProcessor.check_min_matches(steam_id, conn_l2, MetaProcessor.MIN_MATCHES_REQUIRED): return _get_default_meta_features() # Calculate each meta dimension features.update(MetaProcessor._calculate_stability(steam_id, conn_l2)) features.update(MetaProcessor._calculate_side_preference(steam_id, conn_l2)) features.update(MetaProcessor._calculate_opponent_adaptation(steam_id, conn_l2)) features.update(MetaProcessor._calculate_map_specialization(steam_id, conn_l2)) features.update(MetaProcessor._calculate_session_pattern(steam_id, conn_l2)) return features @staticmethod def _calculate_stability(steam_id: str, conn_l2: sqlite3.Connection) -> Dict[str, Any]: """ Calculate Stability (8 columns) Columns: - meta_rating_volatility (STDDEV of last 20 matches) - meta_recent_form_rating (AVG of last 10 matches) - meta_win_rating, meta_loss_rating - meta_rating_consistency - meta_time_rating_correlation - meta_map_stability - meta_elo_tier_stability """ cursor = conn_l2.cursor() # Get recent matches for volatility cursor.execute(""" SELECT rating FROM fact_match_players WHERE steam_id_64 = ? ORDER BY match_id DESC LIMIT 20 """, (steam_id,)) recent_ratings = [row[0] for row in cursor.fetchall() if row[0] is not None] rating_volatility = SafeAggregator.safe_stddev(recent_ratings, 0.0) # Recent form (last 10 matches) recent_form = SafeAggregator.safe_avg(recent_ratings[:10], 0.0) if len(recent_ratings) >= 10 else 0.0 # Win/loss ratings cursor.execute(""" SELECT AVG(CASE WHEN is_win = 1 THEN rating END) as win_rating, AVG(CASE WHEN is_win = 0 THEN rating END) as loss_rating FROM fact_match_players WHERE steam_id_64 = ? """, (steam_id,)) row = cursor.fetchone() win_rating = row[0] if row[0] else 0.0 loss_rating = row[1] if row[1] else 0.0 # Rating consistency (inverse of volatility, normalized) rating_consistency = max(0, 100 - (rating_volatility * 100)) # Time-rating correlation: calculate Pearson correlation between match time and rating cursor.execute(""" SELECT p.rating, m.start_time FROM fact_match_players p JOIN fact_matches m ON p.match_id = m.match_id WHERE p.steam_id_64 = ? AND p.rating IS NOT NULL AND m.start_time IS NOT NULL ORDER BY m.start_time """, (steam_id,)) time_rating_data = cursor.fetchall() if len(time_rating_data) >= 2: ratings = [row[0] for row in time_rating_data] times = [row[1] for row in time_rating_data] # Normalize timestamps to match indices time_indices = list(range(len(times))) # Calculate Pearson correlation n = len(ratings) sum_x = sum(time_indices) sum_y = sum(ratings) sum_xy = sum(x * y for x, y in zip(time_indices, ratings)) sum_x2 = sum(x * x for x in time_indices) sum_y2 = sum(y * y for y in ratings) numerator = n * sum_xy - sum_x * sum_y denominator = ((n * sum_x2 - sum_x ** 2) * (n * sum_y2 - sum_y ** 2)) ** 0.5 time_rating_corr = SafeAggregator.safe_divide(numerator, denominator) if denominator > 0 else 0.0 else: time_rating_corr = 0.0 # Map stability (STDDEV across maps) cursor.execute(""" SELECT m.map_name, AVG(p.rating) as avg_rating FROM fact_match_players p JOIN fact_matches m ON p.match_id = m.match_id WHERE p.steam_id_64 = ? GROUP BY m.map_name """, (steam_id,)) map_ratings = [row[1] for row in cursor.fetchall() if row[1] is not None] map_stability = SafeAggregator.safe_stddev(map_ratings, 0.0) # ELO tier stability (placeholder) elo_tier_stability = rating_volatility # Simplified return { 'meta_rating_volatility': round(rating_volatility, 3), 'meta_recent_form_rating': round(recent_form, 3), 'meta_win_rating': round(win_rating, 3), 'meta_loss_rating': round(loss_rating, 3), 'meta_rating_consistency': round(rating_consistency, 2), 'meta_time_rating_correlation': round(time_rating_corr, 3), 'meta_map_stability': round(map_stability, 3), 'meta_elo_tier_stability': round(elo_tier_stability, 3), } @staticmethod def _calculate_side_preference(steam_id: str, conn_l2: sqlite3.Connection) -> Dict[str, Any]: """ Calculate Side Preference (14 columns) Columns: - meta_side_ct_rating, meta_side_t_rating - meta_side_ct_kd, meta_side_t_kd - meta_side_ct_win_rate, meta_side_t_win_rate - meta_side_ct_fk_rate, meta_side_t_fk_rate - meta_side_ct_kast, meta_side_t_kast - meta_side_rating_diff, meta_side_kd_diff - meta_side_preference - meta_side_balance_score """ cursor = conn_l2.cursor() # Get CT side performance from fact_match_players_ct # Rating is now stored as rating2 from fight_ct cursor.execute(""" SELECT AVG(rating) as avg_rating, AVG(CAST(kills AS REAL) / NULLIF(deaths, 0)) as avg_kd, AVG(kast) as avg_kast, AVG(entry_kills) as avg_fk, SUM(CASE WHEN is_win = 1 THEN 1 ELSE 0 END) as wins, COUNT(*) as total_matches, SUM(round_total) as total_rounds FROM fact_match_players_ct WHERE steam_id_64 = ? AND rating IS NOT NULL AND rating > 0 """, (steam_id,)) ct_row = cursor.fetchone() ct_rating = ct_row[0] if ct_row and ct_row[0] else 0.0 ct_kd = ct_row[1] if ct_row and ct_row[1] else 0.0 ct_kast = ct_row[2] if ct_row and ct_row[2] else 0.0 ct_fk = ct_row[3] if ct_row and ct_row[3] else 0.0 ct_wins = ct_row[4] if ct_row and ct_row[4] else 0 ct_matches = ct_row[5] if ct_row and ct_row[5] else 1 ct_rounds = ct_row[6] if ct_row and ct_row[6] else 1 ct_win_rate = SafeAggregator.safe_divide(ct_wins, ct_matches) ct_fk_rate = SafeAggregator.safe_divide(ct_fk, ct_rounds) # Get T side performance from fact_match_players_t cursor.execute(""" SELECT AVG(rating) as avg_rating, AVG(CAST(kills AS REAL) / NULLIF(deaths, 0)) as avg_kd, AVG(kast) as avg_kast, AVG(entry_kills) as avg_fk, SUM(CASE WHEN is_win = 1 THEN 1 ELSE 0 END) as wins, COUNT(*) as total_matches, SUM(round_total) as total_rounds FROM fact_match_players_t WHERE steam_id_64 = ? AND rating IS NOT NULL AND rating > 0 """, (steam_id,)) t_row = cursor.fetchone() t_rating = t_row[0] if t_row and t_row[0] else 0.0 t_kd = t_row[1] if t_row and t_row[1] else 0.0 t_kast = t_row[2] if t_row and t_row[2] else 0.0 t_fk = t_row[3] if t_row and t_row[3] else 0.0 t_wins = t_row[4] if t_row and t_row[4] else 0 t_matches = t_row[5] if t_row and t_row[5] else 1 t_rounds = t_row[6] if t_row and t_row[6] else 1 t_win_rate = SafeAggregator.safe_divide(t_wins, t_matches) t_fk_rate = SafeAggregator.safe_divide(t_fk, t_rounds) # Differences rating_diff = ct_rating - t_rating kd_diff = ct_kd - t_kd # Side preference classification if abs(rating_diff) < 0.05: side_preference = 'Balanced' elif rating_diff > 0: side_preference = 'CT' else: side_preference = 'T' # Balance score (0-100, higher = more balanced) balance_score = max(0, 100 - abs(rating_diff) * 200) return { 'meta_side_ct_rating': round(ct_rating, 3), 'meta_side_t_rating': round(t_rating, 3), 'meta_side_ct_kd': round(ct_kd, 3), 'meta_side_t_kd': round(t_kd, 3), 'meta_side_ct_win_rate': round(ct_win_rate, 3), 'meta_side_t_win_rate': round(t_win_rate, 3), 'meta_side_ct_fk_rate': round(ct_fk_rate, 3), 'meta_side_t_fk_rate': round(t_fk_rate, 3), 'meta_side_ct_kast': round(ct_kast, 3), 'meta_side_t_kast': round(t_kast, 3), 'meta_side_rating_diff': round(rating_diff, 3), 'meta_side_kd_diff': round(kd_diff, 3), 'meta_side_preference': side_preference, 'meta_side_balance_score': round(balance_score, 2), } @staticmethod def _calculate_opponent_adaptation(steam_id: str, conn_l2: sqlite3.Connection) -> Dict[str, Any]: """ Calculate Opponent Adaptation (12 columns) ELO tiers: lower (<-200), similar (±200), higher (>+200) Columns: - meta_opp_vs_lower_elo_rating, meta_opp_vs_similar_elo_rating, meta_opp_vs_higher_elo_rating - meta_opp_vs_lower_elo_kd, meta_opp_vs_similar_elo_kd, meta_opp_vs_higher_elo_kd - meta_opp_elo_adaptation - meta_opp_stomping_score, meta_opp_upset_score - meta_opp_consistency_across_elos - meta_opp_rank_resistance - meta_opp_smurf_detection NOTE: Using individual origin_elo from fact_match_players """ cursor = conn_l2.cursor() # Get player's matches with individual ELO data cursor.execute(""" SELECT p.rating, CAST(p.kills AS REAL) / NULLIF(p.deaths, 0) as kd, p.is_win, p.origin_elo as player_elo, opp.avg_elo as opponent_avg_elo FROM fact_match_players p JOIN ( SELECT match_id, team_id, AVG(origin_elo) as avg_elo FROM fact_match_players WHERE origin_elo IS NOT NULL GROUP BY match_id, team_id ) opp ON p.match_id = opp.match_id AND p.team_id != opp.team_id WHERE p.steam_id_64 = ? AND p.origin_elo IS NOT NULL """, (steam_id,)) matches = cursor.fetchall() if not matches: return { 'meta_opp_vs_lower_elo_rating': 0.0, 'meta_opp_vs_lower_elo_kd': 0.0, 'meta_opp_vs_similar_elo_rating': 0.0, 'meta_opp_vs_similar_elo_kd': 0.0, 'meta_opp_vs_higher_elo_rating': 0.0, 'meta_opp_vs_higher_elo_kd': 0.0, 'meta_opp_elo_adaptation': 0.0, 'meta_opp_stomping_score': 0.0, 'meta_opp_upset_score': 0.0, 'meta_opp_consistency_across_elos': 0.0, 'meta_opp_rank_resistance': 0.0, 'meta_opp_smurf_detection': 0.0, } # Categorize by ELO difference lower_elo_ratings = [] # Playing vs weaker opponents lower_elo_kds = [] similar_elo_ratings = [] # Similar skill similar_elo_kds = [] higher_elo_ratings = [] # Playing vs stronger opponents higher_elo_kds = [] stomping_score = 0 # Dominating weaker teams upset_score = 0 # Winning against stronger teams for rating, kd, is_win, player_elo, opp_elo in matches: if rating is None or kd is None: continue elo_diff = player_elo - opp_elo # Positive = we're stronger # Categorize ELO tiers (±200 threshold) if elo_diff > 200: # We're stronger (opponent is lower ELO) lower_elo_ratings.append(rating) lower_elo_kds.append(kd) if is_win: stomping_score += 1 elif elo_diff < -200: # Opponent is stronger (higher ELO) higher_elo_ratings.append(rating) higher_elo_kds.append(kd) if is_win: upset_score += 2 # Upset wins count more else: # Similar ELO (±200) similar_elo_ratings.append(rating) similar_elo_kds.append(kd) # Calculate averages avg_lower_rating = SafeAggregator.safe_avg(lower_elo_ratings) avg_lower_kd = SafeAggregator.safe_avg(lower_elo_kds) avg_similar_rating = SafeAggregator.safe_avg(similar_elo_ratings) avg_similar_kd = SafeAggregator.safe_avg(similar_elo_kds) avg_higher_rating = SafeAggregator.safe_avg(higher_elo_ratings) avg_higher_kd = SafeAggregator.safe_avg(higher_elo_kds) # ELO adaptation: performance improvement vs stronger opponents # Positive = performs better vs stronger teams (rare, good trait) elo_adaptation = avg_higher_rating - avg_lower_rating # Consistency: std dev of ratings across ELO tiers all_tier_ratings = [avg_lower_rating, avg_similar_rating, avg_higher_rating] consistency = 100 - SafeAggregator.safe_stddev(all_tier_ratings) * 100 # Rank resistance: K/D vs higher ELO opponents rank_resistance = avg_higher_kd # Smurf detection: high performance vs lower ELO # Indicators: rating > 1.15 AND kd > 1.2 when facing lower ELO opponents smurf_score = 0.0 if len(lower_elo_ratings) > 0 and avg_lower_rating > 1.0: # Base score from rating dominance rating_bonus = max(0, (avg_lower_rating - 1.0) * 100) # Additional score from K/D dominance kd_bonus = max(0, (avg_lower_kd - 1.0) * 50) # Consistency bonus (more matches = more reliable indicator) consistency_bonus = min(len(lower_elo_ratings) / 5.0, 1.0) * 20 smurf_score = rating_bonus + kd_bonus + consistency_bonus # Cap at 100 smurf_score = min(smurf_score, 100.0) return { 'meta_opp_vs_lower_elo_rating': round(avg_lower_rating, 3), 'meta_opp_vs_lower_elo_kd': round(avg_lower_kd, 3), 'meta_opp_vs_similar_elo_rating': round(avg_similar_rating, 3), 'meta_opp_vs_similar_elo_kd': round(avg_similar_kd, 3), 'meta_opp_vs_higher_elo_rating': round(avg_higher_rating, 3), 'meta_opp_vs_higher_elo_kd': round(avg_higher_kd, 3), 'meta_opp_elo_adaptation': round(elo_adaptation, 3), 'meta_opp_stomping_score': round(stomping_score, 2), 'meta_opp_upset_score': round(upset_score, 2), 'meta_opp_consistency_across_elos': round(consistency, 2), 'meta_opp_rank_resistance': round(rank_resistance, 3), 'meta_opp_smurf_detection': round(smurf_score, 2), } # Performance vs lower ELO opponents (simplified - using match-level team ELO) # REMOVED DUPLICATE LOGIC BLOCK THAT WAS UNREACHABLE # The code previously had a return statement before this block, making it dead code. # Merged logic into the first block above using individual player ELOs which is more accurate. @staticmethod def _calculate_map_specialization(steam_id: str, conn_l2: sqlite3.Connection) -> Dict[str, Any]: """ Calculate Map Specialization (10 columns) Columns: - meta_map_best_map, meta_map_best_rating - meta_map_worst_map, meta_map_worst_rating - meta_map_diversity - meta_map_pool_size - meta_map_specialist_score - meta_map_versatility - meta_map_comfort_zone_rate - meta_map_adaptation """ cursor = conn_l2.cursor() # Map performance # Lower threshold to 1 match to ensure we catch high ratings even with low sample size cursor.execute(""" SELECT m.map_name, AVG(p.rating) as avg_rating, COUNT(*) as match_count FROM fact_match_players p JOIN fact_matches m ON p.match_id = m.match_id WHERE p.steam_id_64 = ? GROUP BY m.map_name HAVING match_count >= 1 ORDER BY avg_rating DESC """, (steam_id,)) map_data = cursor.fetchall() if not map_data: return { 'meta_map_best_map': 'unknown', 'meta_map_best_rating': 0.0, 'meta_map_worst_map': 'unknown', 'meta_map_worst_rating': 0.0, 'meta_map_diversity': 0.0, 'meta_map_pool_size': 0, 'meta_map_specialist_score': 0.0, 'meta_map_versatility': 0.0, 'meta_map_comfort_zone_rate': 0.0, 'meta_map_adaptation': 0.0, } # Best map best_map = map_data[0][0] best_rating = map_data[0][1] # Worst map worst_map = map_data[-1][0] worst_rating = map_data[-1][1] # Map diversity (entropy-based) map_ratings = [row[1] for row in map_data] map_diversity = SafeAggregator.safe_stddev(map_ratings, 0.0) # Map pool size (maps with 3+ matches, lowered from 5) cursor.execute(""" SELECT COUNT(DISTINCT m.map_name) FROM fact_match_players p JOIN fact_matches m ON p.match_id = m.match_id WHERE p.steam_id_64 = ? GROUP BY m.map_name HAVING COUNT(*) >= 3 """, (steam_id,)) pool_rows = cursor.fetchall() pool_size = len(pool_rows) # Specialist score (difference between best and worst) specialist_score = best_rating - worst_rating # Versatility (inverse of specialist score, normalized) versatility = max(0, 100 - specialist_score * 100) # Comfort zone rate (% matches on top 3 maps) cursor.execute(""" SELECT SUM(CASE WHEN m.map_name IN ( SELECT map_name FROM ( SELECT m2.map_name, COUNT(*) as cnt FROM fact_match_players p2 JOIN fact_matches m2 ON p2.match_id = m2.match_id WHERE p2.steam_id_64 = ? GROUP BY m2.map_name ORDER BY cnt DESC LIMIT 3 ) ) THEN 1 ELSE 0 END) as comfort_matches, COUNT(*) as total_matches FROM fact_match_players p JOIN fact_matches m ON p.match_id = m.match_id WHERE p.steam_id_64 = ? """, (steam_id, steam_id)) comfort_row = cursor.fetchone() comfort_matches = comfort_row[0] if comfort_row[0] else 0 total_matches = comfort_row[1] if comfort_row[1] else 1 comfort_zone_rate = SafeAggregator.safe_divide(comfort_matches, total_matches) # Map adaptation (avg rating on non-favorite maps) if len(map_data) > 1: non_favorite_ratings = [row[1] for row in map_data[1:]] map_adaptation = SafeAggregator.safe_avg(non_favorite_ratings, 0.0) else: map_adaptation = best_rating return { 'meta_map_best_map': best_map, 'meta_map_best_rating': round(best_rating, 3), 'meta_map_worst_map': worst_map, 'meta_map_worst_rating': round(worst_rating, 3), 'meta_map_diversity': round(map_diversity, 3), 'meta_map_pool_size': pool_size, 'meta_map_specialist_score': round(specialist_score, 3), 'meta_map_versatility': round(versatility, 2), 'meta_map_comfort_zone_rate': round(comfort_zone_rate, 3), 'meta_map_adaptation': round(map_adaptation, 3), } @staticmethod def _calculate_session_pattern(steam_id: str, conn_l2: sqlite3.Connection) -> Dict[str, Any]: """ Calculate Session Pattern (8 columns) Columns: - meta_session_avg_matches_per_day - meta_session_longest_streak - meta_session_weekend_rating, meta_session_weekday_rating - meta_session_morning_rating, meta_session_afternoon_rating - meta_session_evening_rating, meta_session_night_rating Note: Requires timestamp data in fact_matches """ cursor = conn_l2.cursor() # Check if start_time exists cursor.execute(""" SELECT COUNT(*) FROM fact_matches WHERE start_time IS NOT NULL AND start_time > 0 LIMIT 1 """) has_timestamps = cursor.fetchone()[0] > 0 if not has_timestamps: # Return placeholder values return { 'meta_session_avg_matches_per_day': 0.0, 'meta_session_longest_streak': 0, 'meta_session_weekend_rating': 0.0, 'meta_session_weekday_rating': 0.0, 'meta_session_morning_rating': 0.0, 'meta_session_afternoon_rating': 0.0, 'meta_session_evening_rating': 0.0, 'meta_session_night_rating': 0.0, } # 1. Matches per day cursor.execute(""" SELECT DATE(start_time, 'unixepoch') as match_date, COUNT(*) as daily_matches FROM fact_matches m JOIN fact_match_players p ON m.match_id = p.match_id WHERE p.steam_id_64 = ? AND m.start_time IS NOT NULL GROUP BY match_date """, (steam_id,)) daily_stats = cursor.fetchall() if daily_stats: avg_matches_per_day = sum(row[1] for row in daily_stats) / len(daily_stats) else: avg_matches_per_day = 0.0 # 2. Longest Streak (Consecutive wins) cursor.execute(""" SELECT is_win FROM fact_match_players p JOIN fact_matches m ON p.match_id = m.match_id WHERE p.steam_id_64 = ? AND m.start_time IS NOT NULL ORDER BY m.start_time """, (steam_id,)) results = cursor.fetchall() longest_streak = 0 current_streak = 0 for row in results: if row[0]: # Win current_streak += 1 else: longest_streak = max(longest_streak, current_streak) current_streak = 0 longest_streak = max(longest_streak, current_streak) # 3. Time of Day & Week Analysis # Weekend: 0 (Sun) and 6 (Sat) cursor.execute(""" SELECT CAST(strftime('%w', start_time, 'unixepoch') AS INTEGER) as day_of_week, CAST(strftime('%H', start_time, 'unixepoch') AS INTEGER) as hour_of_day, p.rating FROM fact_match_players p JOIN fact_matches m ON p.match_id = m.match_id WHERE p.steam_id_64 = ? AND m.start_time IS NOT NULL AND p.rating IS NOT NULL """, (steam_id,)) matches = cursor.fetchall() weekend_ratings = [] weekday_ratings = [] morning_ratings = [] # 06-12 afternoon_ratings = [] # 12-18 evening_ratings = [] # 18-24 night_ratings = [] # 00-06 for dow, hour, rating in matches: # Weekday/Weekend if dow == 0 or dow == 6: weekend_ratings.append(rating) else: weekday_ratings.append(rating) # Time of Day if 6 <= hour < 12: morning_ratings.append(rating) elif 12 <= hour < 18: afternoon_ratings.append(rating) elif 18 <= hour <= 23: evening_ratings.append(rating) else: # 0-6 night_ratings.append(rating) return { 'meta_session_avg_matches_per_day': round(avg_matches_per_day, 2), 'meta_session_longest_streak': longest_streak, 'meta_session_weekend_rating': round(SafeAggregator.safe_avg(weekend_ratings), 3), 'meta_session_weekday_rating': round(SafeAggregator.safe_avg(weekday_ratings), 3), 'meta_session_morning_rating': round(SafeAggregator.safe_avg(morning_ratings), 3), 'meta_session_afternoon_rating': round(SafeAggregator.safe_avg(afternoon_ratings), 3), 'meta_session_evening_rating': round(SafeAggregator.safe_avg(evening_ratings), 3), 'meta_session_night_rating': round(SafeAggregator.safe_avg(night_ratings), 3), } def _get_default_meta_features() -> Dict[str, Any]: """Return default zero values for all 52 META features""" return { # Stability (8) 'meta_rating_volatility': 0.0, 'meta_recent_form_rating': 0.0, 'meta_win_rating': 0.0, 'meta_loss_rating': 0.0, 'meta_rating_consistency': 0.0, 'meta_time_rating_correlation': 0.0, 'meta_map_stability': 0.0, 'meta_elo_tier_stability': 0.0, # Side Preference (14) 'meta_side_ct_rating': 0.0, 'meta_side_t_rating': 0.0, 'meta_side_ct_kd': 0.0, 'meta_side_t_kd': 0.0, 'meta_side_ct_win_rate': 0.0, 'meta_side_t_win_rate': 0.0, 'meta_side_ct_fk_rate': 0.0, 'meta_side_t_fk_rate': 0.0, 'meta_side_ct_kast': 0.0, 'meta_side_t_kast': 0.0, 'meta_side_rating_diff': 0.0, 'meta_side_kd_diff': 0.0, 'meta_side_preference': 'Balanced', 'meta_side_balance_score': 0.0, # Opponent Adaptation (12) 'meta_opp_vs_lower_elo_rating': 0.0, 'meta_opp_vs_similar_elo_rating': 0.0, 'meta_opp_vs_higher_elo_rating': 0.0, 'meta_opp_vs_lower_elo_kd': 0.0, 'meta_opp_vs_similar_elo_kd': 0.0, 'meta_opp_vs_higher_elo_kd': 0.0, 'meta_opp_elo_adaptation': 0.0, 'meta_opp_stomping_score': 0.0, 'meta_opp_upset_score': 0.0, 'meta_opp_consistency_across_elos': 0.0, 'meta_opp_rank_resistance': 0.0, 'meta_opp_smurf_detection': 0.0, # Map Specialization (10) 'meta_map_best_map': 'unknown', 'meta_map_best_rating': 0.0, 'meta_map_worst_map': 'unknown', 'meta_map_worst_rating': 0.0, 'meta_map_diversity': 0.0, 'meta_map_pool_size': 0, 'meta_map_specialist_score': 0.0, 'meta_map_versatility': 0.0, 'meta_map_comfort_zone_rate': 0.0, 'meta_map_adaptation': 0.0, # Session Pattern (8) 'meta_session_avg_matches_per_day': 0.0, 'meta_session_longest_streak': 0, 'meta_session_weekend_rating': 0.0, 'meta_session_weekday_rating': 0.0, 'meta_session_morning_rating': 0.0, 'meta_session_afternoon_rating': 0.0, 'meta_session_evening_rating': 0.0, 'meta_session_night_rating': 0.0, }