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 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))