polvader 1.0.0__tar.gz

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.
polvader-1.0.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Borys Jastrzębski
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,114 @@
1
+ Metadata-Version: 2.4
2
+ Name: polvader
3
+ Version: 1.0.0
4
+ Summary: Lexicon-and-rule-based sentiment analysis for Polish text, in the style of VADER.
5
+ Author: Borys Jastrzębski
6
+ Author-email: Borys Jastrzębski <borys@stanford.edu>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: Topic :: Text Processing :: Linguistic
13
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
14
+ Classifier: Natural Language :: Polish
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Requires-Dist: accelerate>=1.14.0
17
+ Requires-Dist: deep-translator>=1.11.4
18
+ Requires-Dist: numpy>=2.2.0
19
+ Requires-Dist: openpyxl>=3.1.5
20
+ Requires-Dist: pandas>=2.0.0
21
+ Requires-Dist: pip>=26.1.2
22
+ Requires-Dist: scikit-learn>=1.5.0
23
+ Requires-Dist: scipy>=1.17.1
24
+ Requires-Dist: spacy>=3.8.10
25
+ Requires-Dist: textual>=8.2.7
26
+ Requires-Dist: tqdm>=3.67.3
27
+ Requires-Dist: transformers>=5.13.0
28
+ Requires-Dist: vadersentiment>=3.3.2
29
+ Requires-Dist: xlrd>=2.0.2
30
+ Requires-Python: >=3.11
31
+ Description-Content-Type: text/markdown
32
+
33
+ # polVADER
34
+
35
+ Lexicon-and-rule-based sentiment analysis for Polish text, in the style of
36
+ [VADER](https://github.com/cjhutto/vaderSentiment). No training required at
37
+ inference time — polVADER scores text directly from a Polish sentiment
38
+ lexicon plus a rule layer for negation, intensifiers, capitalization,
39
+ punctuation emphasis, and contrastive conjunctions (`ale`, `jednak`, ...).
40
+
41
+ ## Install
42
+
43
+ ```bash
44
+ pip install polvader
45
+ python -m spacy download pl_core_news_lg
46
+ ```
47
+
48
+ polVADER uses spaCy's `pl_core_news_lg` model for Polish tokenization and
49
+ lemmatization; it is not bundled with the package and must be downloaded
50
+ once after install.
51
+
52
+ ## Usage
53
+
54
+ ```python
55
+ from polvader import Lexicon
56
+
57
+ lex = Lexicon()
58
+ scores = lex.polarity_scores("To był wspaniały dzień pełen szczęścia.")
59
+ print(scores)
60
+ # {'neg': 0.0, 'neu': 0.xxx, 'pos': 0.xxx, 'compound': 0.879}
61
+ ```
62
+
63
+ `polarity_scores()` returns the same `{neg, neu, pos, compound}` contract as
64
+ the original English VADER. `compound` is a single normalized score in
65
+ `[-1, +1]`; `neg`/`neu`/`pos` are proportions of the text's sentiment-bearing
66
+ content.
67
+
68
+ Batch scoring (uses spaCy's `nlp.pipe` internally, much faster than a loop):
69
+
70
+ ```python
71
+ results = lex.score_batch([
72
+ "Świetny produkt, polecam!",
73
+ "Nigdy więcej tu nie wrócę.",
74
+ ], batch_size=256)
75
+ ```
76
+
77
+ For social-media text (tweets, comments — hashtags, @mentions, emoji), run
78
+ `preprocess_social()` first:
79
+
80
+ ```python
81
+ from polvader import preprocess_social
82
+
83
+ text = preprocess_social(raw_tweet)
84
+ scores = lex.polarity_scores(text)
85
+ ```
86
+
87
+ ## Lexicon
88
+
89
+ By default `Lexicon()` loads the coverage-expanded, weight-tuned lexicon
90
+ (~29.5k words): the original ~8.5k-word hand-built Polish valence lexicon,
91
+ expanded via K-NN over PLLuM-8B's static input-embedding table (not a
92
+ contextual/forward-pass embedding — benchmarked as equal-or-better and far
93
+ cheaper to compute) across a multi-domain Polish corpus (tweets,
94
+ product/hotel/service reviews, general sentiment text), then weight-tuned
95
+ end-to-end against those same domains. Pass `expanded=False` for the
96
+ smaller, untuned ~8.5k-word base lexicon instead:
97
+
98
+ ```python
99
+ lex = Lexicon(expanded=False)
100
+ ```
101
+
102
+ ## Modifier system
103
+
104
+ `polarity_scores()` applies, on top of the raw lexicon lookup:
105
+ negation (`nie`, multi-word negators), booster/dampener adverbs, ALL-CAPS
106
+ emphasis, exclamation/question-mark emphasis, sentence-aware scoring for
107
+ multi-sentence text, and contrastive-conjunction reweighting (text after
108
+ "ale"/"jednak" counts more than text before it). Each category can be
109
+ disabled independently via keyword flags on `polarity_scores()` for
110
+ ablation/diagnostic purposes — see its docstring for the full flag list.
111
+
112
+ ## License
113
+
114
+ MIT
@@ -0,0 +1,82 @@
1
+ # polVADER
2
+
3
+ Lexicon-and-rule-based sentiment analysis for Polish text, in the style of
4
+ [VADER](https://github.com/cjhutto/vaderSentiment). No training required at
5
+ inference time — polVADER scores text directly from a Polish sentiment
6
+ lexicon plus a rule layer for negation, intensifiers, capitalization,
7
+ punctuation emphasis, and contrastive conjunctions (`ale`, `jednak`, ...).
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pip install polvader
13
+ python -m spacy download pl_core_news_lg
14
+ ```
15
+
16
+ polVADER uses spaCy's `pl_core_news_lg` model for Polish tokenization and
17
+ lemmatization; it is not bundled with the package and must be downloaded
18
+ once after install.
19
+
20
+ ## Usage
21
+
22
+ ```python
23
+ from polvader import Lexicon
24
+
25
+ lex = Lexicon()
26
+ scores = lex.polarity_scores("To był wspaniały dzień pełen szczęścia.")
27
+ print(scores)
28
+ # {'neg': 0.0, 'neu': 0.xxx, 'pos': 0.xxx, 'compound': 0.879}
29
+ ```
30
+
31
+ `polarity_scores()` returns the same `{neg, neu, pos, compound}` contract as
32
+ the original English VADER. `compound` is a single normalized score in
33
+ `[-1, +1]`; `neg`/`neu`/`pos` are proportions of the text's sentiment-bearing
34
+ content.
35
+
36
+ Batch scoring (uses spaCy's `nlp.pipe` internally, much faster than a loop):
37
+
38
+ ```python
39
+ results = lex.score_batch([
40
+ "Świetny produkt, polecam!",
41
+ "Nigdy więcej tu nie wrócę.",
42
+ ], batch_size=256)
43
+ ```
44
+
45
+ For social-media text (tweets, comments — hashtags, @mentions, emoji), run
46
+ `preprocess_social()` first:
47
+
48
+ ```python
49
+ from polvader import preprocess_social
50
+
51
+ text = preprocess_social(raw_tweet)
52
+ scores = lex.polarity_scores(text)
53
+ ```
54
+
55
+ ## Lexicon
56
+
57
+ By default `Lexicon()` loads the coverage-expanded, weight-tuned lexicon
58
+ (~29.5k words): the original ~8.5k-word hand-built Polish valence lexicon,
59
+ expanded via K-NN over PLLuM-8B's static input-embedding table (not a
60
+ contextual/forward-pass embedding — benchmarked as equal-or-better and far
61
+ cheaper to compute) across a multi-domain Polish corpus (tweets,
62
+ product/hotel/service reviews, general sentiment text), then weight-tuned
63
+ end-to-end against those same domains. Pass `expanded=False` for the
64
+ smaller, untuned ~8.5k-word base lexicon instead:
65
+
66
+ ```python
67
+ lex = Lexicon(expanded=False)
68
+ ```
69
+
70
+ ## Modifier system
71
+
72
+ `polarity_scores()` applies, on top of the raw lexicon lookup:
73
+ negation (`nie`, multi-word negators), booster/dampener adverbs, ALL-CAPS
74
+ emphasis, exclamation/question-mark emphasis, sentence-aware scoring for
75
+ multi-sentence text, and contrastive-conjunction reweighting (text after
76
+ "ale"/"jednak" counts more than text before it). Each category can be
77
+ disabled independently via keyword flags on `polarity_scores()` for
78
+ ablation/diagnostic purposes — see its docstring for the full flag list.
79
+
80
+ ## License
81
+
82
+ MIT
@@ -0,0 +1,50 @@
1
+ [project]
2
+ name = "polvader"
3
+ version = "1.0.0"
4
+ description = "Lexicon-and-rule-based sentiment analysis for Polish text, in the style of VADER."
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ dependencies = [
8
+ "accelerate>=1.14.0",
9
+ "deep-translator>=1.11.4",
10
+ "numpy>=2.2.0",
11
+ "openpyxl>=3.1.5",
12
+ "pandas>=2.0.0",
13
+ "pip>=26.1.2",
14
+ "scikit-learn>=1.5.0",
15
+ "scipy>=1.17.1",
16
+ "spacy>=3.8.10",
17
+ "textual>=8.2.7",
18
+ "tqdm>=3.67.3",
19
+ "transformers>=5.13.0",
20
+ "vadersentiment>=3.3.2",
21
+ "xlrd>=2.0.2",
22
+ ]
23
+ authors = [
24
+ { name="Borys Jastrzębski", email="borys@stanford.edu" },
25
+ ]
26
+ classifiers = [
27
+ "Programming Language :: Python :: 3",
28
+ "Operating System :: OS Independent",
29
+ "Intended Audience :: Science/Research",
30
+ "Topic :: Text Processing :: Linguistic",
31
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
32
+ "Natural Language :: Polish",
33
+ "License :: OSI Approved :: MIT License",
34
+ ]
35
+ license = "MIT"
36
+ license-files = ["LICENSE"]
37
+
38
+ [dependency-groups]
39
+ dev = [
40
+ "ipykernel>=7.2.0",
41
+ "twine>=6.2.0",
42
+ ]
43
+
44
+ [build-system]
45
+ requires = ["uv_build >= 0.10.7, <0.11.0"]
46
+ build-backend = "uv_build"
47
+
48
+ # [project.urls]
49
+ # Homepage = "https://github.com/pypa/sampleproject"
50
+ # Issues = "https://github.com/pypa/sampleproject/issues"
@@ -0,0 +1,34 @@
1
+ """
2
+ polVADER - Polish VADER Sentiment Analysis
3
+
4
+ A Python package for Polish sentiment analysis based on VADER methodology.
5
+ """
6
+
7
+ __version__ = "1.0.0"
8
+ __author__ = "Borys Jastrzębski <borys@stanford.edu>"
9
+
10
+
11
+ from .models import (
12
+ Lexicon,
13
+ string_preprocessing,
14
+ preprocess_social,
15
+ _BOOSTER_DICT,
16
+ _NEGATION_WORDS,
17
+ _MULT_NEGATION_WORDS,
18
+ _BUT_WORDS,
19
+ _OPINION_MARKERS,
20
+ _SOCIAL_MEDIA_SUPPLEMENT,
21
+ )
22
+
23
+ __all__ = [
24
+ "Lexicon",
25
+ "string_preprocessing",
26
+ "preprocess_social",
27
+ "_BOOSTER_DICT",
28
+ "_NEGATION_WORDS",
29
+ "_MULT_NEGATION_WORDS",
30
+ "_BUT_WORDS",
31
+ "_OPINION_MARKERS",
32
+ "_SOCIAL_MEDIA_SUPPLEMENT",
33
+ "__version__",
34
+ ]
File without changes
@@ -0,0 +1,866 @@
1
+ """
2
+ Core model classes for polVADER sentiment analysis.
3
+ """
4
+
5
+ import importlib.resources as data
6
+ import math
7
+ import pickle
8
+ import re
9
+ import string
10
+
11
+ import spacy
12
+
13
+ # ── VADER-style modifier constants ────────────────────────────────────────────
14
+ # [CHANGE 2] B_INCR/B_DECR tuned down from VADER's ±0.293.
15
+ # English VADER was calibrated on a ~7,500-word lexicon; the Polish lexicon is
16
+ # smaller, so each booster fires at higher relative frequency and the original
17
+ # constant over-shifts ~97 % of texts by a mean of 0.30 units. Halving to
18
+ # 0.15 reduces the mean shift while preserving the ordinal ranking of boosted
19
+ # vs. unboosted scores.
20
+ _B_INCR = 0.35 # amplifier boost (additive) — calibrated on PolEmo fit split; was 0.15
21
+ _B_DECR = -0.35 # diminisher boost — was -0.15
22
+ # Reverted from −1.15 to −0.74 (original VADER value). −1.15 amplifies negated
23
+ # words beyond their original magnitude, causing systematic positive-skew for
24
+ # texts with negated negative words ("nie zły" → strongly positive). −0.74
25
+ # dampens instead, keeping "not bad" < "good" in magnitude.
26
+ _N_SCALAR = -0.74 # negation scalar
27
+ _C_INCR = 0.733 # ALL-CAPS emphasis per word
28
+ _ALPHA = 20 # normalisation constant — calibrated; was 15
29
+
30
+ # [CHANGE 3] Balance detector constants.
31
+ # When a text has both positive and negative sentiment signals in similar
32
+ # proportions the document-level sentiment is ambiguous even if individual
33
+ # word scores are strong. We dampen the compound toward zero proportionally
34
+ # to how balanced the opposing signals are.
35
+ _BALANCE_THRESHOLD = 0.20 # min(pos,|neg|)/max(pos,|neg|) ratio to trigger
36
+ _BALANCE_DAMP = 0.60 # compound is multiplied by (1 − ratio×DAMP)
37
+
38
+ # [CHANGE 6] Subjectivity pre-filter constants.
39
+ # Texts that lack subjective/opinion markers and have a moderate compound
40
+ # are likely factual rather than opinionated, so their compound is dampened.
41
+ _SUBJ_THRESHOLD = 0.75 # only fires when |compound| < this value
42
+ _SUBJ_DAMP = 0.65 # multiplier when no opinion markers are present
43
+
44
+ # Polish opinion markers: first-person pronouns, opinion verbs, evaluative
45
+ # stance words, and common review feedback words (all lemma forms).
46
+ _OPINION_MARKERS: frozenset[str] = frozenset({
47
+ # first-person pronouns
48
+ "ja", "mi", "mnie", "mój", "moja", "moje", "moim", "moją",
49
+ "my", "nam", "nas", "nasz", "nasza", "nasze",
50
+ # opinion / evaluative verbs
51
+ "uważać", "myśleć", "sądzić", "wydawać", "czuć", "wierzyć",
52
+ "polecać", "odradzać", "radzić", "lubić", "kochać", "nienawidzić",
53
+ "preferować", "zachwycać", "rozczarować",
54
+ # evaluative stance markers
55
+ "niestety", "żal", "szkoda",
56
+ # common direct-feedback words in reviews
57
+ "polecam", "odradzam", "wracam", "wrócimy", "wróciłem", "wróciłam",
58
+ "żałować", "polecić",
59
+ })
60
+
61
+ # Polish amplifiers and diminishers — additive boost applied to a neighbouring
62
+ # sentiment word. Sign-aware application means amplifiers always increase
63
+ # magnitude and diminishers always reduce it, regardless of direction.
64
+ _BOOSTER_DICT: dict[str, float] = {
65
+ # ── Standard intensifiers ────────────────────────────────────────────────
66
+ "bardzo": _B_INCR, # very
67
+ "niezmiernie": _B_INCR, # immensely
68
+ "niezwykle": _B_INCR, # remarkably
69
+ "wyjątkowo": _B_INCR, # exceptionally
70
+ "szczególnie": _B_INCR, # particularly
71
+ "ekstremalnie": _B_INCR, # extremely
72
+ "absolutnie": _B_INCR, # absolutely
73
+ "całkowicie": _B_INCR, # completely
74
+ "zupełnie": _B_INCR, # totally
75
+ "kompletnie": _B_INCR, # completely (colloquial)
76
+ "ogromnie": _B_INCR, # enormously
77
+ "mocno": _B_INCR, # strongly
78
+ "niesamowicie": _B_INCR, # incredibly
79
+ "fenomenalnie": _B_INCR, # phenomenally
80
+ "zdecydowanie": _B_INCR, # decidedly
81
+ "wyraźnie": _B_INCR, # clearly
82
+ "naprawdę": _B_INCR, # truly / really
83
+ "totalnie": _B_INCR, # totally (colloquial)
84
+ "nadzwyczajnie": _B_INCR, # extraordinarily
85
+ "niewiarygodnie": _B_INCR, # unbelievably
86
+ "głęboko": _B_INCR, # deeply
87
+ "niesłychanie": _B_INCR, # incredibly
88
+ "niebywale": _B_INCR, # extraordinarily
89
+ "wręcz": _B_INCR, # outright
90
+ "doprawdy": _B_INCR, # indeed / truly
91
+ "wielce": _B_INCR, # greatly (formal)
92
+ "istotnie": _B_INCR, # indeed / significantly
93
+ "niewyobrażalnie": _B_INCR, # unimaginably
94
+ "nieprawdopodobnie": _B_INCR, # improbably (as intensifier)
95
+ "zadziwiająco": _B_INCR, # surprisingly
96
+ "niezaprzeczalnie": _B_INCR, # undeniably
97
+ "bezsprzecznie": _B_INCR, # indisputably
98
+ "ewidentnie": _B_INCR, # evidently
99
+ "definitywnie": _B_INCR, # definitively
100
+ "jednoznacznie": _B_INCR, # unequivocally
101
+ # ── Colloquial / social-media intensifiers ───────────────────────────────
102
+ "mega": _B_INCR, # mega (online)
103
+ "super": _B_INCR, # super (very common Polish intensifier)
104
+ "hiper": _B_INCR, # hyper-
105
+ "ultra": _B_INCR, # ultra-
106
+ "ekstra": _B_INCR, # extra / great (colloquial)
107
+ "strasznie": _B_INCR, # terribly (as intensifier)
108
+ "cholernie": _B_INCR, # damn / bloody (intensifier)
109
+ "diabelnie": _B_INCR, # devilishly (intensifier)
110
+ "szalenie": _B_INCR, # madly (intensifier)
111
+ "potwornie": _B_INCR, # monstrously (intensifier)
112
+ "okrutnie": _B_INCR, # cruelly (colloquial intensifier)
113
+ "kurewsko": _B_INCR, # intensifier (vulgar, very common on Twitter)
114
+ "zajebicie": _B_INCR, # intensifier (vulgar, common on Twitter)
115
+ "aż": _B_INCR, # "so/as" emphasiser ("aż tak dobry" = "that good")
116
+ "tak": _B_INCR, # "so" emphasiser before adjective ("tak piękny")
117
+ # ── Formal / news / political amplifiers ─────────────────────────────────
118
+ "rażąco": _B_INCR, # glaringly
119
+ "surowo": _B_INCR, # severely
120
+ "drastycznie": _B_INCR, # drastically
121
+ "dramatycznie": _B_INCR, # dramatically
122
+ "gwałtownie": _B_INCR, # sharply
123
+ "dotkliwie": _B_INCR, # painfully
124
+ "poważnie": _B_INCR, # seriously
125
+ "znacznie": _B_INCR, # considerably
126
+ "znacząco": _B_INCR, # significantly
127
+ "ostro": _B_INCR, # harshly
128
+ "bezwzględnie": _B_INCR, # ruthlessly
129
+ "przerażająco": _B_INCR, # frighteningly
130
+ "zatrważająco": _B_INCR, # alarmingly
131
+ "imponująco": _B_INCR, # impressively
132
+ "skandalicznie": _B_INCR, # scandalously
133
+ "katastrofalnie": _B_INCR, # catastrophically
134
+ "tragicznie": _B_INCR, # tragically
135
+ "fatalnie": _B_INCR, # terribly
136
+ "okropnie": _B_INCR, # horribly
137
+ # ── Warm / positive formal amplifiers ────────────────────────────────────
138
+ "serdecznie": _B_INCR, # warmly
139
+ "gorąco": _B_INCR, # fervently
140
+ "szczerze": _B_INCR, # sincerely
141
+ "cudownie": _B_INCR, # wonderfully
142
+ "genialnie": _B_INCR, # brilliantly
143
+ "wybitnie": _B_INCR, # outstandingly
144
+ "rewelacyjnie": _B_INCR, # awesomely
145
+ "doskonale": _B_INCR, # excellently
146
+ "świetnie": _B_INCR, # great
147
+ # ── Diminishers ──────────────────────────────────────────────────────────
148
+ "trochę": _B_DECR, # a bit
149
+ "nieco": _B_DECR, # somewhat
150
+ "odrobinę": _B_DECR, # a little
151
+ "troszkę": _B_DECR, # a tiny bit
152
+ "lekko": _B_DECR, # slightly
153
+ "delikatnie": _B_DECR, # gently
154
+ "prawie": _B_DECR, # almost
155
+ "ledwo": _B_DECR, # barely
156
+ "ledwie": _B_DECR, # barely
157
+ "zaledwie": _B_DECR, # merely
158
+ "raczej": _B_DECR, # rather (hedging)
159
+ "dość": _B_DECR, # fairly
160
+ "dosyć": _B_DECR, # fairly
161
+ "mniej": _B_DECR, # less
162
+ "mało": _B_DECR, # little
163
+ "niezbyt": _B_DECR, # not very
164
+ "niejako": _B_DECR, # somewhat
165
+ "poniekąd": _B_DECR, # in a way
166
+ "częściowo": _B_DECR, # partially
167
+ "umiarkowanie": _B_DECR, # moderately
168
+ "względnie": _B_DECR, # relatively
169
+ "jakby": _B_DECR, # kind of
170
+ "niby": _B_DECR, # supposedly
171
+ "stosunkowo": _B_DECR, # relatively
172
+ "marginalnie": _B_DECR, # marginally
173
+ "minimalnie": _B_DECR, # minimally
174
+ "nieznacznie": _B_DECR, # slightly
175
+ "powierzchownie": _B_DECR, # superficially
176
+ "pobieżnie": _B_DECR, # cursorily
177
+ "przeciętnie": _B_DECR, # averagely
178
+ }
179
+
180
+ # Fixed negators: apply N_SCALAR regardless of their own lexical value.
181
+ _NEGATION_WORDS: frozenset[str] = frozenset({"nie", "bez", "ani", "brak"})
182
+
183
+ # Multiplicative negators: scale the target word's valence by their own
184
+ # lexicon value (falls back to N_SCALAR when OOV).
185
+ _MULT_NEGATION_WORDS: frozenset[str] = frozenset({
186
+ "nigdy", "nigdzie", "nikt", "nic", "wcale", "bynajmniej",
187
+ "żaden", "żadna", "żadne", "żadnego", "żadnej", "żadnemu",
188
+ "żadnym", "żadnych", "żadnymi",
189
+ })
190
+
191
+ # Contrastive conjunctions: words after these carry more weight.
192
+ _BUT_WORDS: frozenset[str] = frozenset({
193
+ "ale", "jednak", "natomiast", "lecz", "jednakże",
194
+ "tymczasem", "niemniej", "pomimo", "chociaż", "mimo",
195
+ })
196
+
197
+ # [CHANGE 5] Informal / social-media vocabulary supplement.
198
+ # These words are common on Twitter and informal Polish but under-represented
199
+ # in the formal sentiment lexicon. Added via setdefault so they don't
200
+ # overwrite existing entries.
201
+ _SOCIAL_MEDIA_SUPPLEMENT: dict[str, float] = {
202
+ # informal positives
203
+ "spoko": 1.5, # cool / ok
204
+ "spoczko": 1.5, # cool (variant)
205
+ "git": 2.0, # great
206
+ "kapitalny": 2.2, # capital / great
207
+ "bomba": 2.2, # great (slang)
208
+ "odlotowy": 2.3, # awesome
209
+ "zajefajny": 2.5, # super cool (colloquial intensified)
210
+ "zarąbisty": 2.5, # awesome (colloquial)
211
+ "elegancko": 1.5, # cool (slang)
212
+ "cudo": 2.2, # wonder / great thing
213
+ "petarda": 2.2, # firecracker / awesome
214
+ "miodzio": 2.0, # sweet / great (colloquial)
215
+ "odpad": 2.0, # great (ironic slang — context-dependent; lexicon wins)
216
+ # informal negatives
217
+ "słabo": -1.8, # weak / bad
218
+ "żenada": -2.5, # cringe / embarrassing
219
+ "żenujący": -2.2, # embarrassing
220
+ "masakra": -2.0, # disaster (slang for awful)
221
+ "kupa": -2.2, # crap (vulgar)
222
+ "beznadziejnie": -2.0, # hopelessly
223
+ "dno": -2.5, # rock-bottom / the worst
224
+ "szajs": -2.2, # junk / shit (Polonized)
225
+ "żałosny": -2.2, # pathetic
226
+ "obrzydliwy": -2.5, # disgusting
227
+ "kiepski": -1.5, # poor / bad
228
+ "paskudny": -2.0, # ugly / disgusting
229
+ "wstyd": -1.5, # shame
230
+ "odpad": -2.0, # drop-out / rubbish (overloaded; lexicon wins if present)
231
+ }
232
+
233
+ # ── Pre-compiled social-media preprocessing regexes ──────────────────────────
234
+ _MENTION_RE = re.compile(r"@\S+")
235
+ _URL_RE = re.compile(r"https?://\S+|www\.\S+")
236
+ _HASHTAG_RE = re.compile(r"#(\w+)")
237
+ _SPACE_RE = re.compile(r"\s+")
238
+
239
+
240
+ def preprocess_social(text: str) -> str:
241
+ """[CHANGE 4] Strip @mentions and URLs; convert #hashtag → hashtag."""
242
+ text = _MENTION_RE.sub(" ", text)
243
+ text = _URL_RE.sub(" ", text)
244
+ text = _HASHTAG_RE.sub(r"\1", text)
245
+ return _SPACE_RE.sub(" ", text).strip()
246
+
247
+
248
+ def string_preprocessing(piece):
249
+ return (
250
+ piece.translate(str.maketrans("", "", string.punctuation))
251
+ .strip()
252
+ .replace(" ", " ")
253
+ .lower()
254
+ .split(" ")
255
+ )
256
+
257
+
258
+ class Lexicon:
259
+ def _merge(self, dict1, dict2):
260
+ return {**dict1, **dict2}
261
+
262
+ def __init__(self, synonym_weight=0.75, derivative_weight=1,
263
+ valence_floor: float = 0.0,
264
+ valence_clamp: tuple[float, float] | None = None,
265
+ b_incr: float | None = None,
266
+ n_scalar: float | None = None,
267
+ alpha: float | None = None,
268
+ expanded: bool = True,
269
+ pos_scale: float = 1.0,
270
+ neg_scale: float = 1.0):
271
+ # [CHANGE 1] Enable sentencizer for sentence-aware scoring.
272
+ # The full parser is disabled to keep inference fast; the sentencizer
273
+ # provides sentence boundaries via punctuation heuristics.
274
+ self.nlp = spacy.load("pl_core_news_lg", disable=["parser", "ner"])
275
+ if "sentencizer" not in self.nlp.pipe_names:
276
+ self.nlp.add_pipe("sentencizer")
277
+
278
+ self._synonym_weight = synonym_weight
279
+ self._derivative_weight = derivative_weight
280
+
281
+ # Parameterised rule constants — default to module-level values.
282
+ # Overriding these allows calibration without re-running spaCy.
283
+ self._b_incr = b_incr if b_incr is not None else _B_INCR
284
+ self._n_scalar = n_scalar if n_scalar is not None else _N_SCALAR
285
+ self._alpha = alpha if alpha is not None else _ALPHA
286
+
287
+ # Scale booster dict proportionally when b_incr differs from default.
288
+ scale = self._b_incr / _B_INCR if _B_INCR != 0 else 1.0
289
+ self._booster_dict = (
290
+ _BOOSTER_DICT if scale == 1.0
291
+ else {k: v * scale for k, v in _BOOSTER_DICT.items()}
292
+ )
293
+
294
+ self._load_data(expanded=expanded)
295
+ self.lexicon = self._final_lex
296
+
297
+ # [OPT 1] Valence floor: drop low-signal lexicon entries.
298
+ # Neutral texts accumulate many weak-valence hits that partially cancel
299
+ # but leave a residual compound that pushes them out of the neutral band.
300
+ # Pruning entries below the floor removes noise hits without losing the
301
+ # decisive words that carry clear sentiment.
302
+ if valence_floor > 0.0:
303
+ self.lexicon = {
304
+ k: v for k, v in self.lexicon.items() if abs(v) >= valence_floor
305
+ }
306
+
307
+ # [OPT 2] Valence clamp: keep all entries but constrain magnitude to
308
+ # [clamp_min, clamp_max]. Unlike the floor (which prunes entries),
309
+ # clamping preserves lexicon coverage (OOV rate unchanged) while
310
+ # equalising weak signals up and/or strong outliers down.
311
+ if valence_clamp is not None:
312
+ clamp_lo, clamp_hi = valence_clamp
313
+ self.lexicon = {
314
+ k: math.copysign(min(max(abs(v), clamp_lo), clamp_hi), v)
315
+ for k, v in self.lexicon.items()
316
+ }
317
+
318
+ # Per-sign valence scaling: calibrate positive/negative signal strength
319
+ # independently, e.g. to correct corpus-level pos/neg asymmetry.
320
+ if pos_scale != 1.0 or neg_scale != 1.0:
321
+ self.lexicon = {
322
+ k: v * (pos_scale if v > 0 else neg_scale)
323
+ for k, v in self.lexicon.items()
324
+ }
325
+
326
+ # [CHANGE 5] Merge social-media supplement: setdefault preserves
327
+ # existing entries so the core lexicon is not overwritten.
328
+ for word, score in _SOCIAL_MEDIA_SUPPLEMENT.items():
329
+ apply = True
330
+ if valence_floor > 0.0 and abs(score) < valence_floor:
331
+ apply = False
332
+ if apply:
333
+ if valence_clamp is not None:
334
+ clamp_lo, clamp_hi = valence_clamp
335
+ score = math.copysign(min(max(abs(score), clamp_lo), clamp_hi), score)
336
+ if pos_scale != 1.0 or neg_scale != 1.0:
337
+ score = score * (pos_scale if score > 0 else neg_scale)
338
+ self.lexicon.setdefault(word, score)
339
+
340
+ def _lemmatize(self, piece):
341
+ return [self.nlp(word)[0].lemma_ for word in piece]
342
+
343
+ def _load_data(self, expanded: bool = False):
344
+ with open(
345
+ data.files("polvader.lexicons") / "synonyms_dobry_slownik.dat", "rb"
346
+ ) as f:
347
+ self._synonyms_dobry_slownik = pickle.load(f)
348
+
349
+ with open(
350
+ data.files("polvader.lexicons") / "synonyms_kazojc.dat", "rb"
351
+ ) as f:
352
+ self._synonyms_kazojc = pickle.load(f)
353
+
354
+ # expanded_lexicon.dat: coverage-expanded (PLLuM K-NN, stages/coverage.py)
355
+ # + Adam-tuned (stages/adam.py, trained on all 5 corpus sources) —
356
+ # this is the default and best-benchmarked lexicon (see
357
+ # annotation-data/benchmark_translate_vader_results.json). Pass
358
+ # expanded=False for the smaller, untuned hand-built base lexicon.
359
+ expanded_path = (
360
+ data.files("polvader.lexicons") / "expanded_lexicon.dat"
361
+ )
362
+ base_path = (
363
+ data.files("polvader.lexicons")
364
+ / "fem_big_imbir_and_nencki_lex_with_kazojc_adjectives.dat"
365
+ )
366
+ lex_path = expanded_path if (
367
+ expanded and expanded_path.is_file()
368
+ ) else base_path
369
+ with open(lex_path, "rb") as f:
370
+ self._final_lex = pickle.load(f)
371
+
372
+ def _modify_synonym_weights(self, new_weight):
373
+ self._weighted_synonyms = {
374
+ k: v * new_weight for k, v in self.synonyms.items()
375
+ }
376
+ self.lexicon = self._merge(self.lexicon, self._weighted_synonyms)
377
+ self.lexicon = {k: round(v, 2) for k, v in self.lexicon.items()}
378
+
379
+ def _integrate_derivative_forms(self, weight):
380
+ self._weighted_derivatives = {
381
+ k: v * weight for k, v in self.derivatives.items()
382
+ }
383
+ self.lexicon = self._merge(self.lexicon, self._weighted_derivatives)
384
+ self.lexicon = {k: round(v, 2) for k, v in self.lexicon.items()}
385
+
386
+ def _round_neutrals(self):
387
+ new_lex = {}
388
+ for k, v in self.lexicon.items():
389
+ if 1 > v >= 0.5:
390
+ new_lex[k] = 1
391
+ elif -0.5 >= v > -1:
392
+ new_lex[k] = -1
393
+ else:
394
+ new_lex[k] = v
395
+ self.lexicon = new_lex
396
+
397
+ def score(self, text, preprocess=True):
398
+ """Simple lexicon lookup (no modifiers). Returns per-word scores."""
399
+ words = string_preprocessing(text) if preprocess else text
400
+ inner = [self.lexicon.get(w, False) for w in words]
401
+ n = len(words)
402
+ return inner, {
403
+ "neg": sum(1 for x in inner if x and x < 0) / n,
404
+ "unk": sum(1 for x in inner if x is False) / n,
405
+ "pos": sum(1 for x in inner if x and x > 0) / n,
406
+ }
407
+
408
+ # ── Internal per-token scoring ────────────────────────────────────────────
409
+
410
+ def _score_token_lists(
411
+ self,
412
+ surfaces: list[str],
413
+ lemmas: list[str],
414
+ is_word: list[bool],
415
+ ep_count: int = 0,
416
+ qm_count: int = 0,
417
+ return_sentiments: bool = False,
418
+ *,
419
+ use_negation: bool = True,
420
+ use_boosters: bool = True,
421
+ use_caps: bool = True,
422
+ use_balance: bool = True,
423
+ use_subjectivity: bool = True,
424
+ use_nie_prefix: bool = True,
425
+ use_punct: bool = True,
426
+ use_contrastive: bool = True,
427
+ ) -> dict:
428
+ """
429
+ Core VADER-style scoring on pre-tokenised token lists.
430
+
431
+ Modifier rules (in order):
432
+ 1. ALL-CAPS emphasis (±C_INCR per capped word in mixed-case text).
433
+ 2. Boosters / diminishers in the 3-word window before each sentiment
434
+ word (distance damping: ×0.95 at −2, ×0.90 at −3).
435
+ 3. Negation in the same window (N_SCALAR flip or multiplicative scale).
436
+ 4. nie-prefix productive splitting ("niedobry" → −lex["dobry"]).
437
+ 5. Balance detector: dampen compound when pos ≈ neg signals.
438
+ 6. Contrastive conjunction (ale/jednak/…): last occurrence wins;
439
+ words before get ×0.5, words after get ×1.5.
440
+ 7. Punctuation amplification (! and ?).
441
+ 8. Subjectivity pre-filter: moderate compounds without opinion
442
+ markers are dampened toward neutral.
443
+
444
+ Ablation flags (keyword-only):
445
+ use_negation — fixed "nie/bez/ani/brak" and mult negation
446
+ use_boosters — intensifier/diminisher window
447
+ use_caps — ALL-CAPS emphasis
448
+ use_balance — balance detector
449
+ use_subjectivity — subjectivity pre-filter
450
+ use_nie_prefix — fused nie-prefix forms ("niedobry")
451
+ use_punct — !/? punctuation amplification
452
+ use_contrastive — ale/jednak contrastive conjunction
453
+ """
454
+ word_surfaces = [s for s, w in zip(surfaces, is_word) if w]
455
+ has_mixed_case = bool(word_surfaces) and (
456
+ any(s.isupper() for s in word_surfaces)
457
+ and not all(s.isupper() for s in word_surfaces)
458
+ )
459
+
460
+ sentiments: list[float] = []
461
+ sentiment_positions: list[int] = []
462
+
463
+ for i, lemma in enumerate(lemmas):
464
+ if not is_word[i]:
465
+ continue
466
+
467
+ if lemma in _NEGATION_WORDS or lemma in _MULT_NEGATION_WORDS or lemma in _BOOSTER_DICT:
468
+ continue
469
+
470
+ # Find the nearest preceding word token (may be a modifier)
471
+ prev_lemma = None
472
+ for j in range(i - 1, -1, -1):
473
+ if is_word[j]:
474
+ prev_lemma = lemmas[j]
475
+ break
476
+
477
+ # Bigram lookup: "nie_dobry", "bardzo_dobry" etc.
478
+ # If a bigram entry exists it overrides the unigram + context path.
479
+ bigram_key = f"{prev_lemma}_{lemma}" if prev_lemma else None
480
+ if bigram_key and bigram_key in self.lexicon:
481
+ valence = self.lexicon[bigram_key]
482
+ if use_caps and surfaces[i].isupper() and has_mixed_case:
483
+ valence += _C_INCR if valence > 0 else -_C_INCR
484
+ sentiments.append(valence)
485
+ sentiment_positions.append(i)
486
+ continue
487
+
488
+ # Determine valence: plain lexicon or nie-prefix fused form
489
+ is_nie_prefix = False
490
+ if lemma in self.lexicon:
491
+ valence: float = self.lexicon[lemma]
492
+ elif use_nie_prefix and lemma.startswith("nie") and len(lemma) > 5:
493
+ base = lemma[3:]
494
+ if base not in self.lexicon:
495
+ continue
496
+ valence = self.lexicon[base] * self._n_scalar
497
+ is_nie_prefix = True
498
+ else:
499
+ continue
500
+
501
+ if use_caps and surfaces[i].isupper() and has_mixed_case:
502
+ valence += _C_INCR if valence > 0 else -_C_INCR
503
+
504
+ n_fixed_neg = 0
505
+ had_mult_neg = False
506
+ words_seen = 0
507
+ for j in range(i - 1, -1, -1):
508
+ if not is_word[j]:
509
+ continue
510
+ words_seen += 1
511
+ if words_seen > 3:
512
+ break
513
+ ctx = lemmas[j]
514
+
515
+ if use_negation and ctx in _NEGATION_WORDS:
516
+ n_fixed_neg += 1
517
+ continue
518
+
519
+ if use_negation and ctx in _MULT_NEGATION_WORDS:
520
+ valence *= self.lexicon.get(ctx, self._n_scalar)
521
+ had_mult_neg = True
522
+ continue
523
+
524
+ if use_boosters:
525
+ b = self._booster_dict.get(ctx, 0.0)
526
+ if b:
527
+ if words_seen == 2:
528
+ b *= 0.95
529
+ elif words_seen == 3:
530
+ b *= 0.90
531
+ valence += b if valence >= 0 else -b
532
+ continue
533
+
534
+ if ctx in self.lexicon or (
535
+ ctx.startswith("nie") and len(ctx) > 5 and ctx[3:] in self.lexicon
536
+ ):
537
+ break
538
+
539
+ if is_nie_prefix:
540
+ # Word already carries its own negation via the nie- prefix.
541
+ # A context "nie" produces a weak double-negative (mildly
542
+ # positive) rather than a full re-flip with amplification.
543
+ # "nie niedobry" ≈ "not un-good" → weakly positive, dampened.
544
+ if use_negation and n_fixed_neg > 0:
545
+ valence = abs(valence) * 0.5
546
+ if use_negation and had_mult_neg:
547
+ valence = -abs(valence)
548
+ else:
549
+ if n_fixed_neg > 0:
550
+ valence *= self._n_scalar
551
+ if n_fixed_neg > 1:
552
+ # Polish grammatical double-negation stays negative.
553
+ valence = -abs(valence)
554
+ if had_mult_neg and n_fixed_neg > 0:
555
+ valence = -abs(valence)
556
+
557
+ sentiments.append(valence)
558
+ sentiment_positions.append(i)
559
+
560
+ # Balance detector ────────────────────────────────────────────────────
561
+ if use_balance and sentiments:
562
+ pos_raw = sum(s for s in sentiments if s > 0)
563
+ neg_raw = abs(sum(s for s in sentiments if s < 0))
564
+ if pos_raw > 0 and neg_raw > 0:
565
+ balance = min(pos_raw, neg_raw) / max(pos_raw, neg_raw)
566
+ if balance > _BALANCE_THRESHOLD:
567
+ damp = 1.0 - balance * _BALANCE_DAMP
568
+ sentiments = [s * damp for s in sentiments]
569
+
570
+ # Contrastive conjunction (last occurrence wins) ───────────────────────
571
+ if use_contrastive:
572
+ but_idx = None
573
+ for i, w in enumerate(lemmas):
574
+ if w in _BUT_WORDS:
575
+ but_idx = i
576
+ if but_idx is not None and sentiments:
577
+ sentiments = [
578
+ s * 0.5 if pos < but_idx else s * 1.5
579
+ for s, pos in zip(sentiments, sentiment_positions)
580
+ ]
581
+
582
+ # Aggregate with punctuation amplifier ────────────────────────────────
583
+ sum_s = sum(sentiments) if sentiments else 0.0
584
+
585
+ if use_punct:
586
+ punct_amp = ep_count * 0.292
587
+ if qm_count > 1:
588
+ punct_amp += (min(qm_count, 3) * 0.18) if qm_count <= 3 else 0.96
589
+ if sum_s > 0:
590
+ sum_s += punct_amp
591
+ elif sum_s < 0:
592
+ sum_s -= punct_amp
593
+ else:
594
+ punct_amp = 0.0
595
+
596
+ compound = round(
597
+ max(-1.0, min(1.0, sum_s / math.sqrt(sum_s ** 2 + self._alpha))), 4
598
+ )
599
+
600
+ # Subjectivity pre-filter ─────────────────────────────────────────────
601
+ if use_subjectivity and 0 < abs(compound) < _SUBJ_THRESHOLD:
602
+ has_opinion = any(
603
+ lemmas[i] in _OPINION_MARKERS for i in range(len(lemmas)) if is_word[i]
604
+ )
605
+ if not has_opinion:
606
+ compound = round(compound * _SUBJ_DAMP, 4)
607
+
608
+ # Pos / neg proportions ───────────────────────────────────────────────
609
+ pos_sum = sum(s for s in sentiments if s > 0)
610
+ neg_sum = sum(s for s in sentiments if s < 0)
611
+
612
+ if use_punct:
613
+ if pos_sum > abs(neg_sum):
614
+ pos_sum += punct_amp
615
+ elif pos_sum < abs(neg_sum):
616
+ neg_sum -= punct_amp
617
+
618
+ sent_total = pos_sum + abs(neg_sum)
619
+
620
+ # [OPT 3] neu = OOV rate: fraction of alpha words NOT in the lexicon.
621
+ # Previously neu counted zero-valence lexicon hits (~0.01 universally).
622
+ # OOV rate is informative: neutral texts contain many non-lexicon words
623
+ # (factual content, proper nouns, domain terms) while opinionated texts
624
+ # hit the lexicon more selectively. This makes neu a real signal.
625
+ n_words = sum(is_word)
626
+ n_oov = sum(
627
+ 1 for i in range(len(lemmas))
628
+ if is_word[i] and lemmas[i] not in self.lexicon
629
+ )
630
+ neu_oov = round(n_oov / n_words, 3) if n_words > 0 else 0.0
631
+
632
+ if sent_total == 0:
633
+ return {"neg": 0.0, "neu": neu_oov, "pos": 0.0, "compound": compound}
634
+
635
+ out = {
636
+ "neg": round(abs(neg_sum) / sent_total, 3),
637
+ "neu": neu_oov,
638
+ "pos": round(pos_sum / sent_total, 3),
639
+ "compound": compound,
640
+ }
641
+ if return_sentiments:
642
+ out["_sentiments"] = sentiments
643
+ return out
644
+
645
+ # ── Public scoring API ────────────────────────────────────────────────────
646
+
647
+ def polarity_scores(
648
+ self,
649
+ text: str,
650
+ sentence_aware: bool = True,
651
+ *,
652
+ use_negation: bool = True,
653
+ use_boosters: bool = True,
654
+ use_caps: bool = True,
655
+ use_balance: bool = True,
656
+ use_subjectivity: bool = True,
657
+ use_nie_prefix: bool = True,
658
+ use_punct: bool = True,
659
+ use_contrastive: bool = True,
660
+ ) -> dict:
661
+ """
662
+ VADER-style scoring with all Polish modifier improvements.
663
+
664
+ Parameters
665
+ ----------
666
+ text : str
667
+ Raw Polish text. Use preprocess_social() first for tweets.
668
+ sentence_aware : bool
669
+ When True (default), texts with multiple sentences are scored
670
+ sentence-by-sentence and the sentence compounds are averaged.
671
+ This prevents long mixed-sentiment texts from accumulating a
672
+ spuriously strong compound. Single-sentence texts are unaffected.
673
+ use_negation, use_boosters, use_caps, use_balance, use_subjectivity,
674
+ use_nie_prefix, use_punct, use_contrastive : bool
675
+ Ablation flags — disable individual modifier categories for
676
+ diagnostic purposes. All True by default (full modifier system).
677
+
678
+ Returns
679
+ -------
680
+ dict with keys 'neg', 'neu', 'pos', 'compound' (VADER contract).
681
+ """
682
+ flags = dict(
683
+ use_negation=use_negation, use_boosters=use_boosters,
684
+ use_caps=use_caps, use_balance=use_balance,
685
+ use_subjectivity=use_subjectivity, use_nie_prefix=use_nie_prefix,
686
+ use_punct=use_punct, use_contrastive=use_contrastive,
687
+ )
688
+
689
+ ep_count = min(text.count("!"), 3)
690
+ qm_count = text.count("?")
691
+
692
+ doc = self.nlp(text)
693
+ sents = list(doc.sents)
694
+
695
+ if sentence_aware and len(sents) > 1:
696
+ # Score each sentence independently, then aggregate.
697
+ compounds, neg_scores, pos_scores, neu_scores = [], [], [], []
698
+ for sent in sents:
699
+ s_surf = [t.text for t in sent]
700
+ s_lemmas = [t.lemma_.lower() for t in sent]
701
+ s_iw = [t.is_alpha for t in sent]
702
+ sd = self._score_token_lists(s_surf, s_lemmas, s_iw, **flags)
703
+ if sd["compound"] != 0.0:
704
+ compounds.append(sd["compound"])
705
+ neg_scores.append(sd["neg"])
706
+ pos_scores.append(sd["pos"])
707
+ neu_scores.append(sd["neu"]) # OOV rate per sentence
708
+
709
+ if not compounds:
710
+ neu_agg = round(float(sum(neu_scores) / len(neu_scores)), 3) if neu_scores else 0.0
711
+ return {"neg": 0.0, "neu": neu_agg, "pos": 0.0, "compound": 0.0}
712
+
713
+ avg = sum(compounds) / len(compounds)
714
+ if use_punct:
715
+ if avg > 0:
716
+ avg += ep_count * 0.292
717
+ elif avg < 0:
718
+ avg -= ep_count * 0.292
719
+ compound = round(max(-1.0, min(1.0, avg)), 4)
720
+
721
+ # Aggregate neg/pos as mean of per-sentence proportions;
722
+ # neu is mean OOV rate across all sentences (not derived from compound).
723
+ neg_ = round(float(sum(neg_scores) / len(neg_scores)), 3)
724
+ pos_ = round(float(sum(pos_scores) / len(pos_scores)), 3)
725
+ neu_ = round(float(sum(neu_scores) / len(neu_scores)), 3)
726
+ return {"neg": neg_, "neu": neu_, "pos": pos_, "compound": compound}
727
+
728
+ # Single-sequence scoring (or sentence_aware=False)
729
+ surfaces = [t.text for t in doc]
730
+ lemmas = [t.lemma_.lower() for t in doc]
731
+ is_word = [t.is_alpha for t in doc]
732
+ return self._score_token_lists(surfaces, lemmas, is_word, ep_count, qm_count, **flags)
733
+
734
+ def score_batch(
735
+ self,
736
+ texts: list[str],
737
+ sentence_aware: bool = True,
738
+ batch_size: int = 256,
739
+ n_process: int = 1,
740
+ desc: str | None = None,
741
+ *,
742
+ use_negation: bool = True,
743
+ use_boosters: bool = True,
744
+ use_caps: bool = True,
745
+ use_balance: bool = True,
746
+ use_subjectivity: bool = True,
747
+ use_nie_prefix: bool = True,
748
+ use_punct: bool = True,
749
+ use_contrastive: bool = True,
750
+ ) -> list[dict]:
751
+ """
752
+ Score a list of texts using nlp.pipe() for batched spaCy inference.
753
+ Produces identical output to calling polarity_scores() per text but
754
+ is 3–5× faster for large corpora.
755
+
756
+ Parameters
757
+ ----------
758
+ texts : list[str]
759
+ Pre-processed texts (apply preprocess_social() before passing).
760
+ batch_size : int
761
+ spaCy pipe batch size (default 256).
762
+ n_process : int
763
+ Number of worker processes for spaCy (default 1; set -1 for all CPUs,
764
+ but fork-safety varies by platform).
765
+ desc : str | None
766
+ If given, show a tqdm progress bar with this label.
767
+ """
768
+ try:
769
+ from tqdm import tqdm as _tqdm
770
+ _have_tqdm = True
771
+ except ImportError:
772
+ _have_tqdm = False
773
+
774
+ flags = dict(
775
+ use_negation=use_negation, use_boosters=use_boosters,
776
+ use_caps=use_caps, use_balance=use_balance,
777
+ use_subjectivity=use_subjectivity, use_nie_prefix=use_nie_prefix,
778
+ use_punct=use_punct, use_contrastive=use_contrastive,
779
+ )
780
+
781
+ ep_counts = [min(t.count("!"), 3) for t in texts]
782
+ qm_counts = [t.count("?") for t in texts]
783
+
784
+ results = []
785
+ docs = self.nlp.pipe(texts, batch_size=batch_size, n_process=n_process)
786
+ it = zip(docs, ep_counts, qm_counts)
787
+ if desc is not None and _have_tqdm:
788
+ it = _tqdm(it, total=len(texts), desc=desc, unit="text")
789
+ for doc, ep_count, qm_count in it:
790
+ sents = list(doc.sents)
791
+ if sentence_aware and len(sents) > 1:
792
+ compounds, neg_scores, pos_scores, neu_scores = [], [], [], []
793
+ for sent in sents:
794
+ s_surf = [t.text for t in sent]
795
+ s_lemmas = [t.lemma_.lower() for t in sent]
796
+ s_iw = [t.is_alpha for t in sent]
797
+ sd = self._score_token_lists(s_surf, s_lemmas, s_iw, **flags)
798
+ if sd["compound"] != 0.0:
799
+ compounds.append(sd["compound"])
800
+ neg_scores.append(sd["neg"])
801
+ pos_scores.append(sd["pos"])
802
+ neu_scores.append(sd["neu"])
803
+
804
+ if not compounds:
805
+ neu_agg = round(float(sum(neu_scores) / len(neu_scores)), 3) if neu_scores else 0.0
806
+ results.append({"neg": 0.0, "neu": neu_agg, "pos": 0.0, "compound": 0.0})
807
+ continue
808
+
809
+ avg = sum(compounds) / len(compounds)
810
+ if use_punct:
811
+ if avg > 0:
812
+ avg += ep_count * 0.292
813
+ elif avg < 0:
814
+ avg -= ep_count * 0.292
815
+ compound = round(max(-1.0, min(1.0, avg)), 4)
816
+ neg_ = round(float(sum(neg_scores) / len(neg_scores)), 3)
817
+ pos_ = round(float(sum(pos_scores) / len(pos_scores)), 3)
818
+ neu_ = round(float(sum(neu_scores) / len(neu_scores)), 3)
819
+ results.append({"neg": neg_, "neu": neu_, "pos": pos_, "compound": compound})
820
+ else:
821
+ surfaces = [t.text for t in doc]
822
+ lemmas = [t.lemma_.lower() for t in doc]
823
+ is_word = [t.is_alpha for t in doc]
824
+ results.append(self._score_token_lists(surfaces, lemmas, is_word, ep_count, qm_count, **flags))
825
+ return results
826
+
827
+ def score_token_cache(self, cache: list[dict]) -> list[dict]:
828
+ """
829
+ Score from a pre-built token cache (output of build_token_cache).
830
+ Avoids re-running spaCy — used by calibrate_constants.py to sweep
831
+ (b_incr, n_scalar, alpha) without paying the NLP cost each time.
832
+
833
+ Each cache entry is a dict with keys:
834
+ surfaces, lemmas, is_word, ep_count, qm_count,
835
+ sents: list of (surf_list, lemma_list, is_word_list)
836
+ """
837
+ results = []
838
+ for entry in cache:
839
+ sents = entry["sents"]
840
+ ep = entry["ep_count"]
841
+ qm = entry["qm_count"]
842
+ if len(sents) > 1:
843
+ compounds, neg_s, pos_s, neu_s = [], [], [], []
844
+ for s_surf, s_lem, s_iw in sents:
845
+ sd = self._score_token_lists(s_surf, s_lem, s_iw)
846
+ if sd["compound"] != 0.0:
847
+ compounds.append(sd["compound"])
848
+ neg_s.append(sd["neg"]); pos_s.append(sd["pos"]); neu_s.append(sd["neu"])
849
+ if not compounds:
850
+ neu_agg = round(float(sum(neu_s) / len(neu_s)), 3) if neu_s else 0.0
851
+ results.append({"neg": 0.0, "neu": neu_agg, "pos": 0.0, "compound": 0.0})
852
+ continue
853
+ avg = sum(compounds) / len(compounds)
854
+ if avg > 0: avg += ep * 0.292
855
+ elif avg < 0: avg -= ep * 0.292
856
+ compound = round(max(-1.0, min(1.0, avg)), 4)
857
+ results.append({
858
+ "neg": round(float(sum(neg_s)/len(neg_s)), 3),
859
+ "neu": round(float(sum(neu_s)/len(neu_s)), 3),
860
+ "pos": round(float(sum(pos_s)/len(pos_s)), 3),
861
+ "compound": compound,
862
+ })
863
+ else:
864
+ surf, lem, iw = entry["surfaces"], entry["lemmas"], entry["is_word"]
865
+ results.append(self._score_token_lists(surf, lem, iw, ep, qm))
866
+ return results
@@ -0,0 +1,15 @@
1
+ """
2
+ Utility functions for polVADER.
3
+ """
4
+
5
+ def make_lex_dict(lexicon_full_filepath):
6
+ """
7
+ Convert lexicon file to a dictionary.
8
+
9
+ Args:
10
+ lexicon_full_filepath: Path to the lexicon file
11
+
12
+ Returns:
13
+ Dictionary with lexicon data
14
+ """
15
+ pass