File size: 3,121 Bytes
63dd946
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
# Copyright (c) 2026 The Anuvani-Diarization authors
# SPDX-License-Identifier: MIT OR Apache-2.0
"""Utterance-convention decoder: pure-numpy predictor for exported gradient-boosted trees, plus the decision rule.

The trees are scikit-learn HistGradientBoostingRegressor models exported by tools/export_decoder.py. Prediction
replicates sklearn exactly: float64 baseline plus each tree's leaf value, added in iteration order; numerical splits
go left when x <= threshold, missing values follow the stored missing direction.
Decision rule (as frozen in the research pipeline): bridge a same-speaker gap when the gap model predicts a negative
error change per second; drop a short segment when the segment model predicts a negative error change.
"""
import json

import numpy as np

from .geometry import union


class TreeEnsemble:
    def __init__(self, blob):
        self.features = blob["features"]
        self.baseline = float(blob["baseline"])
        self.trees = [{k: np.asarray(v) for k, v in t.items()} for t in blob["trees"]]

    def predict(self, rows):
        if not rows:
            return np.zeros(0)
        X = np.array([[r[f] for f in self.features] for r in rows], dtype=np.float64)
        out = np.full(len(X), self.baseline, dtype=np.float64)
        for t in self.trees:
            feat, thr, mleft = t["feature"], t["threshold"], t["missing_left"]
            left, right, leaf, value = t["left"], t["right"], t["leaf"], t["value"]
            node = np.zeros(len(X), dtype=np.int64)
            for _ in range(len(feat)):
                active = ~leaf[node]
                if not active.any():
                    break
                n = node[active]
                x = X[np.flatnonzero(active), feat[n]]
                go_left = np.where(np.isnan(x), mleft[n], x <= thr[n])
                node[active] = np.where(go_left, left[n], right[n])
            out += value[node]
        return out


def load_decoder(path):
    blob = json.loads(open(path).read())
    return TreeEnsemble(blob["gap"]), TreeEnsemble(blob["seg"])


def apply(hyp, gap_rows, gap_pred, seg_rows, seg_pred, tau_gap=0.0, tau_seg=0.0):
    """hyp: spk -> unbridged intervals. Bridge gaps with gap_pred > tau_gap; drop segments with seg_pred < tau_seg
    unless they were merged by a bridge. (Research: convention_decoder.apply without the unused `moves` option.)"""
    fill = {(r["spk"], r["j"]) for r, p in zip(gap_rows, gap_pred) if p > tau_gap}
    drop = {(r["spk"], r["j"]) for r, p in zip(seg_rows, seg_pred) if p < tau_seg}
    out = {}
    for k, v in hyp.items():
        iv = union([tuple(x) for x in v])
        keep = []
        for j, (s, e) in enumerate(iv):
            merged = (k, j) in fill or (k, j + 1) in fill
            keep.append(not ((k, j) in drop and not merged))
        res = []
        for j, (s, e) in enumerate(iv):
            if j > 0 and (k, j) in fill and res and keep[j] and keep[j - 1]:
                res[-1][1] = max(res[-1][1], e)
            elif keep[j]:
                res.append([s, e])
        if res:
            out[k] = [(a, b) for a, b in res]
    return out