File size: 7,872 Bytes
3d7c238
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Label schema, handbook hard rules and the gated output (pure Python, no torch).

Shared by the local export (``classifier_export.py``), the pod training code and the local evaluation
(``classifier_eval.py``). The schema comes from ``taxonomy.json``, written by the export from the M2
protocol (handbook-v4-dev1); nothing about fields or options is hard-coded elsewhere.

Hard rules (handbook §3, §8, Part C; 0 violations in the 32k Astra labels):
- not relevant (``not_relevant`` or ``unclear``) -> asset_domain not_applicable, direction_primary none;
  relevant -> asset_domain is not not_applicable;
- not evaluable -> direction_primary none, emotion_valence none;
- emotion_valence none <-> emotion_fine not_expressed, and every fine emotion maps to one valence.

Gated output: the rules are applied in GATE_ORDER; forbidden options get probability 0 and the rest of the
field is renormalised. Valence leads fine emotion (the reported field, with more data).
"""
from __future__ import annotations

import json
from pathlib import Path

VERSION = 'taxonomy-v1'
NOT_RELEVANT = ('not_relevant', 'unclear')
FINE_TO_VALENCE = {
    'euphoric': 'positive', 'optimistic': 'positive', 'fomo': 'positive', 'calm': 'positive',
    'uncertain': 'negative', 'anxious': 'negative', 'panic_or_capitulation': 'negative',
    'regret_or_frustration': 'negative', 'angry': 'negative', 'mixed': 'mixed', 'not_expressed': 'none',
}
GATE_ORDER = ('market_relevance', 'asset_domain', 'view_evaluability', 'direction_primary', 'emotion_valence',
              'emotion_fine')


class Taxonomy:
    def __init__(self, fields: dict[str, list[str]], absence: dict[str, str], meta: dict | None = None):
        self.fields = {f: list(o) for f, o in fields.items()}
        self.names = list(self.fields)
        self.absence = dict(absence)
        self.meta = dict(meta or {})
        for f, options in self.fields.items():
            if len(set(options)) != len(options) or self.absence.get(f) not in options:
                raise ValueError('Bad field: ' + f)
        if set(self.fields.get('emotion_fine', ())) != set(FINE_TO_VALENCE):
            raise ValueError('emotion_fine options differ from the valence map')

    @classmethod
    def load(cls, path) -> 'Taxonomy':
        path = Path(path)
        if path.is_dir():
            path = path / 'taxonomy.json'
        value = json.loads(path.read_text(encoding='utf-8'))
        if value.get('version') != VERSION:
            raise ValueError('Unknown taxonomy version: %r' % value.get('version'))
        # The rules live in this module; a taxonomy.json that states different ones is refused, not ignored.
        if (value.get('not_relevant') != list(NOT_RELEVANT) or value.get('fine_to_valence') != FINE_TO_VALENCE
                or value.get('gate_order') != list(GATE_ORDER)):
            raise ValueError('taxonomy.json rules differ from classifier.taxonomy')
        return cls(value['fields'], value['absence'], {k: v for k, v in value.items() if k not in ('fields', 'absence')})

    def to_json(self) -> dict:
        return dict(self.meta, version=VERSION, fields=self.fields, absence=self.absence,
                    not_relevant=list(NOT_RELEVANT), fine_to_valence=FINE_TO_VALENCE, gate_order=list(GATE_ORDER))

    def sizes(self) -> list[int]:
        return [len(self.fields[f]) for f in self.names]

    def index(self, field: str, option: str | None) -> int:
        """Option index, or -1 for a null target (masked in the loss)."""
        return -1 if option is None else self.fields[field].index(option)

    def encode(self, targets: dict) -> list[int]:
        return [self.index(f, targets.get(f)) for f in self.names]


def rule_fill(targets: dict) -> dict:
    """Fill nulls that a hard rule decides; everything else (including all non-null labels) is kept."""
    t = dict(targets)
    rel, ev, val, fine = (t.get('market_relevance'), t.get('view_evaluability'), t.get('emotion_valence'),
                          t.get('emotion_fine'))
    if rel in NOT_RELEVANT:
        if t.get('asset_domain') is None:
            t['asset_domain'] = 'not_applicable'
        if t.get('direction_primary') is None:
            t['direction_primary'] = 'none'
    if ev == 'not_evaluable' and t.get('direction_primary') is None:
        t['direction_primary'] = 'none'
    if val is None:
        implied = {'none'} if ev == 'not_evaluable' else set()
        if fine is not None:
            implied.add(FINE_TO_VALENCE[fine])
        if len(implied) == 1:  # decided by the rules; a conflict (not evaluable + an emotion) stays null
            t['emotion_valence'] = val = implied.pop()
    if fine is None and t.get('emotion_valence') in ('none', 'mixed'):  # the two valences with one fine option
        t['emotion_fine'] = 'not_expressed' if t['emotion_valence'] == 'none' else 'mixed'
    return t


def violations(labels: dict) -> list[str]:
    """Hard rules a complete label set breaks (a rule with a null in it is not checked)."""
    out = []
    rel, dom, ev = labels.get('market_relevance'), labels.get('asset_domain'), labels.get('view_evaluability')
    direction, val, fine = labels.get('direction_primary'), labels.get('emotion_valence'), labels.get('emotion_fine')
    if rel in NOT_RELEVANT and dom is not None and dom != 'not_applicable':
        out.append('not_relevant_domain')
    if rel == 'relevant' and dom == 'not_applicable':
        out.append('relevant_domain')
    if rel in NOT_RELEVANT and direction is not None and direction != 'none':
        out.append('not_relevant_direction')
    if ev == 'not_evaluable' and direction is not None and direction != 'none':
        out.append('not_evaluable_direction')
    if ev == 'not_evaluable' and val is not None and val != 'none':
        out.append('not_evaluable_valence')
    if val is not None and fine is not None and FINE_TO_VALENCE[fine] != val:
        out.append('fine_valence')
    return out


def _restrict(dist: dict[str, float], allowed) -> dict[str, float]:
    allowed = set(allowed)
    kept = {k: (v if k in allowed else 0.0) for k, v in dist.items()}
    total = sum(kept.values())
    if total <= 0:  # every allowed option had probability 0: uniform over the allowed ones
        return {k: (1.0 / len(allowed) if k in allowed else 0.0) for k in dist}
    return {k: v / total for k, v in kept.items()}


def _only(dist: dict[str, float], option: str) -> dict[str, float]:
    return {k: (1.0 if k == option else 0.0) for k in dist}


def top(dist: dict[str, float]) -> str:
    """Highest-probability option; ties go to the earlier option in taxonomy order (dicts keep it)."""
    best = None
    for k, v in dist.items():
        if best is None or v > dist[best]:
            best = k
    return best


def gate(probs: dict[str, dict[str, float]]) -> dict[str, dict[str, float]]:
    """Hard rules applied in GATE_ORDER to per-field distributions (taxonomy option order is kept).

    The steps below follow GATE_ORDER exactly: each decision uses the already-gated fields before it."""
    out = {f: dict(d) for f, d in probs.items()}
    rel = top(out['market_relevance'])
    dom = out['asset_domain']
    out['asset_domain'] = _only(dom, 'not_applicable') if rel in NOT_RELEVANT else \
        _restrict(dom, [o for o in dom if o != 'not_applicable'])
    ev = top(out['view_evaluability'])
    if rel in NOT_RELEVANT or ev == 'not_evaluable':
        out['direction_primary'] = _only(out['direction_primary'], 'none')
    if ev == 'not_evaluable':
        out['emotion_valence'] = _only(out['emotion_valence'], 'none')
    val = top(out['emotion_valence'])
    out['emotion_fine'] = _restrict(out['emotion_fine'], [o for o, v in FINE_TO_VALENCE.items() if v == val])
    return out


def argmax(probs: dict[str, dict[str, float]]) -> dict[str, str]:
    return {f: top(d) for f, d in probs.items()}