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