dane-data 0.2.1__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.
@@ -0,0 +1,219 @@
1
+ """
2
+ Carga y armonización de microdatos EAM (un establecimiento por fila).
3
+
4
+ Entre 1992 y 2024 cambian: el separador (``;``, ``,``, tabulador), las
5
+ mayúsculas de los nombres y el nombre de la variable CIIU (``CIIU2N4``,
6
+ ``ciiu2``, ``CIIU``, ``ciiu3``, ``ciiu4``, ``ciiu_4``). ``load`` deja todos los
7
+ nombres en minúscula y agrega:
8
+
9
+ * ``anio`` año de referencia.
10
+ * ``ciiu`` código CIIU (texto; normalmente 4 dígitos, pero en algunos años
11
+ se publica a 2–3 dígitos por anonimización).
12
+ * ``ciiu_rev`` revisión de la CIIU (2, 3 o 4) según la variable de origen.
13
+ * ``division`` primeros 2 dígitos del CIIU y ``sector`` su nombre.
14
+ * ``departamento`` nombre del departamento.
15
+
16
+ Valores monetarios en **miles de pesos corrientes** (p. ej. el valor agregado
17
+ total de 2024 suma ~1,48e11, es decir ~148 billones de pesos). Para comparar
18
+ entre años hay que deflactar (``dd.eam.deflactar``).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+ import re
25
+ from pathlib import Path
26
+ from typing import Iterable
27
+
28
+ import numpy as np
29
+ import pandas as pd
30
+
31
+ from .. import config
32
+ from .._io import read_table
33
+ from ..referencias import DEPARTAMENTOS
34
+
35
+ log = logging.getLogger("dane_data.eam")
36
+
37
+ # Variables presentes en TODOS los años 1992–2024 (más algunas desde 2000).
38
+ VARIABLES_COMUNES = [
39
+ "nordemp", "nordest", "dpto", "periodo",
40
+ "prodbr2", "prodbind", "consin2", "valagri", "valorven", "valorcom",
41
+ "pertotal", "persocu", "persoesc", "pertem3", "pperytem",
42
+ "salarper", "pressper", "salpeyte", "prespyte",
43
+ "activfi", "invebrta", "eelec",
44
+ ]
45
+ VARIABLES_DESDE_2000 = ["totalv", "deprecia"]
46
+ CORE = VARIABLES_COMUNES + VARIABLES_DESDE_2000
47
+
48
+ ETIQUETAS = {
49
+ "nordemp": "Identificador de la empresa",
50
+ "nordest": "Identificador del establecimiento (planta)",
51
+ "dpto": "Departamento",
52
+ "periodo": "Año de referencia",
53
+ "prodbr2": "Producción bruta",
54
+ "prodbind": "Producción industrial",
55
+ "consin2": "Consumo intermedio",
56
+ "valagri": "Valor agregado",
57
+ "valorven": "Ventas totales",
58
+ "valorcom": "Compras de materias primas, materiales y empaques",
59
+ "pertotal": "Personal total (permanente + propietarios + temporal directo + agencias)",
60
+ "persocu": "Empleados permanentes",
61
+ "persoesc": "Empleados permanentes + propietarios",
62
+ "pertem3": "Empleados temporales directos",
63
+ "pperytem": "Empleados permanentes y temporales directos",
64
+ "salarper": "Salarios de empleados permanentes",
65
+ "pressper": "Prestaciones de empleados permanentes",
66
+ "salpeyte": "Salarios de empleados permanentes y temporales",
67
+ "prespyte": "Prestaciones de empleados permanentes y temporales",
68
+ "activfi": "Activos fijos",
69
+ "invebrta": "Inversión bruta",
70
+ "eelec": "Energía eléctrica consumida (kWh)",
71
+ "totalv": "Valor de otros energéticos consumidos",
72
+ "deprecia": "Depreciación",
73
+ }
74
+
75
+ # Nombres de división (2 dígitos) por revisión de la CIIU.
76
+ SECTORES = {
77
+ 4: {
78
+ "10": "Alimentos", "11": "Bebidas", "12": "Tabaco", "13": "Textiles",
79
+ "14": "Prendas de vestir", "15": "Cuero y calzado", "16": "Madera", "17": "Papel y cartón",
80
+ "18": "Impresión", "19": "Refinación de petróleo", "20": "Químicos", "21": "Farmacéuticos",
81
+ "22": "Caucho y plástico", "23": "Minerales no metálicos", "24": "Metalurgia básica",
82
+ "25": "Productos de metal", "26": "Electrónicos y ópticos", "27": "Equipo eléctrico",
83
+ "28": "Maquinaria y equipo", "29": "Vehículos automotores", "30": "Otro equipo de transporte",
84
+ "31": "Muebles", "32": "Otras manufacturas", "33": "Reparación e instalación de maquinaria",
85
+ "58": "Edición (publicaciones)", "59": "Cine, video y música", "73": "Publicidad",
86
+ },
87
+ 3: {
88
+ "15": "Alimentos y bebidas", "16": "Tabaco", "17": "Textiles", "18": "Prendas de vestir",
89
+ "19": "Cuero y calzado", "20": "Madera", "21": "Papel y cartón", "22": "Edición e impresión",
90
+ "23": "Refinación de petróleo", "24": "Químicos", "25": "Caucho y plástico",
91
+ "26": "Minerales no metálicos", "27": "Metalurgia básica", "28": "Productos de metal",
92
+ "29": "Maquinaria y equipo", "30": "Maquinaria de oficina", "31": "Aparatos eléctricos",
93
+ "32": "Radio, TV y comunicaciones", "33": "Instrumentos médicos y de precisión",
94
+ "34": "Vehículos automotores", "35": "Otro equipo de transporte", "36": "Muebles y otras manufacturas",
95
+ "37": "Reciclaje",
96
+ },
97
+ 2: {
98
+ "31": "Alimentos, bebidas y tabaco", "32": "Textiles, confecciones y cuero",
99
+ "33": "Madera y muebles", "34": "Papel e imprentas", "35": "Químicos, petróleo, caucho y plástico",
100
+ "36": "Minerales no metálicos", "37": "Metálicas básicas", "38": "Productos metálicos y maquinaria",
101
+ "39": "Otras manufacturas",
102
+ },
103
+ }
104
+
105
+ _CIIU_COL = re.compile(r"^ciiu(_?\d)?(n\d)?$")
106
+
107
+
108
+ def _root(root) -> Path:
109
+ return Path(root).expanduser() if root is not None else config.get_path("eam")
110
+
111
+
112
+ def _data_dir(root: Path) -> Path:
113
+ return root / "datos" if (root / "datos").is_dir() else root
114
+
115
+
116
+ def find_files(years: Iterable[int] | int | None = None, root=None) -> dict[int, Path]:
117
+ """``{año: archivo}`` de los datos EAM descargados (ignora el módulo TIC)."""
118
+ base = _data_dir(_root(root))
119
+ if not base.is_dir():
120
+ return {}
121
+ if years is not None:
122
+ years = {int(y) for y in ([years] if isinstance(years, (int, str)) else years)}
123
+ out = {}
124
+ for ydir in sorted(p for p in base.iterdir() if p.is_dir() and p.name.isdigit()):
125
+ anio = int(ydir.name)
126
+ if years is not None and anio not in years:
127
+ continue
128
+ files = sorted(f for f in ydir.iterdir() if f.is_file() and f.suffix.lower() in (".csv", ".txt"))
129
+ if files:
130
+ out[anio] = max(files, key=lambda f: f.stat().st_size)
131
+ return out
132
+
133
+
134
+ def available(root=None) -> pd.DataFrame:
135
+ rows = [{"anio": y, "archivo": f.name, "tamano_mb": round(f.stat().st_size / 1e6, 1)}
136
+ for y, f in find_files(root=root).items()]
137
+ return pd.DataFrame(rows, columns=["anio", "archivo", "tamano_mb"])
138
+
139
+
140
+ def _ciiu_rev(colname: str, anio: int) -> int:
141
+ m = re.search(r"ciiu_?(\d)", colname)
142
+ if m: # ciiu2, ciiu3, ciiu4, ciiu_4, ciiu2n4 -> el dígito tras 'ciiu'
143
+ return int(m.group(1))
144
+ # Cortes según los códigos de los archivos, no según el ANDA: su documentación dice
145
+ # Rev. 3 desde 2000 y Rev. 4 desde las cifras de 2013, pero el archivo de 2000 trae
146
+ # códigos Rev. 2 y el de 2012 ya trae Rev. 4 (ver README).
147
+ return 2 if anio <= 2000 else 3 if anio <= 2011 else 4
148
+
149
+
150
+ def harmonize(df: pd.DataFrame, anio: int) -> pd.DataFrame:
151
+ df = df.copy()
152
+ ciiu_cols = [c for c in df.columns if _CIIU_COL.match(c)]
153
+ if ciiu_cols:
154
+ col = ciiu_cols[0]
155
+ codes = pd.to_numeric(df[col], errors="coerce")
156
+ rev = _ciiu_rev(col, anio)
157
+ # Los nombres originales cambian cada época; quedan unificados en `ciiu`.
158
+ df = df.drop(columns=ciiu_cols)
159
+ # Sin ceros a la izquierda: por anonimización algunos años publican el
160
+ # código a 2 o 3 dígitos (p. ej. "20", "110"); los 2 primeros siempre son la división.
161
+ df["ciiu"] = codes.astype("Int64").astype("string")
162
+ df["ciiu_rev"] = rev
163
+ else:
164
+ df["ciiu"] = pd.NA
165
+ df["ciiu_rev"] = pd.NA
166
+ df["division"] = df["ciiu"].str[:2]
167
+ rev = df["ciiu_rev"].iloc[0] if len(df) else None
168
+ df["sector"] = df["division"].map(SECTORES.get(rev, {}))
169
+ dpto = pd.to_numeric(df.get("dpto"), errors="coerce")
170
+ df["departamento"] = dpto.map(DEPARTAMENTOS) if dpto is not None else pd.NA
171
+ df.insert(0, "anio", anio)
172
+ return df
173
+
174
+
175
+ def load(
176
+ years: Iterable[int] | int | None = None,
177
+ columns="core",
178
+ harmonize_vars: bool = True,
179
+ root=None,
180
+ progress=None,
181
+ ) -> pd.DataFrame:
182
+ """
183
+ Carga la EAM (un establecimiento-año por fila).
184
+
185
+ Parámetros
186
+ ----------
187
+ years : año, lista/rango de años o None (todos los disponibles).
188
+ columns : ``"core"`` (variables comparables entre años, por defecto),
189
+ ``"all"`` o lista de variables extra (minúscula o mayúscula),
190
+ p. ej. ``["c3r10c1", "c4r5c1"]``.
191
+
192
+ >>> eam = dd.eam.load(range(2014, 2025))
193
+ >>> dd.eam.resumen(eam, por=["anio", "sector"])
194
+ """
195
+ files = find_files(years, root=root)
196
+ if not files:
197
+ raise FileNotFoundError(
198
+ f"No hay datos EAM para años={years} en {_root(root)}. "
199
+ "Descárguelos con dane_data.eam.download(...) o revise dane_data.set_paths(...)."
200
+ )
201
+ if columns == "all":
202
+ selector = None
203
+ else:
204
+ wanted = set(CORE) | ({c.lower() for c in columns} if columns not in (None, "core") else set())
205
+ selector = lambda c: c in wanted or bool(_CIIU_COL.match(c)) # noqa: E731
206
+
207
+ frames = []
208
+ for i, (anio, path) in enumerate(sorted(files.items())):
209
+ if progress:
210
+ progress(i / len(files), f"Leyendo EAM {anio}")
211
+ df = read_table(path, columns=selector, case="lower")
212
+ frames.append(harmonize(df, anio) if harmonize_vars else df.assign(anio=anio))
213
+ if progress:
214
+ progress(1.0, "Listo")
215
+ out = pd.concat(frames, ignore_index=True, sort=False)
216
+ for c in ("nordemp", "nordest"):
217
+ if c in out.columns:
218
+ out[c] = pd.to_numeric(out[c], errors="coerce").astype("Int64")
219
+ return out
@@ -0,0 +1,62 @@
1
+ """Gran Encuesta Integrada de Hogares (GEIH) — mercado laboral, microdatos de personas."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Iterable
6
+
7
+ from .. import config
8
+ from .downloader import (DEFAULT_MODULES, EMPALME_CATALOGS, KNOWN_CATALOGS, MODULE_PATTERNS,
9
+ GEIHDownloader)
10
+ from .agregados import (POBLACIONES, POR_PRECALCULADOS, VARIABLES_INDICADORES, exportar, filtrar, serie_frecuencias,
11
+ serie_indicadores, serie_tabla, tabla_de_frecuencias)
12
+ from .indicadores import GLOSARIO, indicadores
13
+ from .loader import (CATEGORIAS, CONDICIONES, CORE_RAW, GRUPOS_EDAD, MARCOS, MODULOS, NIVELES_EDUC, available,
14
+ clear_cache, find_months, harmonize, load, load_month, marco_muestral)
15
+
16
+ ANIOS_DISPONIBLES = sorted(int(y) for y in KNOWN_CATALOGS)
17
+ ANIOS_EMPALME = sorted(int(y) for y in EMPALME_CATALOGS)
18
+
19
+
20
+ def download(
21
+ years: Iterable[int] | int | None = None,
22
+ modules: Iterable[str] | None = None,
23
+ empalme: bool = False,
24
+ root=None,
25
+ delete_zips: bool = False,
26
+ limit_files: int | None = None,
27
+ progress=None,
28
+ should_stop=None,
29
+ ) -> None:
30
+ """
31
+ Descarga microdatos GEIH del ANDA (DANE) a la carpeta configurada.
32
+
33
+ >>> dd.geih.download([2024, 2025]) # serie principal
34
+ >>> dd.geih.download(2016, empalme=True) # serie de empalme
35
+ >>> dd.geih.download(2025, limit_files=1) # solo enero (prueba)
36
+
37
+ ``modules`` acepta: vivienda, caracteristicas, fuerza_trabajo, ocupados,
38
+ desocupados, inactivos, otras_formas_trabajo (por defecto todos).
39
+ Es incremental: los meses ya extraídos se saltan.
40
+ """
41
+ if isinstance(years, (int, str)):
42
+ years = [years]
43
+ root = root or config.get_path("geih_empalme" if empalme else "geih")
44
+ dl = GEIHDownloader(
45
+ base_dir=root,
46
+ keep_zips=not delete_zips,
47
+ extract_csv=True,
48
+ is_empalme=empalme,
49
+ modules=modules,
50
+ progress=progress,
51
+ should_stop=should_stop,
52
+ )
53
+ dl.run_pipeline(target_years=years, limit_files=limit_files)
54
+
55
+
56
+ __all__ = [
57
+ "download", "load", "load_month", "available", "find_months", "harmonize", "indicadores",
58
+ "serie_indicadores", "serie_tabla", "serie_frecuencias", "tabla_de_frecuencias", "exportar", "filtrar",
59
+ "POBLACIONES", "marco_muestral", "MARCOS", "clear_cache", "GLOSARIO", "MODULOS", "CORE_RAW", "CONDICIONES", "NIVELES_EDUC",
60
+ "GRUPOS_EDAD", "CATEGORIAS", "VARIABLES_INDICADORES", "POR_PRECALCULADOS",
61
+ "ANIOS_DISPONIBLES", "ANIOS_EMPALME", "MODULE_PATTERNS", "DEFAULT_MODULES",
62
+ ]
@@ -0,0 +1,358 @@
1
+ """
2
+ Resultados de la GEIH calculados año por año y guardados en disco.
3
+
4
+ Las funciones ``serie_*`` dan lo mismo que cargar todos los años con ``load`` y calcular
5
+ encima, pero leen un año a la vez: la memoria no depende de cuántos años se pidan. Cada
6
+ año queda en ``~/.cache/dane_data/geih/<tipo>/`` y se recalcula solo si cambian sus
7
+ archivos, la solicitud (filtros, meses, variables) o el código de dane-data. ``exportar``
8
+ escribe los microdatos armonizados mes a mes, por la misma razón.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import json
15
+ import logging
16
+ import os
17
+ from functools import lru_cache
18
+ from pathlib import Path
19
+ from typing import Callable, Iterable
20
+
21
+ import numpy as np
22
+ import pandas as pd
23
+
24
+ from .. import config
25
+ from ..referencias import MESES
26
+ from ..stats import _as_list, peso_mensual_promedio, weighted_quantile
27
+ from .indicadores import _COLUMNAS, indicadores
28
+ from .loader import _HARMONIZE_VERSION, CATEGORIAS, MesGEIH, _firma_archivos, _root, find_months, load
29
+
30
+ log = logging.getLogger("dane_data.geih")
31
+
32
+ # Lo que lee `indicadores` más las desagregaciones y filtros de la app.
33
+ VARIABLES_INDICADORES = ["anio", "mes", "fex", "pet", "fuerza_trabajo", "ocupado", "desocupado", "inactivo",
34
+ "formal", "ingreso_laboral", "sexo", "zona", "edad", "grupo_edad", "nivel_educativo",
35
+ "departamento"]
36
+ POR_PRECALCULADOS = (None, "sexo", "zona", "grupo_edad", "nivel_educativo", "departamento")
37
+ POBLACIONES = ("pet", "fuerza_trabajo", "ocupado", "desocupado", "inactivo")
38
+
39
+
40
+ def filtrar(df: pd.DataFrame, filtros: dict | None = None, poblacion: str | None = None) -> pd.DataFrame:
41
+ """
42
+ Filas que cumplen ``filtros`` y pertenecen a ``poblacion``.
43
+
44
+ ``filtros``: ``{columna: [valores]}``, o ``{columna: (mínimo, máximo)}`` para un rango
45
+ (con los extremos), p. ej. ``{"sexo": ["Mujer"], "edad": (15, 28)}``. Si la columna no
46
+ existe (una variable que ese año no trae), nadie cumple el filtro.
47
+ ``poblacion``: una de ``POBLACIONES`` (``"ocupado"``, ``"pet"``...).
48
+ """
49
+ mask = pd.Series(True, index=df.index)
50
+ for col, cond in (filtros or {}).items():
51
+ if col not in df.columns:
52
+ mask &= False
53
+ elif isinstance(cond, tuple):
54
+ mask &= df[col].between(*cond)
55
+ else:
56
+ mask &= df[col].isin(list(cond))
57
+ if poblacion:
58
+ mask &= df[poblacion].eq(1).fillna(False).astype(bool)
59
+ return df if mask.all() else df[mask]
60
+
61
+
62
+ # --------------------------------------------------------------------------- #
63
+ # Caché por año
64
+ # --------------------------------------------------------------------------- #
65
+ @lru_cache(maxsize=1)
66
+ def _firma_codigo() -> str:
67
+ """Hash del código que produce los resultados: si cambia, la caché se recalcula sola."""
68
+ from .. import __version__
69
+
70
+ base = Path(__file__).resolve().parent.parent
71
+ h = hashlib.sha1(__version__.encode())
72
+ for rel in ("geih/loader.py", "geih/indicadores.py", "geih/agregados.py", "stats.py", "referencias.py",
73
+ "_io.py"):
74
+ try:
75
+ h.update((base / rel).read_bytes())
76
+ except OSError:
77
+ pass
78
+ return h.hexdigest()[:16]
79
+
80
+
81
+ def _meses(meses) -> list[int] | None:
82
+ if meses is None:
83
+ return None
84
+ return sorted({int(m) for m in ([meses] if isinstance(meses, (int, str)) else meses)})
85
+
86
+
87
+ def _meses_por_anio(anios, meses, empalme: bool, root) -> dict[int, list[MesGEIH]]:
88
+ """Meses completos en disco (los que usa ``load``), agrupados por año."""
89
+ meses = _meses(meses)
90
+ por_anio: dict[int, list[MesGEIH]] = {}
91
+ for m in find_months(anios, empalme=empalme, root=root):
92
+ if m.completo and (meses is None or m.mes in meses):
93
+ por_anio.setdefault(m.anio, []).append(m)
94
+ if not por_anio:
95
+ raise FileNotFoundError(
96
+ f"No hay datos GEIH para años={anios}, meses={meses} en {_root(empalme, root)}. "
97
+ "Descárguelos con dane_data.geih.download(...) o revise dane_data.set_paths(...)."
98
+ )
99
+ return por_anio
100
+
101
+
102
+ def _por_anio(tipo: str, anios, solicitud: dict, calcular: Callable[[int], pd.DataFrame], *, meses=None,
103
+ empalme: bool = False, root=None, progress=None) -> list[pd.DataFrame]:
104
+ """``calcular(anio)`` para cada año, leído de la caché si ya se hizo con los mismos archivos y solicitud."""
105
+ por_anio = _meses_por_anio(anios, meses, empalme, root)
106
+ texto = json.dumps(solicitud, sort_keys=True, default=str, ensure_ascii=False)
107
+ serie = "empalme" if empalme else "principal"
108
+ partes = []
109
+ for i, (anio, ms) in enumerate(sorted(por_anio.items())):
110
+ firma = [_HARMONIZE_VERSION, _firma_codigo(), texto, *(s for m in ms for s in _firma_archivos(m))]
111
+ digest = hashlib.sha1("|".join(firma).encode()).hexdigest()[:16]
112
+ ruta = config.CACHE_DIR / "geih" / tipo / f"{serie}_{anio}_{digest}.parquet"
113
+ tabla = None
114
+ if ruta.exists():
115
+ try:
116
+ tabla = pd.read_parquet(ruta)
117
+ except Exception: # caché corrupta: se regenera
118
+ tabla = None
119
+ if tabla is None:
120
+ if progress:
121
+ progress(i / len(por_anio), f"Calculando {anio} (solo la primera vez)")
122
+ tabla = calcular(anio)
123
+ try:
124
+ ruta.parent.mkdir(parents=True, exist_ok=True)
125
+ tmp = ruta.with_name(f"{ruta.name}.{os.getpid()}.tmp") # varias sesiones de la app a la vez
126
+ tabla.to_parquet(tmp, index=False)
127
+ tmp.replace(ruta)
128
+ except Exception as e: # disco lleno, sin permisos, etc.
129
+ log.debug(f"No se pudo guardar caché {ruta}: {e}")
130
+ partes.append(tabla)
131
+ if progress:
132
+ progress(1.0, "Listo")
133
+ return partes
134
+
135
+
136
+ # --------------------------------------------------------------------------- #
137
+ # Indicadores laborales
138
+ # --------------------------------------------------------------------------- #
139
+ def serie_indicadores(
140
+ anios: Iterable[int] | int,
141
+ por: str | None = None,
142
+ anual: bool = True,
143
+ empalme: bool = False,
144
+ root=None,
145
+ progress=None,
146
+ filtros: dict | None = None,
147
+ meses: Iterable[int] | int | None = None,
148
+ ) -> pd.DataFrame:
149
+ """
150
+ Lo mismo que ``indicadores(filtrar(load(anios, months=meses), filtros), por=por, anual=anual)``,
151
+ pero sin juntar los microdatos de todos los años en memoria.
152
+
153
+ Cada año se calcula por separado (con todas las desagregaciones de
154
+ ``POR_PRECALCULADOS``, anual y mensual) y se guarda. La primera vez tarda unos
155
+ segundos por año; después es instantáneo, también al cambiar ``por`` o ``anual``.
156
+ Para desagregar por otras variables use ``indicadores`` sobre
157
+ ``load(..., variables=VARIABLES_INDICADORES)``.
158
+
159
+ >>> dd.geih.serie_indicadores(range(2007, 2026)) # TD, TGP... por año
160
+ >>> dd.geih.serie_indicadores(range(2021, 2026), por="sexo", anual=False)
161
+ >>> dd.geih.serie_indicadores(range(2015, 2026), filtros={"edad": (15, 28)}) # jóvenes
162
+ """
163
+ if por not in POR_PRECALCULADOS:
164
+ raise ValueError(f"por={por!r} no está precalculado (opciones: {list(POR_PRECALCULADOS)}). Use "
165
+ "dd.geih.indicadores(dd.geih.load(..., variables=dd.geih.VARIABLES_INDICADORES), por=...).")
166
+ variables = list(dict.fromkeys([*VARIABLES_INDICADORES, *(filtros or {})]))
167
+
168
+ def calcular(anio):
169
+ df = filtrar(load(anio, months=meses, empalme=empalme, root=root, variables=variables), filtros)
170
+ partes = []
171
+ for p in POR_PRECALCULADOS:
172
+ for a in (True, False):
173
+ r = indicadores(df, por=p, anual=a)
174
+ if p:
175
+ r = r.rename(columns={p: "grupo"}).astype({"grupo": object})
176
+ partes.append(r.assign(desagregacion=p or "total", anual=a))
177
+ return pd.concat(partes, ignore_index=True)
178
+
179
+ tablas = _por_anio("indicadores", anios, {"filtros": filtros or {}, "meses": _meses(meses)}, calcular,
180
+ meses=meses, empalme=empalme, root=root, progress=progress)
181
+ res = pd.concat([t[(t["desagregacion"] == (por or "total")) & (t["anual"] == anual)] for t in tablas],
182
+ ignore_index=True)
183
+ keys = ["anio"] + ([] if anual else ["mes"])
184
+ if not anual:
185
+ res["mes"] = res["mes"].astype("int64")
186
+ if por:
187
+ res[por] = res["grupo"].astype(CATEGORIAS[por])
188
+ keys.append(por)
189
+ return res[keys + _COLUMNAS]
190
+
191
+
192
+ # --------------------------------------------------------------------------- #
193
+ # Tablas ponderadas
194
+ # --------------------------------------------------------------------------- #
195
+ def serie_frecuencias(
196
+ anios: Iterable[int] | int,
197
+ variable: str | None = None,
198
+ por: str | Iterable[str] | None = None,
199
+ poblacion: str | None = None,
200
+ filtros: dict | None = None,
201
+ meses: Iterable[int] | int | None = None,
202
+ columns="core",
203
+ empalme: bool = False,
204
+ root=None,
205
+ progress=None,
206
+ ) -> pd.DataFrame:
207
+ """
208
+ Distribución ponderada: una fila por año, combinación de ``por`` y valor de ``variable``,
209
+ con ``peso`` (personas, promedio mensual de cada año) y ``n_muestra``.
210
+
211
+ Es la base de ``serie_tabla``: de ella salen totales, porcentajes, medias y medianas
212
+ exactas aunque se junten varios años. ``columns`` son las variables DANE extra que
213
+ hay que leer (como en ``load``).
214
+
215
+ >>> f = dd.geih.serie_frecuencias(range(2015, 2026), "ingreso_laboral", poblacion="ocupado")
216
+ >>> dd.weighted_quantile(f["ingreso_laboral"], f["peso"], 0.5) # mediana de todos esos años
217
+ """
218
+ por = [p for p in _as_list(por) if p != "anio"]
219
+ claves = ["anio", *por] + ([variable] if variable else [])
220
+ necesarias = list(dict.fromkeys(["fex", *claves, *(filtros or {}), *([poblacion] if poblacion else [])]))
221
+
222
+ def calcular(anio):
223
+ df = load(anio, months=meses, columns=columns, empalme=empalme, root=root, variables=necesarias)
224
+ for c in claves: # una variable DANE que ese año no existe
225
+ if c not in df.columns:
226
+ df[c] = np.nan
227
+ d = filtrar(df, filtros, poblacion)
228
+ x = d[claves].assign(peso=peso_mensual_promedio(d).to_numpy(), n_muestra=1)
229
+ if variable:
230
+ x = x[x[variable].notna()]
231
+ return x.groupby(claves, observed=True, dropna=False).agg(
232
+ peso=("peso", "sum"), n_muestra=("n_muestra", "sum")).reset_index()
233
+
234
+ solicitud = {"variable": variable, "por": sorted(por), "poblacion": poblacion, "filtros": filtros or {},
235
+ "meses": _meses(meses), "columns": columns}
236
+ tablas = _por_anio("frecuencias", anios, solicitud, calcular, meses=meses, empalme=empalme, root=root,
237
+ progress=progress)
238
+ return pd.concat(tablas, ignore_index=True)
239
+
240
+
241
+ def serie_tabla(
242
+ anios: Iterable[int] | int,
243
+ por: str | Iterable[str] | None = None,
244
+ variable: str | None = None,
245
+ estadistico: str = "total",
246
+ poblacion: str | None = None,
247
+ filtros: dict | None = None,
248
+ meses: Iterable[int] | int | None = None,
249
+ dropna: bool = False,
250
+ columns="core",
251
+ empalme: bool = False,
252
+ root=None,
253
+ progress=None,
254
+ ) -> pd.DataFrame:
255
+ """
256
+ Lo mismo que ``dd.tabla(filtrar(load(anios, months=meses), filtros, poblacion), por, variable,
257
+ estadistico)``, calculado año por año y guardado en disco.
258
+
259
+ ``dropna=True`` deja fuera a las personas con valor vacío en alguna variable de ``por``
260
+ (p. ej. la rama de actividad de quien no trabaja).
261
+
262
+ >>> dd.geih.serie_tabla(range(2015, 2026), por=["anio", "nivel_educativo"], estadistico="porcentaje",
263
+ ... poblacion="ocupado")
264
+ >>> dd.geih.serie_tabla([2024, 2025], por="sexo", variable="ingreso_laboral", estadistico="mediana",
265
+ ... filtros={"zona": ["Urbano (cabecera)"]})
266
+ """
267
+ por = _as_list(por)
268
+ if estadistico not in ("total", "porcentaje", "media", "mediana"):
269
+ raise ValueError("estadistico debe ser 'total', 'porcentaje', 'media' o 'mediana'.")
270
+ if estadistico in ("media", "mediana") and variable is None:
271
+ raise ValueError("Indique `variable` para media/mediana.")
272
+ f = serie_frecuencias(anios, variable, por, poblacion, filtros, meses, columns, empalme, root, progress)
273
+ if dropna and por:
274
+ f = f.dropna(subset=por)
275
+ return tabla_de_frecuencias(f, por, variable, estadistico)
276
+
277
+
278
+ def tabla_de_frecuencias(f: pd.DataFrame, por: str | Iterable[str] | None = None, variable: str | None = None,
279
+ estadistico: str = "total") -> pd.DataFrame:
280
+ """Tabla de ``dd.tabla`` a partir de las frecuencias ponderadas de ``serie_frecuencias``."""
281
+ por = _as_list(por)
282
+ if estadistico in ("total", "porcentaje"):
283
+ if not por:
284
+ res = pd.DataFrame({"total": [f["peso"].sum()], "n_muestra": [int(f["n_muestra"].sum())]})
285
+ else:
286
+ res = f.groupby(por, observed=True, dropna=False).agg(
287
+ total=("peso", "sum"), n_muestra=("n_muestra", "sum")).reset_index()
288
+ if estadistico == "porcentaje":
289
+ outer = por[:-1]
290
+ denom = res.groupby(outer, observed=True)["total"].transform("sum") if outer else res["total"].sum()
291
+ res["porcentaje"] = 100 * res["total"] / denom
292
+ return res
293
+
294
+ if estadistico == "media":
295
+ def fn(x):
296
+ return float(np.average(x[variable], weights=x["peso"])) if x["peso"].sum() > 0 else np.nan
297
+ else:
298
+ def fn(x):
299
+ return weighted_quantile(x[variable], x["peso"], 0.5)
300
+ if not por:
301
+ return pd.DataFrame({estadistico: [fn(f)], "n_muestra": [int(f["n_muestra"].sum())]})
302
+ g = f.groupby(por, observed=True, dropna=False)
303
+ stat = g[[variable, "peso"]].apply(fn).rename(estadistico)
304
+ n = g["n_muestra"].sum().rename("n_muestra")
305
+ return pd.concat([stat, n], axis=1).reset_index()
306
+
307
+
308
+ # --------------------------------------------------------------------------- #
309
+ # Exportar microdatos
310
+ # --------------------------------------------------------------------------- #
311
+ def exportar(
312
+ anios: Iterable[int] | int,
313
+ ruta,
314
+ variables: Iterable[str] | None = None,
315
+ poblacion: str | None = None,
316
+ filtros: dict | None = None,
317
+ meses: Iterable[int] | int | None = None,
318
+ columns="core",
319
+ empalme: bool = False,
320
+ root=None,
321
+ progress=None,
322
+ ) -> int:
323
+ """
324
+ Escribe en ``ruta`` (CSV) los microdatos armonizados, una persona por fila, un mes a la vez:
325
+ sirve para muchos años aunque no quepan en memoria. Devuelve el número de filas.
326
+
327
+ ``variables``: columnas a escribir (por defecto, las armonizadas). Las variables DANE
328
+ que no están en ``CORE_RAW`` deben pedirse también en ``columns``.
329
+
330
+ >>> dd.geih.exportar(range(2015, 2026), "ocupados.csv", poblacion="ocupado")
331
+ """
332
+ ruta = Path(ruta)
333
+ por_anio = _meses_por_anio(anios, meses, empalme, root)
334
+ lista = [m for _, ms in sorted(por_anio.items()) for m in ms]
335
+ necesarias = None
336
+ if variables is not None:
337
+ necesarias = list(dict.fromkeys(["anio", "mes", *variables]))
338
+ leer = list(dict.fromkeys([*(necesarias or []), *(filtros or {}), *([poblacion] if poblacion else [])])) or None
339
+ ruta.parent.mkdir(parents=True, exist_ok=True)
340
+ tmp = ruta.with_name(f"{ruta.name}.{os.getpid()}.tmp")
341
+ filas = 0
342
+ try:
343
+ for i, m in enumerate(lista):
344
+ if progress:
345
+ progress(i / len(lista), f"Exportando {MESES[m.mes]} {m.anio}")
346
+ df = filtrar(load(m.anio, months=m.mes, columns=columns, empalme=empalme, root=root, variables=leer),
347
+ filtros, poblacion)
348
+ if necesarias is None: # las armonizadas (minúscula), iguales en todos los meses
349
+ necesarias = [c for c in df.columns if c == c.lower()]
350
+ df.reindex(columns=necesarias).to_csv(tmp, mode="w" if i == 0 else "a", header=i == 0, index=False,
351
+ encoding="utf-8-sig" if i == 0 else "utf-8")
352
+ filas += len(df)
353
+ tmp.replace(ruta)
354
+ finally:
355
+ tmp.unlink(missing_ok=True)
356
+ if progress:
357
+ progress(1.0, "Listo")
358
+ return filas