Spaces:
Sleeping
Sleeping
File size: 13,472 Bytes
c6230f6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 | """
Association-rule mining over watch history.
Every rule is scored the same way, so they can be ranked against each other:
support how many times it happened (guards against small-sample noise)
confidence P(outcome | channel) -- how reliable the habit is
lift confidence / P(outcome) -- how much stronger than chance
Lift is what makes a rule interesting. "You watch this channel on Fridays 20% of the
time" sounds like a pattern until you notice 1/7 is 14%, so the lift is only 1.4.
On channel eligibility: the previous gate was max(10, len(watch_events) // 100), which
scaled with total watches. A user with 20,626 watches needed 206 on one channel to
qualify, leaving 4 eligible channels and zero patterns, while a lighter user with the
same habits got plenty. The gate is now an absolute floor -- it exists for statistical
validity, not to ration results by how much someone watches.
"""
from collections import Counter, defaultdict
from datetime import datetime
from itertools import combinations
from typing import Dict, List, Optional, Set
# Minimum observations before a rule is trustworthy at all.
MIN_SUPPORT = 15
# How many of a user's channels are eligible. Top-N already restricts to their mainstays.
TOP_CHANNELS = 40
# Rules must be this much stronger than chance to be worth showing.
MIN_LIFT = 1.5
# A gap this long ends a viewing session.
SESSION_GAP_MINUTES = 60
# Sessions longer than this are almost always autoplay drift, not deliberate pairing.
MAX_SESSION_CHANNELS = 60
# A channel must span this many months before "seasonal" means anything. One watched
# only in April was never available to appear elsewhere, so its concentration carries
# no information -- that is a discovery, not a season.
MIN_SEASONAL_MONTHS = 3
# Cap per rule type so one kind cannot fill the card and hide the others.
MAX_PER_TYPE = 2
DAY_NAMES = [
"Monday", "Tuesday", "Wednesday", "Thursday",
"Friday", "Saturday", "Sunday",
]
MONTH_NAMES = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
]
# name -> number of hours in the slot, used as the chance baseline
SLOTS = {
"late night": (0, 5),
"morning": (5, 12),
"afternoon": (12, 17),
"evening": (17, 21),
"night": (21, 24),
}
SLOT_PHRASE = {
"late night": "after midnight",
"morning": "in the mornings",
"afternoon": "in the afternoons",
"evening": "in the evenings",
"night": "late in the evening",
}
def top_channels(
watch_events: List[Dict],
limit: int = TOP_CHANNELS,
min_support: int = MIN_SUPPORT,
) -> Set[str]:
"""The user's mainstay channels: most-watched, above an absolute support floor."""
counts = Counter(
e.get("channel_clean") for e in watch_events if e.get("channel_clean")
)
return {ch for ch, n in counts.most_common(limit) if n >= min_support}
def _slot_for(hour: int) -> Optional[str]:
for name, (start, end) in SLOTS.items():
if start <= hour < end:
return name
return None
def _rule(kind: str, count: int, confidence: float, lift: float, insight: str, **extra) -> Dict:
return {
"type": kind,
"count": count,
"confidence": confidence,
"lift": lift,
"insight": insight,
**extra,
}
def _association_rules(
watch_events: List[Dict],
channels: Set[str],
key,
label,
phrase,
kind: str,
min_confidence: float,
extra_field: str,
min_distinct: int = 1,
) -> List[Dict]:
"""Shared channel x attribute mining, used for day, slot and month rules."""
per_channel: Dict[str, Counter] = defaultdict(Counter)
channel_total: Counter = Counter()
attribute_total: Counter = Counter()
for event in watch_events:
value = key(event)
if value is None:
continue
# The chance baseline is the share of ALL viewing with this attribute, not just
# viewing on eligible channels. Restricting it to eligible channels compares a
# channel partly against itself, which drives lift toward 1 and hides real
# patterns whenever few channels qualify.
attribute_total[value] += 1
channel = event.get("channel_clean")
if channel in channels:
per_channel[channel][value] += 1
channel_total[channel] += 1
total = sum(attribute_total.values())
if not total:
return []
rules = []
for channel, values in per_channel.items():
# Concentration only means something if the channel could have appeared
# elsewhere; see MIN_SEASONAL_MONTHS.
if len(values) < min_distinct:
continue
for value, count in values.items():
if count < MIN_SUPPORT:
continue
confidence = count / channel_total[channel]
baseline = attribute_total[value] / total
lift = confidence / baseline if baseline else 0.0
if confidence >= min_confidence and lift >= MIN_LIFT:
rules.append(
_rule(
kind, count, confidence, lift,
phrase(channel, label(value), confidence),
channel=channel, **{extra_field: label(value)},
)
)
return rules
def channel_day_rules(watch_events: List[Dict], channels: Set[str]) -> List[Dict]:
"""Channels tied to a day of the week. Chance baseline is 1/7."""
return _association_rules(
watch_events, channels,
key=lambda e: e.get("day_of_week"),
label=lambda d: DAY_NAMES[d] if 0 <= d < 7 else "Unknown",
phrase=lambda ch, day, conf: (
f"You watch **{ch}** on **{day}s** ({int(conf * 100)}% of the time)"
),
kind="channel_day", min_confidence=0.25, extra_field="day",
)
def channel_slot_rules(watch_events: List[Dict], channels: Set[str]) -> List[Dict]:
"""Channels tied to a time of day. Baseline is the slot's real share of hours."""
return _association_rules(
watch_events, channels,
key=lambda e: _slot_for(e["hour_local"]) if e.get("hour_local") is not None else None,
label=lambda s: s,
phrase=lambda ch, slot, conf: (
f"**{ch}** is your go-to {SLOT_PHRASE.get(slot, slot)} "
f"({int(conf * 100)}% of the time)"
),
kind="channel_time", min_confidence=0.35, extra_field="slot",
)
def seasonal_rules(watch_events: List[Dict], channels: Set[str]) -> List[Dict]:
"""Channels concentrated in one month -- sports seasons, exam periods, holidays."""
return _association_rules(
watch_events, channels,
key=lambda e: e.get("month_local"),
label=lambda m: MONTH_NAMES[m - 1] if 1 <= m <= 12 else "Unknown",
phrase=lambda ch, month, conf: (
f"**{ch}** is your **{month}** channel ({int(conf * 100)}% of it landed there)"
),
kind="seasonal", min_confidence=0.35, extra_field="month",
min_distinct=MIN_SEASONAL_MONTHS,
)
def weekend_rule(watch_events: List[Dict], channels: Set[str]) -> Optional[Dict]:
"""Whether a mainstay channel skews to weekends. Chance baseline is 2/7."""
weekend = Counter()
totals = Counter()
for event in watch_events:
channel = event.get("channel_clean")
day = event.get("day_of_week")
if channel not in channels or day is None:
continue
totals[channel] += 1
if day in (5, 6):
weekend[channel] += 1
best, best_lift, best_count = None, 0.0, 0
for channel, total in totals.items():
if total < MIN_SUPPORT:
continue
confidence = weekend[channel] / total
lift = confidence / (2 / 7)
if lift > best_lift:
best, best_lift, best_count = channel, lift, weekend[channel]
if best is None or best_lift < MIN_LIFT:
return None
confidence = best_count / totals[best]
return _rule(
"weekend", best_count, confidence, best_lift,
f"**{best}** is your weekend channel ({int(confidence * 100)}% on Sat/Sun)",
channel=best,
)
def _sessions(watch_events: List[Dict]) -> List[List[str]]:
"""Channel sequences, split wherever viewing pauses for SESSION_GAP_MINUTES."""
timed = []
for event in watch_events:
stamp = event.get("timestamp_local") or event.get("timestamp_utc")
channel = event.get("channel_clean")
if not stamp or not channel:
continue
try:
timed.append((datetime.fromisoformat(stamp.replace("Z", "+00:00")), channel))
except ValueError:
continue
if not timed:
return []
timed.sort(key=lambda pair: pair[0])
sessions, current = [], [timed[0][1]]
for index in range(1, len(timed)):
gap = (timed[index][0] - timed[index - 1][0]).total_seconds() / 60
if gap > SESSION_GAP_MINUTES:
sessions.append(current)
current = []
current.append(timed[index][1])
sessions.append(current)
return sessions
def cooccurrence_rules(watch_events: List[Dict]) -> List[Dict]:
"""Channel pairs that show up in the same sitting far more often than chance.
This is the classic market-basket rule -- sessions are baskets, channels are items.
It surfaces groupings nobody configured: artists of a genre, a sport and its
highlights reel.
"""
sessions = _sessions(watch_events)
if not sessions:
return []
total = len(sessions)
appears = Counter()
together = Counter()
for session in sessions:
unique = sorted(set(session))
for channel in unique:
appears[channel] += 1
# Very long sessions are autoplay drift; every pair in them would look related.
if len(unique) > MAX_SESSION_CHANNELS:
continue
for pair in combinations(unique, 2):
together[pair] += 1
rules = []
for (first, second), count in together.items():
if count < MIN_SUPPORT:
continue
expected = (appears[first] / total) * (appears[second] / total)
lift = (count / total) / expected if expected else 0.0
if lift < MIN_LIFT * 2: # pairs are noisier, so demand a stronger signal
continue
confidence = max(count / appears[first], count / appears[second])
rules.append(
_rule(
"cooccurrence", count, confidence, lift,
f"**{first}** and **{second}** go together "
f"({count} sessions, {lift:.1f}x more than chance)",
channel_a=first, channel_b=second,
)
)
return rules
def opener_rules(watch_events: List[Dict]) -> List[Dict]:
"""Channels that tend to start a viewing session -- the gateway into a run."""
sessions = _sessions(watch_events)
if not sessions:
return []
total = len(sessions)
opens = Counter(session[0] for session in sessions if session)
appears = Counter()
for session in sessions:
for channel in set(session):
appears[channel] += 1
# Chance baseline: across every channel-appearance, what share are openers? Each
# session has exactly one opener out of however many channels it contains, so this
# is 1/(average distinct channels per session). Dividing openers by sessions instead
# would give 1.0 always, making every lift exactly 1.
appearances = sum(appears.values())
baseline = total / appearances if appearances else 0
rules = []
for channel, count in opens.items():
if count < MIN_SUPPORT:
continue
confidence = count / appears[channel]
lift = confidence / baseline if baseline else 0.0
if lift < MIN_LIFT:
continue
rules.append(
_rule(
"opener", count, confidence, lift,
f"**{channel}** is how you start a session "
f"({count} times, {int(confidence * 100)}% of its sessions)",
channel=channel,
)
)
rules.sort(key=lambda r: r["count"], reverse=True)
return rules
def find_patterns(watch_events: List[Dict], limit: int = 5) -> List[Dict]:
"""All rule types, ranked by confidence x lift, strongest first."""
if not watch_events:
return []
channels = top_channels(watch_events)
if not channels:
return []
rules: List[Dict] = []
rules += channel_day_rules(watch_events, channels)
rules += channel_slot_rules(watch_events, channels)
rules += seasonal_rules(watch_events, channels)
rules += cooccurrence_rules(watch_events)
rules += opener_rules(watch_events)
weekend = weekend_rule(watch_events, channels)
if weekend:
rules.append(weekend)
rules.sort(key=lambda r: r["lift"] * r["confidence"], reverse=True)
# Take the strongest rules, but never more than MAX_PER_TYPE of one kind: five
# variations on "X is your April channel" reads as a malfunction, not a discovery.
selected: List[Dict] = []
per_type: Counter = Counter()
for rule in rules:
if per_type[rule["type"]] >= MAX_PER_TYPE:
continue
selected.append(rule)
per_type[rule["type"]] += 1
if len(selected) == limit:
break
return selected
|