File size: 12,881 Bytes
3bf009f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
979f3c3
3bf009f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
"""Tests Sprint 71 โ€” A.I.1 chantier 2 : rare-token recall.

Couvre :

1. ``tokenize`` : Unicode-aware, contractions (l'an, d'une),
   composรฉs (peut-รชtre, c'est-ร -dire), apostrophe typographique
   โ€™, vide / None.
2. ``frequency_distribution`` : comptage corpus-wide, casse
   insensible par dรฉfaut, multi-doc.
3. ``extract_rare_tokens`` : hapax (max_freq=1), dis legomena
   (max_freq=2), ``max_freq < 1`` โ†’ ValueError.
4. ``compute_rare_token_recall`` :
   - cas standard : 5 rares en GT, 4 prรฉservรฉs
   - multiplicitรฉ : un rare prรฉsent 2ร— en GT, 1ร— en hyp โ†’ 0.5
   - hyp vide โ†’ 0.0, tous manquรฉs
   - GT sans rare โ†’ 0.0, listes vides
   - case_sensitive
5. ``rare_token_recall`` raccourci.
6. **Cas rรฉaliste** : registre d'รฉtat civil, noms propres rares
   discriminรฉs.
"""

from __future__ import annotations

import pytest

from picarones.measurements.rare_tokens import (
    compute_rare_token_recall,
    extract_rare_tokens,
    frequency_distribution,
    rare_token_recall,
    tokenize,
)


# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# 1. tokenize
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€


class TestTokenize:
    def test_basic_words(self) -> None:
        assert tokenize("hello world") == ["hello", "world"]

    def test_contraction_apostrophe_ascii(self) -> None:
        # L'an est un seul token
        assert tokenize("L'an") == ["L'an"]
        assert tokenize("d'une chose") == ["d'une", "chose"]

    def test_contraction_apostrophe_typographic(self) -> None:
        # โ€™ (U+2019) traitรฉ comme ' ร  l'intรฉrieur du token
        assert tokenize("dโ€™une") == ["dโ€™une"]

    def test_compound_with_hyphen(self) -> None:
        assert tokenize("peut-รชtre") == ["peut-รชtre"]
        assert tokenize("c'est-ร -dire") == ["c'est-ร -dire"]

    def test_unicode_diacritics(self) -> None:
        assert tokenize("cafรฉ ร  รฉ รด") == ["cafรฉ", "ร ", "รฉ", "รด"]

    def test_punctuation_separates(self) -> None:
        assert tokenize("Marie, fille.") == ["Marie", "fille"]

    def test_numbers_are_tokens(self) -> None:
        assert tokenize("en 1789 et 1790") == ["en", "1789", "et", "1790"]

    def test_empty_input(self) -> None:
        assert tokenize("") == []
        assert tokenize(None) == []


# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# 2. frequency_distribution
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€


class TestFrequencyDistribution:
    def test_single_document(self) -> None:
        freq = frequency_distribution(["hello hello world"])
        assert freq["hello"] == 2
        assert freq["world"] == 1

    def test_multi_document_summed(self) -> None:
        docs = ["hello world", "hello sun", "moon"]
        freq = frequency_distribution(docs)
        assert freq["hello"] == 2
        assert freq["world"] == 1
        assert freq["moon"] == 1

    def test_case_insensitive_default(self) -> None:
        freq = frequency_distribution(["Hello hello HELLO"])
        assert freq["hello"] == 3
        assert "Hello" not in freq

    def test_case_sensitive(self) -> None:
        freq = frequency_distribution(
            ["Hello hello"], case_sensitive=True,
        )
        assert freq["Hello"] == 1
        assert freq["hello"] == 1


# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# 3. extract_rare_tokens
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€


class TestExtractRareTokens:
    def test_hapax_only(self) -> None:
        # max_freq=1 โ†’ uniquement les tokens uniques
        docs = ["a a b c"]
        rare = extract_rare_tokens(docs, max_freq=1)
        assert rare == frozenset({"b", "c"})

    def test_hapax_plus_dis_legomena_default(self) -> None:
        # max_freq=2 par dรฉfaut
        docs = ["a a a b b c"]
        rare = extract_rare_tokens(docs)
        # a (3) รฉcartรฉ, b (2) inclus, c (1) inclus
        assert rare == frozenset({"b", "c"})

    def test_invalid_max_freq(self) -> None:
        with pytest.raises(ValueError):
            extract_rare_tokens(["x"], max_freq=0)
        with pytest.raises(ValueError):
            extract_rare_tokens(["x"], max_freq=-1)

    def test_empty_corpus(self) -> None:
        assert extract_rare_tokens([]) == frozenset()


# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# 4. compute_rare_token_recall
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€


class TestComputeRareTokenRecall:
    def test_full_recall(self) -> None:
        rare = {"alice", "bob"}
        m = compute_rare_token_recall(
            "alice et bob mangent", "alice et bob mangent", rare,
        )
        assert m["recall"] == 1.0
        assert m["n_rare_tokens_in_reference"] == 2
        assert m["n_rare_tokens_recalled"] == 2
        assert m["missed_tokens"] == []

    def test_partial_recall(self) -> None:
        rare = {"alice", "bob", "charlie"}
        m = compute_rare_token_recall(
            "alice bob charlie", "alice bob", rare,
        )
        assert m["n_rare_tokens_in_reference"] == 3
        assert m["n_rare_tokens_recalled"] == 2
        assert m["recall"] == pytest.approx(2 / 3)
        assert m["missed_tokens"] == ["charlie"]

    def test_zero_recall(self) -> None:
        rare = {"alice", "bob"}
        m = compute_rare_token_recall(
            "alice bob", "x y z", rare,
        )
        assert m["recall"] == 0.0
        assert sorted(m["missed_tokens"]) == ["alice", "bob"]

    def test_multiplicity(self) -> None:
        # Un token rare prรฉsent 2 fois en GT, 1 fois en hyp โ†’ 0.5
        rare = {"dupont"}
        m = compute_rare_token_recall(
            "Dupont et Dupont sont lร ", "Dupont arrive", rare,
        )
        assert m["n_rare_tokens_in_reference"] == 2
        assert m["n_rare_tokens_recalled"] == 1
        assert m["recall"] == 0.5
        assert m["missed_tokens"] == ["dupont"]

    def test_no_rare_in_gt(self) -> None:
        rare = {"alice"}
        m = compute_rare_token_recall("hello world", "hello world", rare)
        assert m["n_rare_tokens_in_reference"] == 0
        assert m["recall"] == 0.0
        assert m["missed_tokens"] == []

    def test_empty_hyp(self) -> None:
        rare = {"alice", "bob"}
        m = compute_rare_token_recall("alice bob", "", rare)
        assert m["recall"] == 0.0
        assert sorted(m["missed_tokens"]) == ["alice", "bob"]

    def test_none_inputs(self) -> None:
        rare = {"alice"}
        m = compute_rare_token_recall(None, None, rare)
        assert m["recall"] == 0.0
        assert m["n_rare_tokens_in_reference"] == 0

    def test_case_insensitive_default(self) -> None:
        rare = {"Alice"}  # passรฉ en casse mixte
        m = compute_rare_token_recall("alice arrive", "alice", rare)
        # Casse-insensible par dรฉfaut : "Alice" โ†’ "alice", match
        assert m["recall"] == 1.0

    def test_case_sensitive(self) -> None:
        rare = {"Alice"}
        m = compute_rare_token_recall(
            "Alice arrive", "alice arrive", rare,
            case_sensitive=True,
        )
        # GT contient "Alice", hyp contient "alice" โ†’ pas de match
        # parce qu'on est sensible ร  la casse
        assert m["n_rare_tokens_in_reference"] == 1
        assert m["recall"] == 0.0


# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# 5. Raccourci
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€


class TestShortcut:
    def test_shortcut_matches_full(self) -> None:
        rare = {"alice", "bob"}
        full = compute_rare_token_recall("alice bob", "alice", rare)
        assert rare_token_recall(
            "alice bob", "alice", rare,
        ) == pytest.approx(full["recall"])


# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# 6. Cas rรฉaliste : registre d'รฉtat civil
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€


class TestRealisticCivilRecord:
    def test_proper_nouns_discrimination(self) -> None:
        # 3 actes d'รฉtat civil avec noms propres uniques
        corpus = [
            "Marie Dupont, fille de Jean Dupont, baptisรฉe 1789.",
            "Pierre Durand, fils de Catherine Bernard, nรฉ 1790.",
            "Jacques Martin, รฉpoux de Anne Lefรจvre, dรฉcรฉdรฉ 1801.",
        ]
        rare = extract_rare_tokens(corpus, max_freq=2)
        # Tous les noms propres sont hapax (1 occurrence) sauf
        # ยซ Dupont ยป (2 occurrences = dis legomenon). Tous restent
        # ยซ rares ยป avec max_freq=2.
        assert "dupont" in rare
        assert "lefรจvre" in rare
        assert "martin" in rare

        # OCR fautif qui rate les noms propres mais prรฉserve les
        # mots frรฉquents
        gt = corpus[0]
        hyp_bad_proper = "Marie X, fille de Jean X, baptisรฉe 1789."
        m = compute_rare_token_recall(gt, hyp_bad_proper, rare)
        # ยซ Dupont ยป prรฉsent 2 fois en GT, 0 fois en hyp โ†’ 0/2
        # ยซ Marie ยป et autres mots non rares โ†’ ignorรฉs
        # ยซ 1789 ยป est rare, prรฉsent 1 fois en GT, 1 fois en hyp โ†’ 1/1
        # ยซ baptisรฉe ยป est rare aussi
        assert m["n_rare_tokens_recalled"] < m["n_rare_tokens_in_reference"]
        # Au moins ยซ dupont ยป manquรฉ
        assert "dupont" in m["missed_tokens"]

    def test_proper_ocr_discriminates_more_than_cer(self) -> None:
        """Vรฉrifie la conjecture du plan : un OCR qui prรฉserve la
        structure mais rate les noms propres a un CER faible mais
        un rare-token recall plus dรฉgradรฉ.

        On compare deux OCR sur le mรชme GT :
        - OCR_A : rate un nom propre rare (ยซ Dupont ยป)
        - OCR_B : rate un mot frรฉquent (ยซ le ยป prรฉsent โ‰ฅ 3ร— dans
          le corpus, donc PAS dans le set des rares)
        """
        # Corpus suffisamment grand pour que ยซ le ยป soit frรฉquent
        # (โ‰ฅ 3 occurrences) et donc non-rare.
        corpus = [
            "Marie Dupont arriva le matin chez le notaire.",
            "Pierre Durand le suivit dans le couloir.",
            "Catherine Bernard attendait le retour le soir.",
            "Jacques Martin รฉcouta le rรฉcit de la journรฉe.",
        ]
        rare = extract_rare_tokens(corpus, max_freq=2)
        # Sanitรฉ : ยซ le ยป n'est PAS rare (apparaรฎt 7 fois)
        assert "le" not in rare
        # ยซ Dupont ยป est rare (1 occurrence)
        assert "dupont" in rare

        gt = corpus[0]
        hyp_a_proper_lost = "Marie X arriva le matin chez le notaire."
        hyp_b_freq_lost = "Marie Dupont arriva matin chez notaire."  # 2 ยซ le ยป manquent
        m_a = compute_rare_token_recall(gt, hyp_a_proper_lost, rare)
        m_b = compute_rare_token_recall(gt, hyp_b_freq_lost, rare)
        # OCR_A perd un rare (ยซ Dupont ยป), OCR_B n'en perd aucun
        # (ยซ le ยป n'est pas rare donc sa perte n'affecte pas le recall)
        assert m_a["recall"] < m_b["recall"]
        assert m_b["recall"] == 1.0