File size: 5,769 Bytes
a8b5f43
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Load the safe JSON baseline. Inference needs only Python's standard library."""
import json
import math
from pathlib import Path
import re
from collections import Counter

TOKEN_PATTERN = r"(?u)\b\w\w+\b"


def route_confidence(confidence, thresholds, known_features=True):
    """Model routing policy; absence of known text always requires escalation."""
    if isinstance(confidence, bool) or not isinstance(confidence, (int, float)) or not math.isfinite(confidence) or not 0 <= confidence <= 1:
        raise ValueError('Confidence must be a finite probability')
    auto = thresholds['auto_accept']
    return ('ESCALATE' if not known_features or confidence < thresholds['escalate']
            else 'AUTO_ACCEPT' if auto is not None and confidence >= auto else 'HUMAN_REVIEW')


def validate_model(model):
    """Fail at load time rather than emit plausible predictions from corrupt data."""
    try:
        if type(model['format_version']) is not int or model['format_version'] != 1:
            raise ValueError('Unsupported model format')
        expected = {'token_pattern': TOKEN_PATTERN, 'ngram_range': [1, 2],
                    'lowercase': True, 'sublinear_tf': True, 'norm': 'l2'}
        if model['vectorizer'] != expected:
            raise ValueError('Unsupported vectorizer configuration')
        classes, vocabulary = model['classes'], model['vocabulary']
        if not isinstance(classes, list) or len(classes) != 5 or any(not isinstance(c, str) or not c for c in classes) or len(set(classes)) != 5:
            raise ValueError('Expected five unique classes')
        if not isinstance(model['model_version'], str) or not model['model_version'].strip():
            raise ValueError('Missing model version')
        if not isinstance(vocabulary, dict) or not vocabulary or any(not isinstance(k, str) or type(v) is not int for k, v in vocabulary.items()) or set(vocabulary.values()) != set(range(len(vocabulary))):
            raise ValueError('Invalid vocabulary indices')
        width = len(vocabulary)
        def numbers(values, size):
            return isinstance(values, list) and len(values) == size and all(type(v) in (int, float) and math.isfinite(v) for v in values)
        if not numbers(model['idf'], width) or any(v <= 0 for v in model['idf']):
            raise ValueError('Invalid IDF values')
        if not numbers(model['intercept'], 5) or not isinstance(model['coefficients'], list) or len(model['coefficients']) != 5 or any(not numbers(row, width) for row in model['coefficients']):
            raise ValueError('Invalid classifier dimensions or weights')
        thresholds = model['thresholds']
        if not isinstance(thresholds, dict) or set(thresholds) != {'auto_accept', 'escalate'}:
            raise ValueError('Invalid threshold configuration')
        for value in thresholds.values():
            if value is not None and (type(value) not in (int, float) or not math.isfinite(value) or not 0 <= value <= 1):
                raise ValueError('Invalid routing threshold')
        if thresholds['escalate'] is None or (thresholds['auto_accept'] is not None and thresholds['auto_accept'] < thresholds['escalate']):
            raise ValueError('Inconsistent routing thresholds')
    except (KeyError, TypeError) as exc:
        raise ValueError('Incomplete or malformed model artifact') from exc


class OccupationClassifier:
    def __init__(self, path=None):
        directory = Path(__file__).parent
        self.path = Path(path) if path is not None else (directory / 'model.json' if (directory / 'model.json').exists() else directory / 'model' / 'model.json')
        self.model = json.loads(self.path.read_text())
        validate_model(self.model)
        self.classes = self.model['classes']
        self.thresholds = self.model['thresholds']

    def predict(self, text):
        if not isinstance(text, str) or not text.strip():
            raise ValueError('Enter a nonempty job title or description.')
        if len(text) > 10000:
            raise ValueError('Text must be at most 10,000 characters.')
        tokens = re.findall(TOKEN_PATTERN, text.lower())
        terms = tokens + [' '.join(pair) for pair in zip(tokens, tokens[1:])]
        counts = Counter(terms)
        vocabulary = self.model['vocabulary']
        features = {vocabulary[t]: (1 + math.log(n)) * self.model['idf'][vocabulary[t]]
                    for t, n in counts.items() if t in vocabulary}
        norm = math.sqrt(sum(value * value for value in features.values()))
        if norm:
            features = {index: value / norm for index, value in features.items()}
        logits = [bias + sum(weights[index] * value for index, value in features.items())
                  for bias, weights in zip(self.model['intercept'], self.model['coefficients'])]
        maximum = max(logits)
        exp = [math.exp(value - maximum) for value in logits]
        probabilities = [value / sum(exp) for value in exp]
        winner = max(range(len(probabilities)), key=probabilities.__getitem__)
        confidence = probabilities[winner]
        route = route_confidence(confidence, self.thresholds, bool(features))
        return {'label': self.classes[winner], 'prediction': self.classes[winner], 'confidence': confidence, 'route': route,
                'probabilities': dict(zip(self.classes, probabilities)),
                'known_features': len(features), 'model_version': self.model['model_version']}


Classifier = OccupationClassifier


if __name__ == '__main__':
    import argparse
    parser = argparse.ArgumentParser()
    parser.add_argument('text')
    parser.add_argument('--model', type=Path)
    args = parser.parse_args()
    print(json.dumps(OccupationClassifier(args.model).predict(args.text), indent=2))