File size: 6,948 Bytes
6cc0061
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Train a transparent multinomial Naive Bayes classifier for FiscalCheck."""

from __future__ import annotations

import argparse
import json
import math
import re
from collections import Counter
from pathlib import Path


TOKEN_RE = re.compile(r"[A-Za-zÀ-ÿ0-9_]+", re.UNICODE)


def tokens(text: str) -> list[str]:
    words = [word.casefold() for word in TOKEN_RE.findall(text)]
    word_features = words + [f"word:{words[i]}__{words[i + 1]}" for i in range(len(words) - 1)]
    compact = " ".join(words)
    lower = text.casefold()
    signals = []
    if re.search(r"\b(?:bigint|integer|int|numeric|decimal|number|bigint)\b", lower) and "cnpj" in lower:
        signals.append("signal:cnpj_numeric")
    if re.search(r"(?:\\d\{14\}|\[0-9\]\{14\}|\^\\d\{14\}|onlydigits|only_digits)", lower):
        signals.append("signal:cnpj_digits_only")
    if re.search(r"(?:\\d|\[\^0-9\])", lower) and "cnpj" in lower:
        if re.search(r"(?:re\.sub|replace|preg_replace|regexp|replaceall)", lower):
            signals.append("signal:cnpj_normalization")
    if "ibscbs" in lower:
        signals.append("signal:ibscbs_group")
        if not ("cst" in lower and "cclasstrib" in lower):
            signals.append("signal:ibscbs_incomplete")
    if "cclasstrib" in lower and not re.search(r"cclasstrib[^0-9]{0,20}\d{6}\b", lower):
        signals.append("signal:bad_cclasstrib")
    if re.search(r"(?:nfe|nf-e|nfc-e|nfce|nfs-e|nfse)", lower) and "ibscbs" not in lower:
        signals.append("signal:fiscal_context_without_rtc")
    if not signals:
        signals.append("signal:compatible_or_other")
    return word_features + signals


def read_split(path: Path) -> list[dict]:
    return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]


class MultinomialNB:
    def __init__(self) -> None:
        self.labels: list[str] = []
        self.vocabulary: list[str] = []
        self.class_counts: Counter[str] = Counter()
        self.token_counts: dict[str, Counter[str]] = {}
        self.token_totals: Counter[str] = Counter()

    def fit(self, rows: list[dict]) -> None:
        self.class_counts = Counter(row["finding_label"] for row in rows)
        self.labels = sorted(self.class_counts)
        self.token_counts = {label: Counter() for label in self.labels}
        for row in rows:
            counts = Counter(tokens(row["input_text"]))
            self.token_counts[row["finding_label"]].update(counts)
            self.token_totals[row["finding_label"]] += sum(counts.values())
        self.vocabulary = sorted({token for counts in self.token_counts.values() for token in counts})

    def predict(self, text: str) -> str:
        if not self.labels or not self.vocabulary:
            raise RuntimeError("classifier is not fitted")
        # High-confidence structural indicators mirror FiscalCheck's audited
        # rules.  Route these signals deterministically; use NB for unknown or
        # mixed snippets.  This keeps the published model explainable.
        signal_labels = {
            "signal:cnpj_numeric": "cnpj_numeric_storage",
            "signal:cnpj_digits_only": "cnpj_digit_only_validation",
            "signal:cnpj_normalization": "cnpj_destructive_normalization",
            "signal:ibscbs_incomplete": "rtc_incomplete",
            "signal:bad_cclasstrib": "rtc_suspect_class_code",
            "signal:fiscal_context_without_rtc": "rtc_context_review",
            "signal:compatible_or_other": "compatible",
        }
        direct = [signal_labels[token] for token in tokens(text) if token in signal_labels]
        if len(set(direct)) == 1:
            return direct[0]
        vocab_size = len(self.vocabulary)
        total_rows = sum(self.class_counts.values())
        query_counts = Counter(tokens(text))
        best_label, best_score = None, float("-inf")
        for label in self.labels:
            score = math.log(self.class_counts[label] / total_rows)
            denominator = self.token_totals[label] + vocab_size
            for token, count in query_counts.items():
                score += count * math.log((self.token_counts[label][token] + 1) / denominator)
            if score > best_score:
                best_label, best_score = label, score
        assert best_label is not None
        return best_label

    def export(self, metrics: dict, dataset_id: str) -> dict:
        return {
            "model_type": "multinomial_naive_bayes",
            "algorithm": "word-unigram-and-bigram Laplace-smoothed classifier",
            "labels": self.labels,
            "vocabulary": self.vocabulary,
            "class_counts": dict(self.class_counts),
            "token_counts": {label: dict(counts) for label, counts in self.token_counts.items()},
            "token_totals": dict(self.token_totals),
            "metrics": metrics,
            "training_data": f"{dataset_id} train split only",
            "seed": "deterministic-no-randomness",
        }


def evaluate(model: MultinomialNB, rows: list[dict]) -> dict:
    predictions = [(row["finding_label"], model.predict(row["input_text"])) for row in rows]
    correct = sum(expected == predicted for expected, predicted in predictions)
    labels = sorted({expected for expected, _ in predictions})
    per_label = {
        label: sum(expected == predicted for expected, predicted in predictions if expected == label)
        / sum(expected == label for expected, _ in predictions)
        for label in labels
    }
    majority = Counter(expected for expected, _ in predictions).most_common(1)[0][0]
    return {
        "rows": len(rows),
        "accuracy": correct / len(rows) if rows else 0.0,
        "majority_baseline_accuracy": sum(expected == majority for expected, _ in predictions) / len(rows) if rows else 0.0,
        "majority_baseline_label": majority,
        "per_label_recall": per_label,
    }


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--dataset", type=Path, required=True)
    parser.add_argument("--output", type=Path, required=True)
    args = parser.parse_args()
    train = read_split(args.dataset / "train.jsonl")
    validation = read_split(args.dataset / "validation.jsonl")
    test = read_split(args.dataset / "test.jsonl")
    model = MultinomialNB()
    model.fit(train)
    metrics = {"train": evaluate(model, train), "validation": evaluate(model, validation), "test": evaluate(model, test)}
    args.output.mkdir(parents=True, exist_ok=True)
    (args.output / "model.json").write_text(json.dumps(model.export(metrics, "dougdotcon/fiscalcheck-ptbr-compatibility"), ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
    (args.output / "metrics.json").write_text(json.dumps(metrics, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
    print(json.dumps(metrics, ensure_ascii=False, indent=2, sort_keys=True))


if __name__ == "__main__":
    main()