File size: 3,915 Bytes
81f630c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
StackedMultimodal — the final multi-modal model (used for training AND deployment).

Architecture (per survival horizon):
  base_tabular : HistGradientBoosting (class-balanced) on 7 tabular features
                 (country, cyclic month, log viral loads CBPV/DWV/KBV)
  base_audio   : StandardScaler -> PCA(16) -> LogisticRegression (balanced) on the
                 191 librosa acoustic features
  meta         : LogisticRegression on [p_tabular, p_audio]  (learned late fusion)

fit() trains the meta on inner GroupKFold out-of-fold base predictions (no leakage),
then refits both bases on all supplied data and tunes the decision threshold on the
inner-OOF meta probabilities. Because fit() is self-contained, the same object can
be honestly evaluated with an outer GroupKFold and then fit on all data for release.
"""
import numpy as np
from sklearn.model_selection import GroupKFold
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.decomposition import PCA
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.metrics import f1_score

RNG = 42


def make_tabular():
    return Pipeline([
        ('imp', SimpleImputer(strategy='median')),
        ('clf', HistGradientBoostingClassifier(
            max_iter=400, learning_rate=0.05, max_leaf_nodes=31,
            l2_regularization=1.0, class_weight='balanced', random_state=RNG))])


def make_audio(k=16):
    return Pipeline([
        ('imp', SimpleImputer(strategy='median')),
        ('sc', StandardScaler()),
        ('pca', PCA(n_components=k, random_state=RNG)),
        ('clf', LogisticRegression(max_iter=2000, class_weight='balanced', C=0.5))])


def tune_threshold(y, p):
    ts = np.clip(np.unique(np.round(p, 4)), 1e-3, 1 - 1e-3)
    best_t, best_f1 = 0.5, -1.0
    for t in ts:
        f = f1_score(y, (p >= t).astype(int), zero_division=0)
        if f > best_f1:
            best_f1, best_t = f, t
    return float(best_t)


class StackedMultimodal:
    """Fixed-weight late fusion: p = w_tab * p_tabular + (1 - w_tab) * p_audio.

    A weight scan under GroupKFold showed w_tab ~= 0.8 dominates tabular-only on
    BOTH ROC-AUC and average precision for both horizons (audio helps rank
    borderline cases without diluting the concentrated metadata signal). A learned
    meta-learner over-weighted audio and hurt precision, so we keep the robust,
    transparent fixed weight.
    """
    def __init__(self, pca_k=16, inner_splits=5, w_tab=0.8):
        self.pca_k = pca_k
        self.inner_splits = inner_splits
        self.w_tab = w_tab

    def fit(self, Xtab, Xaud, y, groups):
        y = np.asarray(y)
        n = len(y)
        oof_t = np.zeros(n)
        oof_a = np.zeros(n)
        gkf = GroupKFold(n_splits=self.inner_splits)
        for tr, te in gkf.split(Xtab, y, groups):
            mt = make_tabular().fit(Xtab[tr], y[tr])
            ma = make_audio(self.pca_k).fit(Xaud[tr], y[tr])
            oof_t[te] = mt.predict_proba(Xtab[te])[:, 1]
            oof_a[te] = ma.predict_proba(Xaud[te])[:, 1]
        # refit bases on ALL data
        self.tab_ = make_tabular().fit(Xtab, y)
        self.aud_ = make_audio(self.pca_k).fit(Xaud, y)
        # threshold on inner-OOF fused probs (honest, no test leakage)
        self.oof_meta_ = self.w_tab * oof_t + (1 - self.w_tab) * oof_a
        self.threshold_ = tune_threshold(y, self.oof_meta_)
        self.base_oof_ = (oof_t, oof_a)
        return self

    def predict_proba(self, Xtab, Xaud):
        pt = self.tab_.predict_proba(Xtab)[:, 1]
        pa = self.aud_.predict_proba(Xaud)[:, 1]
        p = self.w_tab * pt + (1 - self.w_tab) * pa
        return p, pt, pa

    def predict(self, Xtab, Xaud):
        p, _, _ = self.predict_proba(Xtab, Xaud)
        return (p >= self.threshold_).astype(int)