humetrix 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
humetrix/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ from .analyzer import HumorAnalyzer
2
+ from .quantum_entropy import QuantumUncertainty, QuantumIncongruity
3
+ from .surprise import LocalGlobalSurprise
4
+ from .kaoetal import KaoConfig, KaoAmbiguity, KaoDistinctiveness
5
+
6
+ __all__ = [
7
+ "HumorAnalyzer",
8
+ "QuantumUncertainty",
9
+ "QuantumIncongruity",
10
+ "LocalGlobalSurprise",
11
+ "KaoConfig",
12
+ "KaoAmbiguity",
13
+ "KaoDistinctiveness"
14
+ ]
humetrix/_resources.py ADDED
@@ -0,0 +1,58 @@
1
+ """Download and load the external resources used by the metrics."""
2
+
3
+ from pathlib import Path
4
+ from typing import Iterable, Optional
5
+
6
+ import safetensors.numpy
7
+ from gensim.models import KeyedVectors
8
+ from huggingface_hub import hf_hub_download
9
+
10
+ from .configs import EXTERNAL_EMBEDDINGS, RESOURCES_REPO, RESOURCES_REVISION
11
+
12
+
13
+ def download_resource(
14
+ filenames: Iterable[str],
15
+ subfolder: Optional[str] = None,
16
+ repo_id: str = RESOURCES_REPO,
17
+ revision: str = RESOURCES_REVISION,
18
+ ) -> Path:
19
+ """Download files from the Hugging Face Hub repository and return their directory."""
20
+
21
+ paths = [
22
+ hf_hub_download(repo_id, filename, subfolder=subfolder, revision=revision)
23
+ for filename in filenames
24
+ ]
25
+
26
+ if not paths:
27
+ raise ValueError("At least one filename is required.")
28
+ return Path(paths[0]).parent
29
+
30
+
31
+ def download_embeddings(language: str) -> Path:
32
+ """Download the default glove embeddings for a language."""
33
+ filenames = ["embeddings.safetensors", "vocab.txt"]
34
+ if language in EXTERNAL_EMBEDDINGS:
35
+ repo_id, revision = EXTERNAL_EMBEDDINGS[language]
36
+ return download_resource(filenames, repo_id=repo_id, revision=revision)
37
+ return download_resource(filenames, subfolder=f"glove/{language}")
38
+
39
+
40
+ def load_embeddings(directory: Path) -> KeyedVectors:
41
+ """Load embeddings from `embeddings.safetensors` + `vocab.txt` files."""
42
+ tensors = safetensors.numpy.load_file(directory / "embeddings.safetensors")
43
+ vectors = tensors["embeddings"]
44
+
45
+ with open(directory / "vocab.txt", encoding="utf-8", newline="\n") as f:
46
+ vocab = f.read().split("\n")
47
+ if vocab[-1] == "":
48
+ vocab.pop()
49
+
50
+ if len(vocab) != len(vectors):
51
+ raise ValueError(
52
+ f"{directory}: vocab.txt has {len(vocab)} words, "
53
+ f"but the matrix has {len(vectors)} rows."
54
+ )
55
+
56
+ embeddings = KeyedVectors(vector_size=vectors.shape[1])
57
+ embeddings.add_vectors(vocab, vectors)
58
+ return embeddings
humetrix/analyzer.py ADDED
@@ -0,0 +1,397 @@
1
+ """
2
+ This module provides a centralized API for analyzing humor in text using various metrics.
3
+
4
+ The HumorAnalyzer class requires at least the analysis language. Depending on the metrics
5
+ to be used, additional resources such as word embeddings, skip-gram models, and n-gram models
6
+ may need to be provided.
7
+
8
+ Resources are loaded as needed to optimize performance.
9
+ """
10
+
11
+ import importlib
12
+ from pathlib import Path
13
+ from typing import Dict, Union
14
+
15
+ import polars as pl
16
+ import safetensors.torch
17
+ import spacy
18
+ import torch
19
+ from gensim.models import KeyedVectors
20
+ from spacy.language import Language
21
+ from transformers import AutoModelForMaskedLM, AutoTokenizer, Pipeline, pipeline
22
+
23
+ from ._resources import download_embeddings, download_resource, load_embeddings
24
+ from .configs import SPACY_MODELS, TOKENIZER_MODELS, TRANSFORMER_MODELS
25
+ from .kaoetal import KaoAmbiguity, KaoConfig, KaoDistinctiveness
26
+ from .quantum_entropy import QuantumIncongruity, QuantumUncertainty
27
+ from .skipgram import SGNS, build_vocabulary
28
+ from .surprise import LocalGlobalSurprise
29
+
30
+
31
+ def _load_spacy_model(name: str) -> Language:
32
+ """Load a spacy mode, downloading it on first use if needed."""
33
+ try:
34
+ return spacy.load(name)
35
+ except OSError:
36
+ pass
37
+
38
+ from spacy.cli.download import download
39
+
40
+ try:
41
+ download(name)
42
+ except SystemExit:
43
+ raise OSError(
44
+ f"spacy model {name} is not installed and could not be "
45
+ f"downloaded automatically. Install it with:\n"
46
+ f" python -m spacy download {name}"
47
+ ) from None
48
+
49
+ importlib.invalidate_caches()
50
+ return spacy.load(name)
51
+
52
+
53
+ class HumorAnalyzer:
54
+ """
55
+ A class to analyze humor in text using different metrics.
56
+
57
+ Some of the metrics implemented here require external resources such as word embeddings,
58
+ skip-gram models, and n-gram models.
59
+
60
+ Supported metrics include:
61
+ - Quantum Incongruity
62
+ - Quantum Uncertainty
63
+ - Local-Global Surprise
64
+ - Kao et al.'s Ambiguity
65
+ - Kao et al.'s Distinctiveness
66
+
67
+ Parameters
68
+ ----------
69
+ language : str
70
+ The language of analysis.
71
+ embeddings_path : str, optional
72
+ Directory with `embeddings.safetensors` and `vocab.txt`. If None, the default embeddings for the language are downloaded.
73
+ skipgram_path : str, optional
74
+ Path to the skip-gram model file is safetensors format. If None (together with `skipgram_vocab_path`), the default model for the language is downloaded.
75
+ skipgram_vocab_path: str, optional
76
+ CSV vocabulary used to train the skipgram model (column `ngram`). Required when `skipgram_path` is given.
77
+ ngram_dir_path : str, optional
78
+ Directory with `1gram.csv` and `3gram.csv`. If None, the default n-gram tables for the language are downloaded.
79
+ transformer_model_name : str, optional
80
+ Name of the transformer model to use. Defaults to None.
81
+ device: str or torch.device, optional
82
+ Device for the neural models. If None, eses a GPU when available.
83
+
84
+ Raises
85
+ ------
86
+ ValueError
87
+ If the specified language is not supported.
88
+ """
89
+
90
+ def __init__(
91
+ self,
92
+ language: str,
93
+ embeddings_path: Union[str, None] = None,
94
+ skipgram_path: Union[str, None] = None,
95
+ skipgram_vocab_path: Union[str, None] = None,
96
+ ngram_dir_path: Union[str, None] = None,
97
+ transformer_model_name: Union[str, None] = None,
98
+ device: Union[str, torch.device, None] = None,
99
+ ) -> None:
100
+ self.language = language
101
+ if language not in SPACY_MODELS:
102
+ raise ValueError(f"Unsupported language: {language}")
103
+
104
+ if device is None:
105
+ device = "cuda" if torch.cuda.is_available() else "cpu"
106
+ self.device = torch.device(device)
107
+
108
+ self._transformer_model_name = transformer_model_name
109
+ self._paths = {
110
+ "embeddings": embeddings_path,
111
+ "skipgram": skipgram_path,
112
+ "skipgram_vocab": skipgram_vocab_path,
113
+ "ngram_dir": ngram_dir_path,
114
+ }
115
+ self._models = {}
116
+
117
+ def _load_model(
118
+ self, name: str
119
+ ) -> Union[Language, Dict, KeyedVectors, pl.DataFrame, SGNS, Pipeline]:
120
+ """
121
+ Load and cache the specified model.
122
+
123
+ Parameters
124
+ ----------
125
+ name : str
126
+ The name of the model to load.
127
+
128
+ Returns
129
+ -------
130
+ spacy model or dict or gensim KeyedVectors or polars DataFrame or SGNS model or transformers pipeline
131
+ The loaded model.
132
+
133
+ Raises
134
+ ------
135
+ ValueError
136
+ If the model type is unknown or required paths are not provided.
137
+ """
138
+ if name not in self._models:
139
+ if name == "spacy":
140
+ model_name = SPACY_MODELS[self.language]
141
+ self._models[name] = _load_spacy_model(model_name)
142
+ elif name == "tokenizer":
143
+ if self.language in TOKENIZER_MODELS:
144
+ model_name = TOKENIZER_MODELS[self.language]
145
+ self._models[name] = _load_spacy_model(model_name)
146
+ else:
147
+ self._models[name] = spacy.blank(self.language)
148
+ elif name == "bert":
149
+ model_name = TRANSFORMER_MODELS[self.language]
150
+ tokenizer = AutoTokenizer.from_pretrained(model_name)
151
+ lm = AutoModelForMaskedLM.from_pretrained(
152
+ model_name, device_map=self.device
153
+ )
154
+ self._models[name] = {"tokenizer": tokenizer, "lm": lm}
155
+ elif name == "embeddings":
156
+ directory = self._paths["embeddings"]
157
+ if directory is None:
158
+ directory = download_embeddings(self.language)
159
+ self._models[name] = load_embeddings(Path(directory))
160
+ elif name == "ngram1" or name == "ngram3":
161
+ ngram_dir = self._paths["ngram_dir"]
162
+ if ngram_dir is None:
163
+ ngram_dir = download_resource(
164
+ ["1gram.csv", "3gram.csv"], subfolder=f"ngrams/{self.language}"
165
+ )
166
+ ngram_path = Path(ngram_dir) / f"{name[-1]}gram.csv"
167
+ self._models[name] = pl.read_csv(ngram_path)
168
+ elif name == "kao_models":
169
+ skipgram_path = self._paths["skipgram"]
170
+ vocab_path = self._paths["skipgram_vocab"]
171
+ if skipgram_path is None and vocab_path is None:
172
+ skipgram_dir = download_resource(
173
+ ["model.safetensors"], subfolder=f"skipgram/{self.language}"
174
+ )
175
+ vocab_dir = download_resource(
176
+ ["1gram.csv"], subfolder=f"ngrams/{self.language}"
177
+ )
178
+ skipgram_path = skipgram_dir / "model.safetensors"
179
+ vocab_path = vocab_dir / "1gram.csv"
180
+ elif skipgram_path is None or vocab_path is None:
181
+ raise ValueError(
182
+ "Provide both `skipgram_path` and `skipgram_vocab_path`, "
183
+ "or neither to use the default skipgram model."
184
+ )
185
+ vocab = build_vocabulary(str(vocab_path))
186
+ state_dict = safetensors.torch.load_file(skipgram_path)
187
+ embedding_dim = state_dict["embeddings.weight"].shape[1]
188
+ sgns = SGNS(vocab, embedding_dim)
189
+ sgns.load_state_dict(state_dict)
190
+ sgns.eval()
191
+ self._models[name] = {
192
+ "skipgram": sgns,
193
+ "ngram1": self._load_model("ngram1"),
194
+ "ngram3": self._load_model("ngram3"),
195
+ }
196
+ elif name == "transformer_pipeline":
197
+ if self._transformer_model_name:
198
+ model_name = self._transformer_model_name
199
+ else:
200
+ model_name = TRANSFORMER_MODELS[self.language]
201
+ self._models[name] = pipeline(
202
+ "feature-extraction", model_name, device=self.device
203
+ )
204
+ else:
205
+ raise ValueError(f"Unknown model type: {name}")
206
+ return self._models[name]
207
+
208
+ def quantum_incongruity(
209
+ self, sentence: str, backend: str = "word_embeddings"
210
+ ) -> Union[float, None]:
211
+ """
212
+ Calculate the quantum incongruity of a sentence.
213
+
214
+ Parameters
215
+ ----------
216
+ sentence : str
217
+ The sentence to analyze.
218
+ backend : {'word_embeddings', 'transformer'}, optional
219
+ The backend to use for embeddings. Defaults to 'word_embeddings'.
220
+
221
+ Returns
222
+ -------
223
+ float or None
224
+ The quantum incongruity score, or None if it cannot be calculated.
225
+
226
+ Raises
227
+ ------
228
+ ValueError
229
+ If an unknown backend is specified.
230
+
231
+ See Also
232
+ --------
233
+ quantum_entropy.QuantumIncongruity.score : Method that performs the actual scoring.
234
+ """
235
+ if backend == "word_embeddings":
236
+ embeddings = self._load_model("embeddings")
237
+ elif backend == "transformer":
238
+ embeddings = self._load_model("transformer_pipeline")
239
+ else:
240
+ raise ValueError(f"Unknown backend for quantum incongruity: {backend}")
241
+ scorer = QuantumIncongruity(
242
+ sentence=sentence,
243
+ embeddings=embeddings,
244
+ spacy_model=self._load_model("tokenizer"),
245
+ device=self.device,
246
+ )
247
+ return scorer.score()
248
+
249
+ def quantum_uncertainty(
250
+ self, sentence: str, backend: str = "word_embeddings"
251
+ ) -> Union[float, None]:
252
+ """
253
+ Calculate the quantum uncertainty of a sentence.
254
+
255
+ Parameters
256
+ ----------
257
+ sentence : str
258
+ The sentence to analyze.
259
+ backend : {'word_embeddings', 'transformer'}, optional
260
+ The backend to use for embeddings. Defaults to 'word_embeddings'.
261
+
262
+ Returns
263
+ -------
264
+ float or None
265
+ The quantum uncertainty score, or None if it cannot be calculated.
266
+
267
+ Raises
268
+ ------
269
+ ValueError
270
+ If an unknown backend is specified.
271
+
272
+ See Also
273
+ --------
274
+ quantum_entropy.QuantumUncertainty.score : Method that performs the actual scoring.
275
+ """
276
+ if backend == "word_embeddings":
277
+ embeddings = self._load_model("embeddings")
278
+ elif backend == "transformer":
279
+ embeddings = self._load_model("transformer_pipeline")
280
+ else:
281
+ raise ValueError(f"Unknown backend for quantum uncertainty: {backend}")
282
+ scorer = QuantumUncertainty(
283
+ sentence=sentence,
284
+ embeddings=embeddings,
285
+ spacy_model=self._load_model("tokenizer"),
286
+ device=self.device,
287
+ )
288
+ return scorer.score()
289
+
290
+ def local_global_surprise(
291
+ self, sentence: str, pun_sign: str, alt_sign: str
292
+ ) -> float:
293
+ """
294
+ Calculate the local-global surprise of a sentence.
295
+
296
+ Parameters
297
+ ----------
298
+ sentence : str
299
+ The sentence to analyze.
300
+ pun_sign : str
301
+ The pun sign. The word or phrase that is ambiguous or humorous.
302
+ alt_sign : str
303
+ The alternative sign. The word or phrase that is evoked by the pun sign.
304
+
305
+ Returns
306
+ -------
307
+ float
308
+ The local-global surprise score.
309
+
310
+ See Also
311
+ --------
312
+ surprise.LocalGlobalSurprise.score : Method that performs the actual scoring.
313
+ """
314
+ bert_models = self._load_model("bert")
315
+ scorer = LocalGlobalSurprise(
316
+ sentence=sentence,
317
+ pun_sign=pun_sign,
318
+ alt_sign=alt_sign,
319
+ spacy_model=self._load_model("tokenizer"),
320
+ )
321
+ return scorer.score(tokenizer=bert_models["tokenizer"], lm=bert_models["lm"])
322
+
323
+ def kao_ambiguity(self, sentence: str, pun_sign: str, alt_sign: str) -> float:
324
+ """
325
+ Calculate Kao et al.'s ambiguity of a sentence.
326
+
327
+ Parameters
328
+ ----------
329
+ sentence : str
330
+ The sentence to analyze.
331
+ pun_sign : str
332
+ The pun sign. The word or phrase that is ambiguous or humorous.
333
+ alt_sign : str
334
+ The alternative sign. The word or phrase that is evoked by the pun sign.
335
+
336
+ Returns
337
+ -------
338
+ float
339
+ Ambiguity score.
340
+
341
+ See Also
342
+ --------
343
+ kaoetal.KaoAmbiguity.score : Method that performs the actual scoring.
344
+ """
345
+ kao_models = self._load_model("kao_models")
346
+ config = KaoConfig(
347
+ sentence=sentence,
348
+ language=self.language,
349
+ spacy_model=self._load_model("spacy"),
350
+ skipgram=kao_models["skipgram"],
351
+ )
352
+ scorer = KaoAmbiguity(
353
+ config=config,
354
+ pun_sign=pun_sign,
355
+ alt_sign=alt_sign,
356
+ ngram1=kao_models["ngram1"],
357
+ ngram3=kao_models["ngram3"],
358
+ )
359
+ return scorer.score()
360
+
361
+ def kao_distinctiveness(self, sentence: str, pun_sign: str, alt_sign: str) -> float:
362
+ """
363
+ Calculate Kao et al.'s distinctiveness of a sentence.
364
+
365
+ Parameters
366
+ ----------
367
+ sentence : str
368
+ The sentence to analyze.
369
+ pun_sign : str
370
+ The pun sign. The word or phrase that is ambiguous or humorous.
371
+ alt_sign : str
372
+ The alternative sign. The word or phrase that is evoked by the pun sign.
373
+
374
+ Returns
375
+ -------
376
+ float
377
+ Distinctiveness score.
378
+
379
+ See Also
380
+ --------
381
+ kaoetal.KaoDistinctiveness.score : Method that performs the actual scoring.
382
+ """
383
+ kao_models = self._load_model("kao_models")
384
+ config = KaoConfig(
385
+ sentence=sentence,
386
+ language=self.language,
387
+ spacy_model=self._load_model("spacy"),
388
+ skipgram=kao_models["skipgram"],
389
+ )
390
+ scorer = KaoDistinctiveness(
391
+ config=config,
392
+ pun_sign=pun_sign,
393
+ alt_sign=alt_sign,
394
+ ngram1=kao_models["ngram1"],
395
+ ngram3=kao_models["ngram3"],
396
+ )
397
+ return scorer.score()
humetrix/configs.py ADDED
@@ -0,0 +1,36 @@
1
+ """
2
+ Definition of constants: language codes, spacy models, transformer models, and content word POS tags.
3
+ """
4
+
5
+ SPACY_MODELS = {
6
+ "pt": "pt_core_news_lg",
7
+ "en": "en_core_web_trf",
8
+ "fr": "fr_dep_news_trf",
9
+ "es": "es_dep_news_trf",
10
+ "zh": "zh_core_web_trf",
11
+ }
12
+ # Pipelines used only for tokenization in QE and Suprise metrics.
13
+ # Languages not listed here use spacy.blank(language).
14
+ TOKENIZER_MODELS = {"zh": "zh_core_web_sm"}
15
+ TRANSFORMER_MODELS = {
16
+ "pt": "neuralmind/bert-base-portuguese-cased",
17
+ "en": "bert-base-cased",
18
+ "fr": "camembert-base",
19
+ "es": "dccuchile/bert-base-spanish-wwm-cased",
20
+ "zh": "bert-base-chinese",
21
+ }
22
+ CONTENT_WORD_TAGS = {
23
+ "pt": {"ADJ", "ADV", "AUX", "NOUN", "NUM", "PART", "PROPN", "VERB"},
24
+ "en": {"ADJ", "ADV", "AUX", "NOUN", "NUM", "PART", "PROPN", "VERB"},
25
+ "fr": {"ADJ", "ADV", "AUX", "NOUN", "NUM", "PART", "PROPN", "VERB"},
26
+ "es": {"ADJ", "ADV", "AUX", "NOUN", "NUM", "PART", "PROPN", "VERB"},
27
+ }
28
+
29
+ # Hugging Face Hub repository with GloVe, skip-gram and n-gram resources
30
+ RESOURCES_REPO = "NLP-CISUC/humetrix-resources"
31
+ RESOURCES_REVISION = "cef7d0ac55fae7c154b4bb588de0be2f6d43432b"
32
+
33
+ # GloVe embeddings hosted elsewere: language -> (repo_id, revision)
34
+ EXTERNAL_EMBEDDINGS = {
35
+ "pt": ("nilc-nlp/glove-300d", "44dac98d46b885135a7fe1565f5c67f0f897a4ea")
36
+ }