Código fuente para dane_data.stats

"""Estadísticas ponderadas por factor de expansión."""

from __future__ import annotations

from typing import Iterable

import numpy as np
import pandas as pd


def _as_list(x) -> list:
    if x is None:
        return []
    return [x] if isinstance(x, str) else list(x)


def peso_mensual_promedio(df: pd.DataFrame, peso: str = "fex") -> pd.Series:
    """
    Peso que convierte totales de varios meses en un *promedio mensual*.

    El factor de expansión de la GEIH es mensual: si se juntan 12 meses, la
    suma de ``fex`` es 12 veces la población. Este peso divide ``fex`` por el
    número de meses distintos de cada año presentes en ``df``. Sumarlo
    equivale al promedio de los totales mensuales (método DANE para
    agregados trimestrales/anuales).

    Parámetros
    ----------
    df
        Microdatos con ``anio``, ``mes`` y la columna del peso. Sin ``anio`` o ``mes`` se devuelve el
        peso tal cual.
    peso
        Columna del factor de expansión.

    Devuelve
    --------
    Series
        El peso dividido por el número de meses de cada año.

    Notas
    -----
    Si ``df.attrs["meses_por_anio"]`` existe (``{anio: número de meses}``), se divide por esos meses; si no,
    por los meses que hay en ``df``. ``geih.filtrar`` lo anota con los meses de antes de filtrar: así un
    mes que el filtro deja sin nadie (un grupo pequeño) cuenta como 0 en el promedio, en vez de sacarse
    del promedio. Con ``df[df.mes <= 3]`` se cuentan los meses que quedan, como debe ser para un trimestre.

    Ejemplos
    --------
    >>> import pandas as pd
    >>> from dane_data.stats import peso_mensual_promedio
    >>> df = pd.DataFrame({"anio": [2024, 2024, 2024], "mes": [1, 2, 2], "fex": [100.0, 90.0, 30.0]})
    >>> peso_mensual_promedio(df).tolist()
    [50.0, 45.0, 15.0]
    >>> df.attrs["meses_por_anio"] = {2024: 4}  # se cargaron 4 meses; en dos no quedó nadie
    >>> peso_mensual_promedio(df).tolist()
    [25.0, 22.5, 7.5]
    """
    if "mes" not in df.columns or "anio" not in df.columns:
        return df[peso]
    n_meses = df.groupby("anio", observed=True)["mes"].transform("nunique")
    if meses := df.attrs.get("meses_por_anio"):
        n_meses = df["anio"].astype("int64").map(meses).fillna(n_meses)
    return df[peso] / n_meses


[documentos] def weighted_quantile(values, weights, q: float) -> float: """ Cuantil ponderado. Es el menor valor cuya masa acumulada alcanza la fracción ``q`` del peso total, la misma definición de ``_pctile`` en Stata. Siempre devuelve uno de los valores observados (no interpola). Parámetros ---------- values Valores (cualquier secuencia numérica). weights Pesos, del mismo largo que ``values``. Se ignoran los pares con algún NaN y los pesos que no son positivos. q Fracción entre 0 y 1: 0.5 es la mediana. Devuelve -------- float El cuantil, o NaN si no queda ningún valor. Véase también ------------- weighted_mean : Media ponderada. tabla : Medianas ponderadas por grupo. Ejemplos -------- >>> import dane_data as dd >>> dd.weighted_quantile([10, 20, 30], [1, 1, 1], 0.5) 20.0 >>> dd.weighted_quantile([10, 20, 30], [5, 1, 1], 0.5) 10.0 """ v = np.asarray(values, dtype=float) w = np.asarray(weights, dtype=float) mask = ~(np.isnan(v) | np.isnan(w)) & (w > 0) v, w = v[mask], w[mask] if v.size == 0: return np.nan order = np.argsort(v, kind="stable") v, w = v[order], w[order] # CDF inversa: el menor valor cuya masa acumulada alcanza q (como _pctile de Stata). cum = np.cumsum(w) return float(v[np.searchsorted(cum, q * cum[-1], side="left")])
[documentos] def weighted_mean(values, weights) -> float: """ Media ponderada. Parámetros ---------- values Valores (cualquier secuencia numérica). weights Pesos, del mismo largo que ``values``. Se ignoran los pares con algún NaN. Devuelve -------- float La media, o NaN si no quedan valores o los pesos suman 0. Véase también ------------- weighted_quantile : Cuantiles ponderados (por ejemplo, la mediana). tabla : Medias ponderadas por grupo. Ejemplos -------- >>> import dane_data as dd >>> dd.weighted_mean([10, 20, 30], [1, 1, 2]) 22.5 >>> dd.weighted_mean([10, None, 30], [1, 1, 1]) 20.0 """ v = np.asarray(values, dtype=float) w = np.asarray(weights, dtype=float) mask = ~(np.isnan(v) | np.isnan(w)) return float(np.average(v[mask], weights=w[mask])) if mask.any() and w[mask].sum() > 0 else np.nan
[documentos] def tabla( df: pd.DataFrame, por: str | Iterable[str] | None = None, variable: str | None = None, estadistico: str = "total", peso: str | None = "fex", ) -> pd.DataFrame: """ Tabla ponderada de cualquier variable. Expande la muestra con el factor de expansión y, si hay varios meses de un mismo año, convierte los totales en promedios mensuales, como el DANE. Sirve para la GEIH y para cualquier otra tabla con pesos. Parámetros ---------- df Microdatos, una fila por persona (por ejemplo, de ``dd.geih.load``). por Variable o lista de variables que definen los grupos. Sin ``por``, una sola fila con todo ``df``. variable Variable numérica para ``"media"`` y ``"mediana"``. Las filas donde está vacía se descartan también con ``"total"`` y ``"porcentaje"``. estadistico ``"total"``: personas (promedio mensual) en cada grupo. ``"porcentaje"``: distribución de la *última* variable de ``por`` dentro de las demás; por ejemplo, con ``por=["anio", "sexo"]``, el porcentaje de cada sexo en cada año. ``"media"`` o ``"mediana"``: de ``variable``, ponderadas, en cada grupo. peso Columna del factor de expansión. ``None`` cuenta observaciones sin expandir. Devuelve -------- DataFrame Una fila por grupo con las columnas de ``por``, el estadístico (``total``, ``porcentaje``, ``media`` o ``mediana``; ``"porcentaje"`` trae también ``total``) y ``n_muestra``, el número de observaciones sin expandir. Lanza ----- ValueError Si ``estadistico`` no es uno de los cuatro, o falta ``variable`` para la media o la mediana. Véase también ------------- dane_data.geih.serie_tabla : La misma tabla para muchos años, sin cargarlos en memoria. weighted_mean : Media ponderada de una sola serie. weighted_quantile : Cuantil ponderado de una sola serie. Notas ----- Con pocas observaciones las cifras expandidas no son confiables: revise ``n_muestra`` y desconfíe de los grupos con menos de unas 30 a 100 personas. La mediana es la de ``weighted_quantile`` (el menor valor que acumula la mitad del peso). Ejemplos -------- Dos meses de una muestra con seis personas. Cada total es el promedio de los dos meses: >>> import pandas as pd >>> import dane_data as dd >>> df = pd.DataFrame({ ... "anio": 2024, "mes": [1, 1, 1, 2, 2, 2], ... "sexo": ["Mujer", "Hombre", "Mujer", "Mujer", "Hombre", "Hombre"], ... "ingreso": [1.2, 2.0, 1.5, 1.3, 2.2, 1.8], ... "fex": [100.0, 150.0, 50.0, 120.0, 90.0, 110.0], ... }) >>> dd.tabla(df, por="sexo") sexo total n_muestra 0 Hombre 175.0 3 1 Mujer 135.0 3 >>> dd.tabla(df, por="sexo", estadistico="porcentaje").round(1) sexo total n_muestra porcentaje 0 Hombre 175.0 3 56.5 1 Mujer 135.0 3 43.5 >>> dd.tabla(df, por="sexo", variable="ingreso", estadistico="mediana") sexo mediana n_muestra 0 Hombre 2.0 3 1 Mujer 1.3 3 Con la GEIH: >>> df = dd.geih.load(2024) # doctest: +SKIP >>> dd.tabla(df, por=["anio", "nivel_educativo"], estadistico="porcentaje") # doctest: +SKIP >>> dd.tabla(df[df.ocupado == 1], por="sexo", variable="ingreso_laboral", estadistico="mediana") # doctest: +SKIP """ por = _as_list(por) d = df.copy() if peso is None: d["_w"] = 1.0 else: d["_w"] = peso_mensual_promedio(d, peso) if variable is not None: d = d[d[variable].notna()] g = d.groupby(por, observed=True, dropna=False) if por else None if estadistico in ("total", "porcentaje"): if g is None: res = pd.DataFrame({"total": [d["_w"].sum()], "n_muestra": [len(d)]}) else: res = g.agg(total=("_w", "sum"), n_muestra=("_w", "size")).reset_index() if estadistico == "porcentaje": outer = por[:-1] # dropna=False como en la agregación: un grupo exterior vacío (NaN) también suma 100 % denom = (res.groupby(outer, observed=True, dropna=False)["total"].transform("sum") if outer else res["total"].sum()) res["porcentaje"] = 100 * res["total"] / denom return res if variable is None: raise ValueError("Indique `variable` para media/mediana.") if estadistico == "media": fn = lambda x: weighted_mean(x[variable], x["_w"]) # noqa: E731 elif estadistico == "mediana": fn = lambda x: weighted_quantile(x[variable], x["_w"], 0.5) # noqa: E731 else: raise ValueError("estadistico debe ser 'total', 'porcentaje', 'media' o 'mediana'.") if g is None: return pd.DataFrame({estadistico: [fn(d)], "n_muestra": [len(d)]}) stat = g[[variable, "_w"]].apply(fn).rename(estadistico) n = g.size().rename("n_muestra") return pd.concat([stat, n], axis=1).reset_index()