apunim 1.0.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.
- apunim/__init__.py +734 -0
- apunim/_list_dict.py +83 -0
- apunim-1.0.0.dist-info/METADATA +56 -0
- apunim-1.0.0.dist-info/RECORD +6 -0
- apunim-1.0.0.dist-info/WHEEL +4 -0
- apunim-1.0.0.dist-info/licenses/LICENSE +619 -0
apunim/__init__.py
ADDED
|
@@ -0,0 +1,734 @@
|
|
|
1
|
+
import warnings
|
|
2
|
+
import math
|
|
3
|
+
from collections import namedtuple
|
|
4
|
+
from collections.abc import Collection
|
|
5
|
+
from typing import Any, TypeVar
|
|
6
|
+
|
|
7
|
+
import statsmodels.stats.multitest
|
|
8
|
+
import scipy.stats
|
|
9
|
+
import numpy as np
|
|
10
|
+
from numpy.typing import NDArray
|
|
11
|
+
|
|
12
|
+
from . import _list_dict
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
FactorType = TypeVar("FactorType")
|
|
16
|
+
ApunimResult = namedtuple("ApunimResult", ["apunim", "pvalue"])
|
|
17
|
+
"""
|
|
18
|
+
Container for the result of the Aposteriori Unimodality (apunim) test
|
|
19
|
+
for a single factor level.
|
|
20
|
+
|
|
21
|
+
Attributes:
|
|
22
|
+
apunim (float): The apunim statistic for the factor.
|
|
23
|
+
- apunim > 0: Increased polarization due to group differences.
|
|
24
|
+
- apunim < 0: Decreased polarization due to group differences.
|
|
25
|
+
- apunim ≈ 0: Polarization explained by chance.
|
|
26
|
+
- NaN indicates that the statistic could not be computed.
|
|
27
|
+
|
|
28
|
+
pvalue (float): The p-value associated with the AP-unimodality statistic.
|
|
29
|
+
Reflects the statistical significance of the observed polarization
|
|
30
|
+
relative to randomized partitions. NaN indicates p-value could not
|
|
31
|
+
be computed.
|
|
32
|
+
.. seealso::
|
|
33
|
+
- :func:`aposteriori_unimodality` for testing group-level polarization using DFU/nDFU.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# code adapted from John Pavlopoulos
|
|
38
|
+
# https://github.com/ipavlopoulos/ndfu/blob/main/src/__init__.py
|
|
39
|
+
def dfu(x: Collection[float], bins: int, normalized: bool = True) -> float:
|
|
40
|
+
"""
|
|
41
|
+
Compute the Distance From Unimodality (DFU) for a sequence of annotations.
|
|
42
|
+
|
|
43
|
+
DFU measures how much a distribution deviates from being unimodal. The
|
|
44
|
+
normalized DFU (nDFU) rescales the value to the range [0, 1].
|
|
45
|
+
|
|
46
|
+
- DFU/nDFU = 0 indicates a unimodal or flat distribution.
|
|
47
|
+
- Higher DFU/nDFU values indicate stronger multimodality or polarization.
|
|
48
|
+
- nDFU = 1 indicates the maximum possible polarization.
|
|
49
|
+
|
|
50
|
+
:param x: Sequence of annotation values (e.g., ratings, scores). Values
|
|
51
|
+
need not be discrete, but discrete annotations should use a number
|
|
52
|
+
of bins equal to the number of distinct values.
|
|
53
|
+
:type x: Collection[float]
|
|
54
|
+
:param bins: Number of bins to use for histogramming. For discrete data,
|
|
55
|
+
it is recommended to use the number of distinct annotation levels.
|
|
56
|
+
:type bins: int
|
|
57
|
+
:param normalized: If True, returns the normalized DFU (nDFU). If False,
|
|
58
|
+
returns the raw DFU.
|
|
59
|
+
:type normalized: bool
|
|
60
|
+
:raises ValueError: If `x` is empty or`bins` < 2.
|
|
61
|
+
:return: DFU or normalized DFU (nDFU) statistic for the sequence.
|
|
62
|
+
:rtype: float
|
|
63
|
+
|
|
64
|
+
.. note::
|
|
65
|
+
DFU is computed based on the maximum difference between the histogram
|
|
66
|
+
peak and its neighbors. For details on the methodology and usage, see
|
|
67
|
+
the original paper:
|
|
68
|
+
`Pavlopoulos and Likas 2024 <https://aclanthology.org/2024.eacl-long.117/>`_.
|
|
69
|
+
|
|
70
|
+
.. seealso::
|
|
71
|
+
- :func:`aposteriori_unimodality` for testing group-level polarization
|
|
72
|
+
using DFU/nDFU.
|
|
73
|
+
|
|
74
|
+
.. rubric:: Credits
|
|
75
|
+
Original code and concept adapted from John Pavlopoulos:
|
|
76
|
+
https://github.com/ipavlopoulos/ndfu
|
|
77
|
+
"""
|
|
78
|
+
if bins <= 1:
|
|
79
|
+
raise ValueError("Number of bins must be at least two.")
|
|
80
|
+
|
|
81
|
+
hist = _to_hist(x, bins=bins)
|
|
82
|
+
|
|
83
|
+
max_value = np.max(hist)
|
|
84
|
+
pos_max = np.argmax(hist)
|
|
85
|
+
|
|
86
|
+
# right search
|
|
87
|
+
right_diffs = hist[pos_max + 1:] - hist[pos_max:-1]
|
|
88
|
+
max_rdiff = right_diffs.max(initial=0)
|
|
89
|
+
|
|
90
|
+
# left search
|
|
91
|
+
if pos_max > 0:
|
|
92
|
+
left_diffs = hist[0:pos_max] - hist[1: pos_max + 1]
|
|
93
|
+
max_ldiff = left_diffs[left_diffs > 0].max(initial=0)
|
|
94
|
+
else:
|
|
95
|
+
max_ldiff = 0
|
|
96
|
+
|
|
97
|
+
max_diff = max(max_rdiff, max_ldiff)
|
|
98
|
+
dfu_stat = max_diff / max_value if normalized else max_diff
|
|
99
|
+
return float(dfu_stat)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def aposteriori_unimodality(
|
|
103
|
+
annotations: Collection[float],
|
|
104
|
+
factor_group: Collection[FactorType], # type: ignore
|
|
105
|
+
comment_group: Collection[FactorType], # type: ignore
|
|
106
|
+
num_bins: int | None = None,
|
|
107
|
+
iterations: int = 100,
|
|
108
|
+
alpha: float | None = 0.05,
|
|
109
|
+
two_sided: bool = True,
|
|
110
|
+
seed: int | None = None,
|
|
111
|
+
) -> dict[FactorType, ApunimResult]:
|
|
112
|
+
"""
|
|
113
|
+
Perform the Aposteriori Unimodality (apunim) test for group-wise
|
|
114
|
+
polarization.
|
|
115
|
+
|
|
116
|
+
This test evaluates whether differences between annotator groups
|
|
117
|
+
(e.g., gender, age) contribute significantly to the polarization observed
|
|
118
|
+
in a dataset, as measured by Distance From Unimodality (DFU).
|
|
119
|
+
|
|
120
|
+
The test compares the observed DFU of each factor level to the distribution
|
|
121
|
+
of DFU values obtained by randomly partitioning annotations according to
|
|
122
|
+
group sizes (apriori randomization). The apunim statistic
|
|
123
|
+
quantifies the relative increase or decrease in polarization attributable
|
|
124
|
+
to group differences.
|
|
125
|
+
|
|
126
|
+
Generally:
|
|
127
|
+
- apunim > 0: increased polarization due to group differences.
|
|
128
|
+
- apunim < 0: decreased polarization due to group differences.
|
|
129
|
+
- apunim ≈ 0: polarization explained by chance.
|
|
130
|
+
|
|
131
|
+
:param annotations:
|
|
132
|
+
A list of annotation scores, where each element corresponds to an
|
|
133
|
+
annotation (e.g., a toxicity score) made by an annotator.
|
|
134
|
+
Needs not be discrete.
|
|
135
|
+
:type annotations: Collection[float]
|
|
136
|
+
:param factor_group:
|
|
137
|
+
A list indicating the group assignment (e.g., 'male', 'female') of
|
|
138
|
+
the annotator who produced each annotation. For example, if two
|
|
139
|
+
annotations were made by a male and female annotator respectively,
|
|
140
|
+
the provided factor_group would be ["male", "female"].
|
|
141
|
+
:type factor_group: Collection[`FactorType`]
|
|
142
|
+
:param comment_group:
|
|
143
|
+
A list of comment identifiers, where each element associates an
|
|
144
|
+
annotation with a specific comment in the discussion.
|
|
145
|
+
:type comment_group: Collection[`FactorType`]
|
|
146
|
+
:param num_bins:
|
|
147
|
+
The number of bins to use when computing the DFU polarization metric.
|
|
148
|
+
If data is discrete, it is advisable to use the number of modes.
|
|
149
|
+
Example: An annotation task in the 1-5 LIKERT scale should use 5 bins.
|
|
150
|
+
None to create as many bins as the distinct values in the annotations.
|
|
151
|
+
WARNING: If set to None, check whether all possible values are
|
|
152
|
+
represented at least once in the provided annotation.
|
|
153
|
+
:type num_bins: int
|
|
154
|
+
:param iterations:
|
|
155
|
+
The number of randomized groups compared against the original groups.
|
|
156
|
+
A larger number makes the method more accurate,
|
|
157
|
+
but also more computationally expensive.
|
|
158
|
+
:type iterations: int
|
|
159
|
+
:param alpha:
|
|
160
|
+
The target statistical significance. Used to apply pvalue correction
|
|
161
|
+
for multiple comparisons. None to disable pvalue corrections.
|
|
162
|
+
:type alpha: float | None
|
|
163
|
+
:param two_sided:
|
|
164
|
+
Whether the statistical tests run for both less and
|
|
165
|
+
greater polarization, or just greater. Defaults to True.
|
|
166
|
+
:type two_sided: bool
|
|
167
|
+
:param seed: The random seed used, None for non-deterministic outputs.
|
|
168
|
+
:type seed: int | None
|
|
169
|
+
:return: Dictionary mapping factor levels to ApunimResult namedtuples
|
|
170
|
+
containing: the apunim value and its pvalue
|
|
171
|
+
:rtype: dict[FactorType, ApunimResult]
|
|
172
|
+
:raises ValueError:
|
|
173
|
+
- If input lists differ in length.
|
|
174
|
+
- If `annotations` is empty.
|
|
175
|
+
- If `factor_group` has fewer than 2 unique groups.
|
|
176
|
+
- If `comment_group` has fewer than 2 unique comments.
|
|
177
|
+
- If `iterations` < 1.
|
|
178
|
+
- If `num_bins` < 2.
|
|
179
|
+
- If `alpha` is not in the range [0,1].
|
|
180
|
+
- If no valid polarized comments are found
|
|
181
|
+
(all DFU ≤ 0.01 or fewer than 2 annotator groups per comment).
|
|
182
|
+
- If `_apriori_polarization_stat` finds inconsistent
|
|
183
|
+
group sizes vs. annotations.
|
|
184
|
+
|
|
185
|
+
.. seealso::
|
|
186
|
+
- :class:`ApunimResult` - Return type.
|
|
187
|
+
- :func:`dfu` - Computes the Distance from Unimodality.
|
|
188
|
+
|
|
189
|
+
.. note::
|
|
190
|
+
The test is relatively robust even with a small number of annotations
|
|
191
|
+
per comment. The pvalue estimation is parametric (Student-t test).
|
|
192
|
+
"""
|
|
193
|
+
rng = np.random.default_rng(seed=seed)
|
|
194
|
+
bins = num_bins if num_bins is not None else len(_unique(annotations))
|
|
195
|
+
|
|
196
|
+
_validate_input(
|
|
197
|
+
annotations, factor_group, comment_group, iterations, bins, alpha
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
annotations = np.array(annotations)
|
|
201
|
+
factor_group: NDArray[Any] = np.array(factor_group)
|
|
202
|
+
comment_group: NDArray[Any] = np.array(comment_group)
|
|
203
|
+
all_factors = _unique(factor_group)
|
|
204
|
+
|
|
205
|
+
# Remove NaN annotations and corresponding factor/comment entries
|
|
206
|
+
valid_mask = ~np.isnan(annotations)
|
|
207
|
+
annotations = annotations[valid_mask]
|
|
208
|
+
factor_group = factor_group[valid_mask]
|
|
209
|
+
comment_group = comment_group[valid_mask]
|
|
210
|
+
|
|
211
|
+
# Identify comments with actual polarization
|
|
212
|
+
valid_comments = _get_valid_comments(
|
|
213
|
+
annotations=annotations,
|
|
214
|
+
comment_group=comment_group,
|
|
215
|
+
factor_group=factor_group,
|
|
216
|
+
bins=bins,
|
|
217
|
+
)
|
|
218
|
+
if not valid_comments:
|
|
219
|
+
raise ValueError("No polarized comments found.")
|
|
220
|
+
|
|
221
|
+
(
|
|
222
|
+
annotations,
|
|
223
|
+
factor_group,
|
|
224
|
+
comment_group,
|
|
225
|
+
all_factors,
|
|
226
|
+
) = _filter_to_valid_comments(
|
|
227
|
+
annotations,
|
|
228
|
+
factor_group,
|
|
229
|
+
comment_group,
|
|
230
|
+
valid_comments,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
observed_dfu_dict, apriori_dfu_dict = _compute_dfu_distributions(
|
|
234
|
+
valid_comments,
|
|
235
|
+
annotations,
|
|
236
|
+
factor_group,
|
|
237
|
+
comment_group,
|
|
238
|
+
all_factors,
|
|
239
|
+
bins,
|
|
240
|
+
iterations,
|
|
241
|
+
rng,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
results = _compute_factor_results(
|
|
245
|
+
observed_dfu_dict,
|
|
246
|
+
apriori_dfu_dict,
|
|
247
|
+
all_factors,
|
|
248
|
+
two_sided,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
# Apply p-value correction if needed
|
|
252
|
+
if alpha is not None:
|
|
253
|
+
results = _correct_pvalues(results, alpha)
|
|
254
|
+
|
|
255
|
+
return results
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _filter_to_valid_comments(
|
|
259
|
+
annotations,
|
|
260
|
+
factor_group,
|
|
261
|
+
comment_group,
|
|
262
|
+
valid_comments,
|
|
263
|
+
):
|
|
264
|
+
valid_mask = np.isin(comment_group, valid_comments)
|
|
265
|
+
annotations = annotations[valid_mask]
|
|
266
|
+
factor_group = factor_group[valid_mask]
|
|
267
|
+
comment_group = comment_group[valid_mask]
|
|
268
|
+
|
|
269
|
+
# update factors after filtering
|
|
270
|
+
all_factors = _unique(factor_group)
|
|
271
|
+
|
|
272
|
+
return annotations, factor_group, comment_group, all_factors
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _compute_dfu_distributions(
|
|
276
|
+
valid_comments,
|
|
277
|
+
annotations,
|
|
278
|
+
factor_group,
|
|
279
|
+
comment_group,
|
|
280
|
+
all_factors,
|
|
281
|
+
bins,
|
|
282
|
+
iterations,
|
|
283
|
+
rng,
|
|
284
|
+
):
|
|
285
|
+
observed_dfu_dict = _list_dict._ListDict()
|
|
286
|
+
apriori_dfu_dict = _list_dict._ListDict()
|
|
287
|
+
|
|
288
|
+
for curr_comment in valid_comments:
|
|
289
|
+
mask = comment_group == curr_comment
|
|
290
|
+
comment_ann = annotations[mask]
|
|
291
|
+
comment_groups = factor_group[mask]
|
|
292
|
+
|
|
293
|
+
# counts per factor
|
|
294
|
+
lengths_by_factor = {
|
|
295
|
+
factor: int(np.count_nonzero(comment_groups == factor))
|
|
296
|
+
for factor in all_factors
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
# observed DFUs
|
|
300
|
+
observed_dfu_dict.add_dict(
|
|
301
|
+
_factor_dfu_stat(comment_ann, comment_groups, bins=bins)
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
# randomized apriori DFUs
|
|
305
|
+
apriori_dfu_dict.add_dict(
|
|
306
|
+
_apriori_polarization_stat(
|
|
307
|
+
annotations=comment_ann,
|
|
308
|
+
group_sizes=lengths_by_factor,
|
|
309
|
+
bins=bins,
|
|
310
|
+
iterations=iterations,
|
|
311
|
+
rng=rng,
|
|
312
|
+
)
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
return observed_dfu_dict, apriori_dfu_dict
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _compute_factor_results(
|
|
319
|
+
observed_dfu_dict,
|
|
320
|
+
apriori_dfu_dict,
|
|
321
|
+
all_factors,
|
|
322
|
+
two_sided,
|
|
323
|
+
):
|
|
324
|
+
results = {}
|
|
325
|
+
|
|
326
|
+
for factor in all_factors:
|
|
327
|
+
apunim = _aposteriori_polarization_stat(
|
|
328
|
+
observed_dfus=observed_dfu_dict[factor],
|
|
329
|
+
randomized_dfus=apriori_dfu_dict[factor],
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
pvalue = _aposteriori_pvalue_parametric(
|
|
333
|
+
randomized_dfus=apriori_dfu_dict[factor],
|
|
334
|
+
kappa=apunim,
|
|
335
|
+
two_sided=two_sided,
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
results[factor] = ApunimResult(apunim=apunim, pvalue=pvalue)
|
|
339
|
+
|
|
340
|
+
return results
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _correct_pvalues(results, alpha):
|
|
344
|
+
factors, result_objs = zip(*results.items())
|
|
345
|
+
pvals = [r.pvalue for r in result_objs]
|
|
346
|
+
|
|
347
|
+
corrected = _apply_correction_to_results(pvals, alpha)
|
|
348
|
+
|
|
349
|
+
return {
|
|
350
|
+
f: ApunimResult(r.apunim, cp)
|
|
351
|
+
for f, r, cp in zip(factors, result_objs, corrected)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _get_valid_comments(
|
|
356
|
+
annotations: NDArray[np.float64],
|
|
357
|
+
comment_group: NDArray[np.int64],
|
|
358
|
+
factor_group: NDArray[Any],
|
|
359
|
+
bins: int,
|
|
360
|
+
) -> list[int]:
|
|
361
|
+
# --- FIRST LOOP: Identify valid comments ---
|
|
362
|
+
valid_comments = []
|
|
363
|
+
for curr_comment_id in _unique(comment_group):
|
|
364
|
+
is_in_curr_comment = comment_group == curr_comment_id
|
|
365
|
+
all_comment_annotations = annotations[is_in_curr_comment]
|
|
366
|
+
comment_annotator_groups = factor_group[is_in_curr_comment]
|
|
367
|
+
|
|
368
|
+
if len(all_comment_annotations) > 0 and _comment_is_valid(
|
|
369
|
+
comment_annotations=all_comment_annotations,
|
|
370
|
+
comment_annotator_groups=comment_annotator_groups,
|
|
371
|
+
bins=bins,
|
|
372
|
+
):
|
|
373
|
+
valid_comments.append(curr_comment_id)
|
|
374
|
+
|
|
375
|
+
return valid_comments
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _validate_input(
|
|
379
|
+
annotations: Collection[float],
|
|
380
|
+
annotator_group: Collection[FactorType],
|
|
381
|
+
comment_group: Collection[FactorType],
|
|
382
|
+
iterations: int,
|
|
383
|
+
bins: int,
|
|
384
|
+
alpha: float | None,
|
|
385
|
+
) -> None:
|
|
386
|
+
if not (len(annotations) == len(annotator_group) == len(comment_group)):
|
|
387
|
+
raise ValueError(
|
|
388
|
+
"Length of provided lists must be the same, "
|
|
389
|
+
+ f"but len(annotations)=={len(annotations)}, "
|
|
390
|
+
+ f"len(annotator_group)=={len(annotator_group)}, "
|
|
391
|
+
+ f"len(comment_group)=={len(comment_group)}"
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
if len(annotations) == 0:
|
|
395
|
+
raise ValueError("No annotations given.")
|
|
396
|
+
|
|
397
|
+
if len(_unique(annotator_group)) < 2:
|
|
398
|
+
raise ValueError("Only one group was provided.")
|
|
399
|
+
|
|
400
|
+
if len(_unique(comment_group)) < 2:
|
|
401
|
+
raise ValueError(
|
|
402
|
+
"Only one comment was provided. "
|
|
403
|
+
"The Aposteriori Unimodality Test is defined for discussions, "
|
|
404
|
+
"not individual comments."
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
if iterations < 1:
|
|
408
|
+
raise ValueError("iterations must be at least 1.")
|
|
409
|
+
|
|
410
|
+
if bins < 2:
|
|
411
|
+
raise ValueError("Number of bins has to be at least 2.")
|
|
412
|
+
if alpha is not None and (alpha < 0 or alpha > 1):
|
|
413
|
+
raise ValueError("Alpha should be between 0 and 1.")
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _comment_is_valid(
|
|
417
|
+
comment_annotations: Collection[float],
|
|
418
|
+
comment_annotator_groups: Collection[FactorType],
|
|
419
|
+
bins: int,
|
|
420
|
+
) -> bool:
|
|
421
|
+
"""
|
|
422
|
+
A comment is valid if:
|
|
423
|
+
1. It shows polarization (DFU > 0.01)
|
|
424
|
+
2. It has at least two distinct annotator groups
|
|
425
|
+
"""
|
|
426
|
+
|
|
427
|
+
# --- Check for polarization ---
|
|
428
|
+
has_polarization = not np.isclose(
|
|
429
|
+
dfu(comment_annotations, bins=bins, normalized=True),
|
|
430
|
+
0,
|
|
431
|
+
atol=0.01,
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
# --- annotator groups ---
|
|
435
|
+
groups = [x for x in comment_annotator_groups if _is_not_none(x)]
|
|
436
|
+
sufficient_groups = len(_unique(groups)) >= 2
|
|
437
|
+
|
|
438
|
+
return has_polarization and sufficient_groups
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _factor_dfu_stat(
|
|
442
|
+
all_comment_annotations: NDArray[np.float64],
|
|
443
|
+
annotator_group: NDArray[Any],
|
|
444
|
+
bins: int,
|
|
445
|
+
) -> dict[object, float]:
|
|
446
|
+
"""
|
|
447
|
+
Generate the polarization stat (dfu diff stat) for each factor of the
|
|
448
|
+
selected feature, for one comment.
|
|
449
|
+
|
|
450
|
+
:param all_comment_annotations: An array containing all annotations
|
|
451
|
+
for the current comment
|
|
452
|
+
:type all_comment_annotations: NDArray[float]
|
|
453
|
+
:param annotator_group: An array where each value is a distinct level of
|
|
454
|
+
the currently considered factor
|
|
455
|
+
:type annotator_group: NDArray[`FactorType`]
|
|
456
|
+
:param bins: number of annotation levels
|
|
457
|
+
:type bins: int
|
|
458
|
+
:return: The polarization stats for each level of the currently considered
|
|
459
|
+
factor, for one comment
|
|
460
|
+
:rtype: dict[FactorType, float]
|
|
461
|
+
"""
|
|
462
|
+
if all_comment_annotations.shape != annotator_group.shape:
|
|
463
|
+
raise ValueError("Value and group arrays must be the same length.")
|
|
464
|
+
|
|
465
|
+
if len(all_comment_annotations) == 0:
|
|
466
|
+
raise ValueError("Empty annotation list given.")
|
|
467
|
+
|
|
468
|
+
stats = {}
|
|
469
|
+
for factor in _unique(annotator_group):
|
|
470
|
+
factor_annotations = all_comment_annotations[annotator_group == factor]
|
|
471
|
+
if len(factor_annotations) == 0:
|
|
472
|
+
stats[factor] = np.nan
|
|
473
|
+
else:
|
|
474
|
+
stats[factor] = dfu(factor_annotations, bins=bins)
|
|
475
|
+
|
|
476
|
+
return stats
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _apriori_polarization_stat(
|
|
480
|
+
annotations: NDArray[np.float64],
|
|
481
|
+
group_sizes: dict[Any, int],
|
|
482
|
+
bins: int,
|
|
483
|
+
iterations: int,
|
|
484
|
+
rng: np.random.Generator,
|
|
485
|
+
) -> dict[object, list[float]]:
|
|
486
|
+
"""
|
|
487
|
+
For a single comment's annotations, generate `iterations` random partitions
|
|
488
|
+
that respect the given group_sizes, compute the normalized DFU for each
|
|
489
|
+
resulting group, and return a dict mapping factor -> list of DFU values
|
|
490
|
+
(one value per iteration).
|
|
491
|
+
|
|
492
|
+
:param annotations: 1D numpy array of annotation values for the comment
|
|
493
|
+
:param group_sizes:
|
|
494
|
+
dict mapping factor -> size for that factor in this comment
|
|
495
|
+
:param bins: number of bins to use when computing DFU
|
|
496
|
+
:param iterations: number of random partitions to sample
|
|
497
|
+
:return: dict mapping factor -> list[float] (length == iterations)
|
|
498
|
+
"""
|
|
499
|
+
# order of factors must be preserved so results align
|
|
500
|
+
factors = list(group_sizes.keys())
|
|
501
|
+
sizes = np.array([group_sizes[f] for f in factors], dtype=int)
|
|
502
|
+
|
|
503
|
+
if np.sum(sizes) != len(annotations):
|
|
504
|
+
raise ValueError(
|
|
505
|
+
"Sum of provided group sizes must equal the number of annotations."
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
# prepare result lists
|
|
509
|
+
results: dict[object, list[float]] = {f: [] for f in factors}
|
|
510
|
+
|
|
511
|
+
for _ in range(iterations):
|
|
512
|
+
partitions = _random_partition(arr=annotations, sizes=sizes, rng=rng)
|
|
513
|
+
# partitions is a list of numpy arrays in the same order as `factors`
|
|
514
|
+
for f, part in zip(factors, partitions):
|
|
515
|
+
if part.size == 0:
|
|
516
|
+
results[f].append(np.nan)
|
|
517
|
+
else:
|
|
518
|
+
results[f].append(dfu(part, bins=bins))
|
|
519
|
+
return results
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _random_partition(
|
|
523
|
+
arr: NDArray,
|
|
524
|
+
sizes: NDArray[np.int64],
|
|
525
|
+
rng: np.random.Generator,
|
|
526
|
+
) -> list[NDArray]:
|
|
527
|
+
"""
|
|
528
|
+
Randomly partition a numpy array into groups of given sizes.
|
|
529
|
+
|
|
530
|
+
Parameters:
|
|
531
|
+
- arr: numpy array to be partitioned.
|
|
532
|
+
- sizes: list of integers indicating the size of each group.
|
|
533
|
+
|
|
534
|
+
Returns:
|
|
535
|
+
- List of numpy arrays, each with the size specified in `sizes`.
|
|
536
|
+
|
|
537
|
+
Raises:
|
|
538
|
+
- ValueError: if the sum of sizes does not match the length of arr.
|
|
539
|
+
"""
|
|
540
|
+
if np.sum(sizes) != len(arr):
|
|
541
|
+
raise ValueError(
|
|
542
|
+
f"Sum of sizes ({np.sum(sizes)}) must equal length "
|
|
543
|
+
f"of input array ({len(arr)})."
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
shuffled = rng.permutation(arr)
|
|
547
|
+
partitions = []
|
|
548
|
+
start = 0
|
|
549
|
+
for size in sizes:
|
|
550
|
+
end = start + size
|
|
551
|
+
partitions.append(shuffled[start:end])
|
|
552
|
+
start = end
|
|
553
|
+
|
|
554
|
+
return partitions
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _aposteriori_polarization_stat(
|
|
558
|
+
observed_dfus: list[float],
|
|
559
|
+
randomized_dfus: list[list[float]],
|
|
560
|
+
) -> float:
|
|
561
|
+
"""
|
|
562
|
+
Compute the apunim statistic and p-value.
|
|
563
|
+
"""
|
|
564
|
+
if len(observed_dfus) == 0 or np.all(np.isnan(observed_dfus)):
|
|
565
|
+
return np.nan
|
|
566
|
+
|
|
567
|
+
O_f = np.nanmean(observed_dfus)
|
|
568
|
+
|
|
569
|
+
# expected mean from randomizations
|
|
570
|
+
# filters out all-nan expected values which may crop up
|
|
571
|
+
means = [_safe_nanmean(r) for r in randomized_dfus]
|
|
572
|
+
means = [m for m in means if not np.isnan(m)]
|
|
573
|
+
if len(means) == 0:
|
|
574
|
+
warnings.warn(
|
|
575
|
+
"Apunim statistic is NaN because all randomized DFU estimates "
|
|
576
|
+
"were NaN. This typically means that randomized groups were empty "
|
|
577
|
+
"or had no variation in annotations.",
|
|
578
|
+
RuntimeWarning,
|
|
579
|
+
)
|
|
580
|
+
return np.nan
|
|
581
|
+
|
|
582
|
+
E_f = np.mean(means)
|
|
583
|
+
if np.isclose(E_f, 1, atol=10e-3):
|
|
584
|
+
warnings.warn(
|
|
585
|
+
"Estimated polarization is very close to max. "
|
|
586
|
+
"The aposteriori test may be unreliable."
|
|
587
|
+
)
|
|
588
|
+
if E_f == 1:
|
|
589
|
+
warnings.warn(
|
|
590
|
+
"Apunim statistic is NaN because the expected DFU (E_f) is 1, "
|
|
591
|
+
"meaning all random partitions were already maximally polarized.",
|
|
592
|
+
RuntimeWarning,
|
|
593
|
+
)
|
|
594
|
+
return np.nan
|
|
595
|
+
|
|
596
|
+
apunim = (O_f - E_f) / (1.0 - E_f)
|
|
597
|
+
return float(apunim)
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _aposteriori_pvalue_parametric(
|
|
601
|
+
randomized_dfus: list[list[float]], kappa: float, two_sided: bool
|
|
602
|
+
) -> float:
|
|
603
|
+
"""
|
|
604
|
+
Parametric p-value estimation for κ using a normal approximation.
|
|
605
|
+
"""
|
|
606
|
+
if np.isnan(kappa):
|
|
607
|
+
warnings.warn(
|
|
608
|
+
"p-value could not be computed because the apunim statistic "
|
|
609
|
+
"is NaN. This usually happens when a factor has no valid "
|
|
610
|
+
"annotations.",
|
|
611
|
+
RuntimeWarning,
|
|
612
|
+
)
|
|
613
|
+
return np.nan
|
|
614
|
+
|
|
615
|
+
# compute null distribution of kappa as before
|
|
616
|
+
kappa_null = []
|
|
617
|
+
for i, r in enumerate(randomized_dfus):
|
|
618
|
+
if len(r) == 0 or np.all(np.isnan(r)):
|
|
619
|
+
continue
|
|
620
|
+
O_r = np.nanmean(r)
|
|
621
|
+
other_means = [
|
|
622
|
+
_safe_nanmean(rr) for j, rr in enumerate(randomized_dfus) if j != i
|
|
623
|
+
]
|
|
624
|
+
other_means = [m for m in other_means if not np.isnan(m)]
|
|
625
|
+
if len(other_means) == 0:
|
|
626
|
+
continue
|
|
627
|
+
E_r = np.mean(other_means)
|
|
628
|
+
kappa_null.append((O_r - E_r) / (1.0 - E_r))
|
|
629
|
+
|
|
630
|
+
kappa_null = np.array(kappa_null)
|
|
631
|
+
if len(kappa_null) < 2:
|
|
632
|
+
warnings.warn(
|
|
633
|
+
"p-value is NaN because the null distribution for kappa "
|
|
634
|
+
"could not be estimated (fewer than two valid randomized "
|
|
635
|
+
"DFU values). This often occurs when comments contain too few "
|
|
636
|
+
"annotations per group.",
|
|
637
|
+
RuntimeWarning,
|
|
638
|
+
)
|
|
639
|
+
return np.nan
|
|
640
|
+
|
|
641
|
+
# use a one-sample t-test comparing kappa_null to the observed kappa
|
|
642
|
+
# H0: mean(kappa_null) == kappa
|
|
643
|
+
# We compute test statistic for the difference from kappa
|
|
644
|
+
p_value = scipy.stats.ttest_1samp(
|
|
645
|
+
kappa_null, kappa, alternative="two-sided" if two_sided else "larger"
|
|
646
|
+
).pvalue # type: ignore
|
|
647
|
+
|
|
648
|
+
return float(p_value)
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def _safe_nanmean(x):
|
|
652
|
+
"""Helper to compute nanmean safely."""
|
|
653
|
+
return np.nanmean(x) if len(x) > 0 and not np.all(np.isnan(x)) else np.nan
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def _apply_correction_to_results(
|
|
657
|
+
pvalues: Collection[float], alpha: float = 0.05
|
|
658
|
+
) -> NDArray:
|
|
659
|
+
"""
|
|
660
|
+
Apply multiple hypothesis correction to a list of p-values.
|
|
661
|
+
Returns corrected p-values in the same order.
|
|
662
|
+
|
|
663
|
+
NaN p-values are excluded from correction (because FDR procedures
|
|
664
|
+
cannot operate on undefined hypotheses). They are restored to NaN
|
|
665
|
+
in their original positions after correction.
|
|
666
|
+
"""
|
|
667
|
+
pvals = np.array(pvalues, dtype=float)
|
|
668
|
+
|
|
669
|
+
if np.any((pvals[~np.isnan(pvals)] < 0) | (pvals[~np.isnan(pvals)] > 1)):
|
|
670
|
+
raise ValueError("Invalid pvalues given for correction.")
|
|
671
|
+
|
|
672
|
+
return _apply_correction(pvals, alpha)
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def _apply_correction(pvalues: NDArray, alpha: float) -> NDArray:
|
|
676
|
+
"""
|
|
677
|
+
FDR correction that handles NaNs safely.
|
|
678
|
+
|
|
679
|
+
Steps:
|
|
680
|
+
1. Identify valid (non-NaN) p-values.
|
|
681
|
+
2. Apply FDR-BH only to valid p-values.
|
|
682
|
+
3. Restore NaN positions to the output.
|
|
683
|
+
"""
|
|
684
|
+
pvals = np.array(pvalues, dtype=float)
|
|
685
|
+
valid_mask = ~np.isnan(pvals)
|
|
686
|
+
valid_pvals = pvals[valid_mask]
|
|
687
|
+
|
|
688
|
+
# If no valid values exist, return array of NaN
|
|
689
|
+
if len(valid_pvals) == 0:
|
|
690
|
+
warnings.warn(
|
|
691
|
+
"All p-values are NaN; skipping multiple-testing correction.",
|
|
692
|
+
RuntimeWarning,
|
|
693
|
+
)
|
|
694
|
+
return pvals # all NaN array
|
|
695
|
+
|
|
696
|
+
# Perform FDR correction on valid p-values
|
|
697
|
+
corrected_valid = statsmodels.stats.multitest.multipletests(
|
|
698
|
+
valid_pvals,
|
|
699
|
+
alpha=alpha,
|
|
700
|
+
method="fdr_bh",
|
|
701
|
+
is_sorted=False,
|
|
702
|
+
returnsorted=False,
|
|
703
|
+
)[1]
|
|
704
|
+
|
|
705
|
+
# Create output array and restore NaNs
|
|
706
|
+
corrected = np.full_like(pvals, np.nan)
|
|
707
|
+
corrected[valid_mask] = corrected_valid
|
|
708
|
+
|
|
709
|
+
return corrected
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def _to_hist(scores: Collection[float], bins: int) -> NDArray:
|
|
713
|
+
"""
|
|
714
|
+
Creates a normalised histogram. Used for DFU calculation.
|
|
715
|
+
:param: scores: the ratings (not necessarily discrete)
|
|
716
|
+
:param: num_bins: the number of bins to create
|
|
717
|
+
:param: normed: whether to normalise the counts or not, by default true
|
|
718
|
+
:return: the histogram
|
|
719
|
+
"""
|
|
720
|
+
scores_array = np.array(scores)
|
|
721
|
+
if len(scores_array) == 0:
|
|
722
|
+
raise ValueError("Annotation list can not be empty.")
|
|
723
|
+
|
|
724
|
+
counts, _ = np.histogram(a=scores_array, bins=bins, density=True)
|
|
725
|
+
return counts
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def _unique(x: Collection[Any]) -> list[Any]:
|
|
729
|
+
# preserve first-seen order
|
|
730
|
+
return list(dict.fromkeys(x))
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def _is_not_none(x: Any) -> bool:
|
|
734
|
+
return x is not None and not (isinstance(x, float) and math.isnan(x))
|