feat(datascience): rigor experimental para papers — effect size, IC, Holm + preregistro inmutable

Subsistema de papers reproducibles (grupo de capacidad `papers`). Añade las
funciones estadísticas que un paper honesto necesita y la función que congela la
hipótesis antes de mirar los datos (anti-HARKing).

Nuevas funciones (puras salvo la última):
- effect_size_cohens_d: Cohen's d + Hedges' g (corrección de sesgo para N
  pequeño) + interpretación cualitativa (negligible/small/medium/large por los
  umbrales de Cohen). Dict-no-throw ante varianza cero / N insuficiente.
- confidence_interval_mean: intervalo de confianza de una media (t de Student) o
  de la diferencia de medias con Welch (df de Welch–Satterthwaite, sin asumir
  varianzas iguales). Dict-no-throw; el IC colapsa al punto cuando la varianza es
  cero.
- preregister_hypothesis (impura): congela hipótesis + plan de análisis en
  papers/<slug>/preregistration.md con frozen_at (UTC) y content_hash (sha256 del
  cuerpo normalizado, no del frontmatter). Inmutabilidad: una vez frozen, un
  contenido distinto se RECHAZA sin sobrescribir (mata el HARKing); idempotente si
  el contenido es idéntico. Siempre dict-no-throw.

Extensión:
- fdr_correction 1.0.0 -> 1.1.0: añade method="holm" (Holm-Bonferroni step-down,
  controla FWER, más potente que Bonferroni simple). Reúsa la maquinaria de
  alineación 1:1 con None/inválidos; no rompe los métodos bh/bonferroni.

Reutiliza del registry: fdr_correction (BH + Bonferroni ya existían) como base
para Holm. pearson y spearman_corr ya cubrían correlación.

Tests: 36 pytest verdes (cohen/hedges 8, confidence/welch 8, fdr/holm/bonferroni
12, preregister 4 + extras), golden contra valores conocidos y validados con
scipy. Golden manual del preregistro: congela, idempotente, rechaza edición
(bytes en disco idénticos al congelado).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-30 20:42:12 +02:00
parent a1e2e3567c
commit 4f1530797e
13 changed files with 1265 additions and 21 deletions
@@ -0,0 +1,87 @@
---
name: confidence_interval_mean
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: pure
signature: "def confidence_interval_mean(data: list, other: list = None, confidence: float = 0.95) -> dict"
description: "Intervalo de confianza (IC) de la media de una muestra con la t de Student, o de la DIFERENCIA de medias de dos muestras independientes con el metodo de Welch (sin asumir varianzas iguales). Una muestra: df=n-1, se=sd_muestral/sqrt(n) (sd con ddof=1), tcrit=t.ppf((1+confidence)/2, df), ci=mean+/-tcrit*se. Dos muestras: IC de mean(data)-mean(other) con se=sqrt(se1^2+se2^2) y grados de libertad de Welch-Satterthwaite. Pura y robusta: nunca lanza; ante casos degenerados (muestra vacia, n<2) devuelve nan + clave note, y con varianza cero el IC colapsa al punto (no es error). Usa scipy.stats y numpy."
tags: [papers, statistics, confidence-interval, welch, t-test, python]
params:
- name: data
desc: "muestra de observaciones numericas (lista de numeros). Si other es None, el IC es el de la media de data."
- name: other
desc: "segunda muestra independiente (lista de numeros) o None (default). Si se da, el IC es el de la diferencia de medias mean(data)-mean(other) calculada con Welch (no asume varianzas iguales)."
- name: confidence
desc: "nivel de confianza en (0, 1); 0.95 = IC del 95% (default). El cuantil critico es t.ppf((1+confidence)/2, df)."
output: "dict {mean, ci_low, ci_high, se, df, confidence, n}. mean = media de data (una muestra) o la diferencia mean(data)-mean(other) (dos muestras). En el caso de dos muestras se anaden ademas n1 y n2 (y n = n1+n2). df son los grados de libertad de la t (Welch-Satterthwaite si dos muestras). Casos degenerados (muestra vacia, n<2) anaden la clave note y dejan ci_low/ci_high/se (y a veces df) en nan; con varianza cero y n>=2 el IC colapsa a [mean, mean] con se=0 (con note, sin nan). Nunca None ni excepcion."
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [scipy, numpy]
tested: true
tests: ["test_one_sample_golden_contra_scipy", "test_one_sample_distinto_nivel_confianza", "test_welch_diferencia_golden_contra_scipy", "test_edge_un_solo_elemento_no_lanza_nan_note", "test_edge_lista_vacia_no_lanza_note", "test_edge_varianza_cero_colapsa_al_punto", "test_edge_welch_muestra_vacia_no_lanza_note", "test_edge_welch_n1_uno_no_lanza_note"]
test_file_path: "python/functions/datascience/confidence_interval_mean_test.py"
file_path: "python/functions/datascience/confidence_interval_mean.py"
---
## Ejemplo
```python
from datascience import confidence_interval_mean
# IC del 95% de la media de una muestra (t de Student).
data = [2, 4, 4, 4, 5, 5, 7, 9]
ci = confidence_interval_mean(data, confidence=0.95)
print(ci["mean"]) # -> 5.0
print(ci["df"]) # -> 7.0 (n - 1)
print(round(ci["ci_low"], 5), round(ci["ci_high"], 5))
# -> 3.21251 6.78749 (se con sd muestral ddof=1 ~ 2.13809)
# IC del 95% de la DIFERENCIA de medias (Welch, no asume varianzas iguales).
control = [23.0, 21.0, 25.0, 22.0, 24.0, 26.0]
tratado = [18.0, 20.0, 17.0, 19.0, 21.0]
diff = confidence_interval_mean(control, tratado, confidence=0.95)
print(diff["mean"]) # -> 4.5 (mean(control) - mean(tratado))
print(round(diff["ci_low"], 4), round(diff["ci_high"], 4))
# Si el intervalo no incluye 0, la diferencia es significativa al 5%.
# Degenerados: nunca lanza.
print(confidence_interval_mean([5])["note"]) # n < 2: ... indefinidos
print(confidence_interval_mean([3, 3, 3])["se"]) # -> 0.0 (IC colapsa a [3, 3])
```
## Cuando usarla
Cuando quieras cuantificar la **incertidumbre de una media estimada** a partir de
una muestra: reporta `[ci_low, ci_high]` en vez de un punto suelto para mostrar
el rango plausible del valor real al nivel de confianza pedido. Usala tambien
para **comparar dos grupos** (A/B test, control vs tratamiento, antes vs
despues con grupos independientes): pasa las dos muestras y, si el IC de la
diferencia **no incluye el 0**, la diferencia es significativa al nivel
`1 - confidence`. Es el complemento del p-valor: ademas de "hay efecto", te dice
"de que tamano y con que margen". Para dos muestras usa Welch por defecto, asi
que no necesitas comprobar antes si las varianzas son iguales.
## Gotchas
- Pura y determinista (no hace I/O, no muta las entradas), pero **no** es
stdlib-only: depende de `scipy.stats` y `numpy` (ambos en el venv del proyecto).
- Con `other` usa **Welch** (df de Welch-Satterthwaite): NO asume varianzas
iguales ni tamanos de muestra iguales. Si necesitas el t-test clasico de
varianzas agrupadas (pooled), esta funcion no lo hace.
- `sd` se calcula con **ddof=1** (sd muestral), que es lo correcto para el IC de
una media con la t. Atajos como `sd_poblacional/sqrt(n)` (ddof=0) dan un
intervalo demasiado estrecho.
- En el caso de dos muestras, `mean` es la **diferencia** `mean(data) - mean(other)`
(no la media de data). El orden importa: el signo del IC depende de cual va
primero.
- Nunca lanza. Casos degenerados devuelven `nan` en `ci_low`/`ci_high`/`se`
(y a veces `df`) mas una clave `note`: muestra vacia o `n < 2` en cualquiera de
las muestras. **Excepcion**: con varianza cero y `n >= 2` el IC colapsa al
punto `[mean, mean]` con `se = 0` (no es un error, no hay `nan`).
- Comprueba `"note" in out` antes de usar `ci_low`/`ci_high` si la muestra puede
ser degenerada.