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,82 @@
1
+ """Indicadores de mercado laboral a partir de la GEIH armonizada."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Iterable
6
+
7
+ import numpy as np
8
+ import pandas as pd
9
+
10
+ from ..stats import _as_list, peso_mensual_promedio, weighted_mean, weighted_quantile
11
+
12
+ GLOSARIO = {
13
+ "PET": "Población en edad de trabajar. Desde 2022 (marco 2018): 15 años o más. "
14
+ "Antes: 12+ en zonas urbanas y 10+ en rurales.",
15
+ "FT": "Fuerza de trabajo (antes PEA): personas ocupadas + desocupadas.",
16
+ "TGP": "Tasa global de participación = FT / PET × 100. ¿Qué parte de quienes pueden trabajar participa en el mercado laboral?",
17
+ "TO": "Tasa de ocupación = Ocupados / PET × 100.",
18
+ "TD": "Tasa de desocupación = Desocupados / FT × 100. El 'desempleo'.",
19
+ "Informalidad": "Ocupados que NO están afiliados a salud y cotizando a pensión, sobre el total de ocupados × 100. "
20
+ "Es una definición por seguridad social; la oficial del DANE usa además tamaño de empresa.",
21
+ "Ingreso laboral": "INGLABO: ingresos laborales mensuales (pesos corrientes) imputados por el DANE.",
22
+ }
23
+ _COLUMNAS = ["pet", "fuerza_trabajo", "ocupados", "desocupados", "inactivos", "tgp", "to", "td",
24
+ "tasa_informalidad", "ingreso_medio", "ingreso_mediano", "n_muestra"]
25
+
26
+
27
+ def indicadores(df: pd.DataFrame, por: str | Iterable[str] | None = None, anual: bool = True) -> pd.DataFrame:
28
+ """
29
+ Indicadores de mercado laboral ponderados.
30
+
31
+ Parámetros
32
+ ----------
33
+ df : DataFrame de ``dane_data.geih.load`` (con variables armonizadas).
34
+ por : variable(s) para desagregar, p. ej. ``"sexo"`` o ``["zona", "nivel_educativo"]``.
35
+ anual : si True, promedia los meses de cada año; si False, un resultado por mes.
36
+
37
+ Devuelve niveles (personas, promedio mensual) y tasas en %:
38
+ ``pet, fuerza_trabajo, ocupados, desocupados, inactivos, tgp, to, td,
39
+ tasa_informalidad, ingreso_medio, ingreso_mediano, n_muestra``.
40
+ """
41
+ por = _as_list(por)
42
+ time_keys = ["anio"] if anual else ["anio", "mes"]
43
+ keys = time_keys + por
44
+
45
+ # Solo las sumas ponderadas que hacen falta: copiar el frame entero duplica la memoria
46
+ # con muchos años.
47
+ w = (peso_mensual_promedio(df) if anual else df["fex"]).to_numpy("float64")
48
+
49
+ def es(col: str) -> np.ndarray:
50
+ return df[col].to_numpy("float64", na_value=0) == 1
51
+
52
+ ocupado = es("ocupado")
53
+ sumas = pd.DataFrame({
54
+ "pet": np.where(es("pet"), w, 0.0),
55
+ "fuerza_trabajo": np.where(es("fuerza_trabajo"), w, 0.0),
56
+ "ocupados": np.where(ocupado, w, 0.0),
57
+ "desocupados": np.where(es("desocupado"), w, 0.0),
58
+ "inactivos": np.where(es("inactivo"), w, 0.0),
59
+ "informales": np.where(ocupado & (df["formal"].to_numpy("float64", na_value=np.nan) == 0), w, 0.0),
60
+ }, index=df.index)
61
+ g = sumas.groupby([df[k] for k in keys], observed=True, dropna=False)
62
+ res = g.sum()
63
+ res["n_muestra"] = g.size()
64
+ with np.errstate(divide="ignore", invalid="ignore"):
65
+ res["tgp"] = 100 * res["fuerza_trabajo"] / res["pet"]
66
+ res["to"] = 100 * res["ocupados"] / res["pet"]
67
+ res["td"] = 100 * res["desocupados"] / res["fuerza_trabajo"]
68
+ res["tasa_informalidad"] = 100 * res["informales"] / res["ocupados"]
69
+
70
+ ingreso = df["ingreso_laboral"].to_numpy("float64", na_value=np.nan)
71
+ con_ingreso = ocupado & (ingreso > 0)
72
+ if con_ingreso.any():
73
+ ocup = df.loc[con_ingreso, keys].assign(_y=ingreso[con_ingreso], _w=w[con_ingreso])
74
+ gi = ocup.groupby(keys, observed=True, dropna=False)
75
+ res["ingreso_medio"] = gi.apply(lambda x: weighted_mean(x["_y"], x["_w"]))
76
+ res["ingreso_mediano"] = gi.apply(lambda x: weighted_quantile(x["_y"], x["_w"], 0.5))
77
+ else:
78
+ res["ingreso_medio"] = np.nan
79
+ res["ingreso_mediano"] = np.nan
80
+
81
+ res = res.replace([np.inf, -np.inf], np.nan).reset_index()
82
+ return res[keys + _COLUMNAS]
@@ -0,0 +1,600 @@
1
+ """
2
+ Carga y armonización de microdatos GEIH ya descargados.
3
+
4
+ La GEIH cambia de estructura según la época:
5
+
6
+ * 2007–2021: cada módulo viene partido por dominio (``Area``, ``Cabecera``,
7
+ ``Resto``). ``Area`` (13/23 ciudades) es un subconjunto de ``Cabecera``, así
8
+ que el total nacional es ``Cabecera + Resto``. Desocupados e inactivos
9
+ vienen en módulos separados. Factor de expansión ``FEX_C_2011`` (coma decimal).
10
+ * 2020 y serie de empalme: un archivo por módulo, factor ``FEX_C``.
11
+ * 2022 en adelante (Marco 2018): módulo único "No ocupados" (desocupados +
12
+ fuera de la fuerza de trabajo), factor ``FEX_C18``, sexo en ``P3271``.
13
+
14
+ ``load`` oculta todo eso y entrega una persona por fila con variables
15
+ armonizadas en minúscula (``anio``, ``mes``, ``fex``, ``sexo``, ``edad``,
16
+ ``condicion``, ``formal``, ``ingreso_laboral``, ...). Las variables originales
17
+ del DANE se conservan en MAYÚSCULA.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import hashlib
23
+ import logging
24
+ import re
25
+ from dataclasses import dataclass, field
26
+ from functools import lru_cache
27
+ from pathlib import Path
28
+ from typing import Iterable
29
+
30
+ import numpy as np
31
+ import pandas as pd
32
+
33
+ from .. import config
34
+ from .._io import columnas, normalize_columns, read_table, sniff
35
+ from ..referencias import DEPARTAMENTOS, MESES, SMLV, strip_accents
36
+
37
+ log = logging.getLogger("dane_data.geih")
38
+
39
+ KEYS_PERSONA = ["DIRECTORIO", "SECUENCIA_P", "ORDEN"]
40
+ KEYS_HOGAR = ["DIRECTORIO", "SECUENCIA_P"]
41
+
42
+ # Variables originales que se leen siempre (si existen) para poder armonizar.
43
+ CORE_RAW = [
44
+ *KEYS_PERSONA, "HOGAR", "MES", "PERIODO", "DPTO", "AREA", "CLASE",
45
+ "FEX_C18", "FEX_C", "FEX_C_2011",
46
+ "P6020", "P3271", "P6040", "P6050",
47
+ "OCI", "DSI", "INI", "FFT", "FT", "PET", "PETI",
48
+ "INGLABO", "P6090", "P6920", "P6430", "P6800",
49
+ "RAMA2D", "RAMA2D_R4", "P6210", "P3042",
50
+ ]
51
+
52
+ MODULOS = ("caracteristicas", "fuerza", "ocupados", "no_ocupados", "otras", "vivienda")
53
+ _MESES_PREFIJO = {
54
+ "ene": 1, "feb": 2, "mar": 3, "abr": 4, "may": 5, "jun": 6, "jul": 7,
55
+ "ago": 8, "sep": 9, "set": 9, "oct": 10, "nov": 11, "dic": 12,
56
+ }
57
+ # Nombres de módulo con plural opcional: el DANE publica "No ocupado.CSV" (marzo 2024),
58
+ # "No ocupados.CSV", "Desocupados", la errata "Deocupados", "Ocupados04.csv" (2013), etc.
59
+ _RE_NO_OCU = re.compile(r"\bno ocupados?(?![a-z])")
60
+ _RE_DESOCU = re.compile(r"\bd(?:e|es)ocupados?(?![a-z])")
61
+ _RE_INACT = re.compile(r"\binactivos?(?![a-z])")
62
+ _RE_OCU = re.compile(r"\bocupados?(?![a-z])")
63
+ _HARMONIZE_VERSION = "7"
64
+ # Formatos de los microdatos. Se baja el CSV salvo donde el del ANDA está dañado (ver
65
+ # CSV_DANADOS en el descargador); dentro de una carpeta se prefiere el formato alterno.
66
+ EXTENSIONES = {".dta": 0, ".txt": 1, ".csv": 2}
67
+ _RE_MILES = re.compile(r"^\d{1,3}(\.\d{3}){2,}$")
68
+
69
+
70
+ # --------------------------------------------------------------------------- #
71
+ # Descubrimiento de archivos
72
+ # --------------------------------------------------------------------------- #
73
+ def classify_file(filename: str) -> tuple[str | None, str | None]:
74
+ """Devuelve (módulo, dominio) a partir del nombre de archivo del DANE."""
75
+ n = strip_accents(Path(filename).name)
76
+ dominio = None
77
+ for d in ("area", "cabecera", "resto"):
78
+ if re.match(rf"^{d}\b", n):
79
+ dominio = d
80
+ if _RE_NO_OCU.search(n) or _RE_DESOCU.search(n) or _RE_INACT.search(n):
81
+ modulo = "no_ocupados"
82
+ elif _RE_OCU.search(n):
83
+ modulo = "ocupados"
84
+ elif "fuerza de trabajo" in n:
85
+ modulo = "fuerza"
86
+ elif "caracter" in n or "personas" in n:
87
+ modulo = "caracteristicas"
88
+ elif "hogar" in n or "vivienda" in n:
89
+ modulo = "vivienda"
90
+ elif "otras" in n:
91
+ modulo = "otras"
92
+ else:
93
+ modulo = None
94
+ return modulo, dominio
95
+
96
+
97
+ @lru_cache(maxsize=4096)
98
+ def _header(path: Path) -> frozenset[str]:
99
+ return frozenset(columnas(path))
100
+
101
+
102
+ @lru_cache(maxsize=4096)
103
+ def _defecto(path: Path) -> str | None:
104
+ """
105
+ Defectos conocidos de los CSV del ANDA que hacen inservible un archivo (ver README,
106
+ "Diferencias con la documentación del DANE"). En septiembre de 2021 el CSV viene
107
+ separado por espacios, y el texto libre también los tiene. En 2008 y 2011 el factor
108
+ de expansión perdió el punto decimal ("849.491.032.387.525" es 84,9491032387525).
109
+ """
110
+ if path.suffix.lower() == ".dta":
111
+ return None
112
+ sep, enc = sniff(path)
113
+ with open(path, encoding=enc, errors="replace") as fh:
114
+ encabezado = fh.readline().rstrip("\r\n").split(sep)
115
+ filas = [fh.readline().rstrip("\r\n").split(sep) for _ in range(20)]
116
+ if len(encabezado) == 1:
117
+ return "un archivo legible (el CSV del DANE viene separado por espacios)"
118
+ fex = [i for i, c in enumerate(normalize_columns(encabezado)) if c.startswith("FEX")]
119
+ valores = [f[i].strip() for f in filas for i in fex if len(f) > i and f[i].strip()]
120
+ if valores and sum(bool(_RE_MILES.match(v)) for v in valores) > len(valores) / 2:
121
+ return "un factor de expansión legible (el CSV del DANE lo trae sin punto decimal)"
122
+ return None
123
+
124
+
125
+ def month_from_folder(name: str) -> int | None:
126
+ for word in re.findall(r"[a-z]+", strip_accents(name)):
127
+ if word[:3] in _MESES_PREFIJO and not word.startswith("marco"):
128
+ return _MESES_PREFIJO[word[:3]]
129
+ return None
130
+
131
+
132
+ @dataclass
133
+ class MesGEIH:
134
+ anio: int
135
+ mes: int
136
+ carpeta: Path
137
+ archivos: dict[str, list[Path]] = field(default_factory=dict)
138
+
139
+ @property
140
+ def modulos(self) -> list[str]:
141
+ return sorted(self.archivos)
142
+
143
+ @property
144
+ def faltantes(self) -> list[str]:
145
+ """
146
+ Información necesaria para los indicadores laborales que no está en disco.
147
+ En 2020 y en la serie de empalme las marcas OCI/DSI/INI vienen en el
148
+ módulo de características, así que se revisan los encabezados.
149
+ """
150
+ if "caracteristicas" not in self.archivos:
151
+ return ["caracteristicas"]
152
+ if danos := sorted({d for f in self.archivos["caracteristicas"] if (d := _defecto(f))}):
153
+ return danos
154
+ cols = set().union(*(_header(f) for f in self.archivos["caracteristicas"]))
155
+ nombres = [strip_accents(f.name) for f in self.archivos.get("no_ocupados", [])]
156
+ falta = []
157
+ if "ocupados" not in self.archivos and "OCI" not in cols:
158
+ falta.append("ocupados")
159
+ if not any(_RE_NO_OCU.search(n) or _RE_DESOCU.search(n) for n in nombres) and "DSI" not in cols:
160
+ falta.append("desocupados")
161
+ if "fuerza" not in self.archivos and not cols & {"PET", "PETI", "INI"}:
162
+ falta.append("fuerza")
163
+ return falta
164
+
165
+ @property
166
+ def completo(self) -> bool:
167
+ return not self.faltantes
168
+
169
+
170
+ def _root(empalme: bool, root) -> Path:
171
+ if root is not None:
172
+ return Path(root).expanduser()
173
+ return config.get_path("geih_empalme" if empalme else "geih")
174
+
175
+
176
+ def _csv_dir(root: Path) -> Path:
177
+ return root / "datos_csv" if (root / "datos_csv").is_dir() else root
178
+
179
+
180
+ def _pick_files(files: list[Path]) -> dict[str, list[Path]]:
181
+ """Agrupa por módulo y elige el dominio nacional (Cabecera + Resto)."""
182
+ grouped: dict[str, dict[str | None, list[Path]]] = {}
183
+ for f in files:
184
+ modulo, dominio = classify_file(f.name)
185
+ if modulo:
186
+ grouped.setdefault(modulo, {}).setdefault(dominio, []).append(f)
187
+ out = {}
188
+ for modulo, by_dom in grouped.items():
189
+ if "cabecera" in by_dom or "resto" in by_dom:
190
+ chosen = by_dom.get("cabecera", []) + by_dom.get("resto", [])
191
+ elif None in by_dom:
192
+ chosen = by_dom[None]
193
+ else:
194
+ chosen = by_dom["area"]
195
+ # Desocupados + Inactivos (antes de 2022) son dos archivos del mismo
196
+ # módulo lógico; se conservan ambos. Duplicados exactos de nombre no.
197
+ seen, uniq = set(), []
198
+ # Archivos del nivel superior primero; las copias en subcarpetas se ignoran.
199
+ for f in sorted(chosen, key=lambda p: (len(p.parts), EXTENSIONES.get(p.suffix.lower(), 9), str(p))):
200
+ key = re.sub(r"\s*\(\d+\)", "", f.stem.lower())
201
+ if key not in seen:
202
+ seen.add(key)
203
+ uniq.append(f)
204
+ out[modulo] = uniq
205
+ return out
206
+
207
+
208
+ def find_months(years: Iterable[int] | int | None = None, empalme: bool = False, root=None) -> list[MesGEIH]:
209
+ """Lista las carpetas mensuales disponibles localmente."""
210
+ base = _csv_dir(_root(empalme, root))
211
+ if not base.is_dir():
212
+ return []
213
+ if years is not None:
214
+ years = {int(y) for y in ([years] if isinstance(years, (int, str)) else years)}
215
+ result = []
216
+ for ydir in sorted(p for p in base.iterdir() if p.is_dir() and p.name.isdigit()):
217
+ anio = int(ydir.name)
218
+ if years is not None and anio not in years:
219
+ continue
220
+ for mdir in sorted(p for p in ydir.iterdir() if p.is_dir()):
221
+ mes = month_from_folder(mdir.name)
222
+ # rglob: algunos ZIP del DANE traen subcarpetas (p. ej. "CSV 13/No ocupados.CSV")
223
+ files = [f for f in mdir.rglob("*") if f.is_file() and f.suffix.lower() in EXTENSIONES]
224
+ if mes is None or not files:
225
+ continue
226
+ result.append(MesGEIH(anio, mes, mdir, _pick_files(files)))
227
+ # Un mes puede quedar en dos carpetas: la del CSV dañado del ANDA y la del formato que
228
+ # lo reemplaza. Se usa la completa.
229
+ por_mes: dict[tuple[int, int], list[MesGEIH]] = {}
230
+ for m in result:
231
+ por_mes.setdefault((m.anio, m.mes), []).append(m)
232
+ result = []
233
+ for ms in por_mes.values():
234
+ completos = [m for m in ms if m.completo] if len(ms) > 1 else []
235
+ result += completos or ms
236
+ return sorted(result, key=lambda m: (m.anio, m.mes))
237
+
238
+
239
+ def available(empalme: bool = False, root=None) -> pd.DataFrame:
240
+ """Tabla de años/meses descargados y sus módulos."""
241
+ rows = [
242
+ {"anio": m.anio, "mes": m.mes, "nombre_mes": MESES[m.mes], "carpeta": m.carpeta.name,
243
+ "modulos": ", ".join(m.modulos), "completo": m.completo, "faltantes": ", ".join(m.faltantes)}
244
+ for m in find_months(empalme=empalme, root=root)
245
+ ]
246
+ return pd.DataFrame(rows, columns=["anio", "mes", "nombre_mes", "carpeta", "modulos", "completo", "faltantes"])
247
+
248
+
249
+ # --------------------------------------------------------------------------- #
250
+ # Lectura de un mes
251
+ # --------------------------------------------------------------------------- #
252
+ def _merge_new_columns(base: pd.DataFrame, other: pd.DataFrame, keys: list[str]) -> pd.DataFrame:
253
+ keys = [k for k in keys if k in base.columns and k in other.columns]
254
+ extra = [c for c in other.columns if c not in base.columns]
255
+ # El factor de expansión es el de características. Otros módulos pueden traer uno
256
+ # distinto con otro nombre (febrero de 2011: FEX_C en "Otras actividades" junto a
257
+ # FEX_C_2011), que harmonize preferiría.
258
+ if any(c.startswith("FEX") for c in base.columns):
259
+ extra = [c for c in extra if not c.startswith("FEX")]
260
+ if not keys or not extra:
261
+ return base
262
+ other = other[keys + extra].drop_duplicates(subset=keys)
263
+ for k in keys: # tipos compatibles para el merge
264
+ if base[k].dtype != other[k].dtype:
265
+ base[k] = pd.to_numeric(base[k], errors="coerce")
266
+ other[k] = pd.to_numeric(other[k], errors="coerce")
267
+ return base.merge(other, on=keys, how="left")
268
+
269
+
270
+ def _markers(path: Path) -> dict[str, int]:
271
+ """
272
+ Marcas de pertenencia a un módulo. En años antiguos (p. ej. 2007) no existen
273
+ las variables OCI/DSI/INI: estar en el archivo de Ocupados es ser ocupado.
274
+ """
275
+ n = strip_accents(path.name)
276
+ if "fuerza de trabajo" in n:
277
+ return {"_EN_FT": 1}
278
+ if _RE_DESOCU.search(n):
279
+ return {"_EN_DSI": 1}
280
+ if "inactivos" in n:
281
+ return {"_EN_INI": 1}
282
+ if _RE_OCU.search(n) and not _RE_NO_OCU.search(n):
283
+ return {"_EN_OCU": 1}
284
+ return {}
285
+
286
+
287
+ def _read_module(files: list[Path], columns) -> pd.DataFrame:
288
+ frames = []
289
+ for f in files:
290
+ df = read_table(f, columns=columns)
291
+ if len(df.columns):
292
+ frames.append(df.assign(**_markers(f)))
293
+ if not frames:
294
+ return pd.DataFrame()
295
+ return pd.concat(frames, ignore_index=True, sort=False) if len(frames) > 1 else frames[0]
296
+
297
+
298
+ def load_month(mes: MesGEIH, columns="core", modules: Iterable[str] | None = None) -> pd.DataFrame:
299
+ """
300
+ Fusiona los módulos de un mes a nivel persona (llave DIRECTORIO, SECUENCIA_P, ORDEN).
301
+
302
+ ``columns``: ``"core"`` (variables para armonizar), ``"all"`` (todas) o una
303
+ lista de variables DANE adicionales a las ``core``.
304
+ """
305
+ if columns == "all":
306
+ selector = None
307
+ else:
308
+ wanted = set(CORE_RAW) | ({c.upper() for c in columns} if columns not in (None, "core") else set())
309
+ selector = wanted.__contains__
310
+ modules = set(modules or MODULOS)
311
+
312
+ if "caracteristicas" not in mes.archivos:
313
+ raise FileNotFoundError(f"{mes.carpeta}: falta el módulo de características generales")
314
+ base = _read_module(mes.archivos["caracteristicas"], selector)
315
+
316
+ for modulo in ("fuerza", "ocupados", "no_ocupados", "otras"):
317
+ if modulo not in mes.archivos or modulo not in modules:
318
+ continue
319
+ df = _read_module(mes.archivos[modulo], selector)
320
+ base = _merge_new_columns(base, df, KEYS_PERSONA)
321
+
322
+ if "vivienda" in mes.archivos and "vivienda" in modules:
323
+ df = _read_module(mes.archivos["vivienda"], selector)
324
+ base = _merge_new_columns(base, df, KEYS_HOGAR)
325
+
326
+ ids = pd.DataFrame({"anio": mes.anio, "mes": mes.mes}, index=base.index)
327
+ return pd.concat([ids, base], axis=1)
328
+
329
+
330
+ # --------------------------------------------------------------------------- #
331
+ # Armonización
332
+ # --------------------------------------------------------------------------- #
333
+ def _num(df: pd.DataFrame, col: str) -> pd.Series:
334
+ if col in df.columns:
335
+ return pd.to_numeric(df[col], errors="coerce").astype("float64")
336
+ return pd.Series(np.nan, index=df.index)
337
+
338
+
339
+ def _first(df: pd.DataFrame, cols: list[str]) -> pd.Series:
340
+ out = pd.Series(np.nan, index=df.index)
341
+ for c in cols:
342
+ if c in df.columns:
343
+ out = out.fillna(pd.to_numeric(df[c], errors="coerce").astype("float64"))
344
+ return out
345
+
346
+
347
+ _EDUC_P3042 = {1: "Ninguno", 2: "Ninguno", 3: "Primaria", 4: "Secundaria", 5: "Media", 6: "Media",
348
+ 7: "Media", 8: "Superior", 9: "Superior", 10: "Superior", 11: "Superior",
349
+ 12: "Superior", 13: "Superior"}
350
+ _EDUC_P6210 = {1: "Ninguno", 2: "Ninguno", 3: "Primaria", 4: "Secundaria", 5: "Media", 6: "Superior"}
351
+ NIVELES_EDUC = ["Ninguno", "Primaria", "Secundaria", "Media", "Superior"]
352
+ CONDICIONES = ["Ocupado", "Desocupado", "Inactivo", "Fuera de la PET"]
353
+ GRUPOS_EDAD = ["0–14", "15–24", "25–34", "35–44", "45–54", "55–64", "65+"]
354
+ POSICIONES = ["Empleado particular", "Empleado del gobierno", "Empleado doméstico", "Cuenta propia",
355
+ "Patrón o empleador", "Familiar sin remuneración", "Sin remuneración (otros hogares)",
356
+ "Jornalero o peón", "Otro"]
357
+ # Categorías fijas: los meses se armonizan por separado y, al concatenarlos, solo
358
+ # conservan el tipo categórico si todos tienen exactamente las mismas categorías.
359
+ CATEGORIAS = {
360
+ "sexo": pd.CategoricalDtype(["Hombre", "Mujer"]),
361
+ "zona": pd.CategoricalDtype(["Urbano (cabecera)", "Rural (centros poblados y disperso)"]),
362
+ "grupo_edad": pd.CategoricalDtype(GRUPOS_EDAD, ordered=True),
363
+ "nivel_educativo": pd.CategoricalDtype(NIVELES_EDUC, ordered=True),
364
+ "departamento": pd.CategoricalDtype(sorted(set(DEPARTAMENTOS.values()))),
365
+ "condicion": pd.CategoricalDtype(CONDICIONES),
366
+ "posicion_ocupacional": pd.CategoricalDtype(POSICIONES),
367
+ }
368
+ MARCOS = {
369
+ "censo 2005": "proyecciones de población del censo 2005 y PET de 12 años o más (10 en la zona rural)",
370
+ "CNPV 2018": "proyecciones de población del CNPV 2018 y PET de 15 años o más",
371
+ }
372
+
373
+
374
+ def marco_muestral(anio: int, empalme: bool = False) -> str:
375
+ """
376
+ Marco de los factores de expansión de un año en el ANDA (clave de ``MARCOS``). Los niveles y las
377
+ tasas no son comparables entre marcos. En la serie principal, 2020 ya viene con el CNPV 2018 pero
378
+ 2021 todavía con el censo 2005; desde 2022, con el CNPV 2018. La serie de empalme lleva 2010–2020
379
+ al CNPV 2018.
380
+ """
381
+ return "CNPV 2018" if empalme or anio == 2020 or anio >= 2022 else "censo 2005"
382
+
383
+
384
+ def harmonize(df: pd.DataFrame, edad_minima_pet: int | None = None) -> pd.DataFrame:
385
+ """
386
+ Agrega variables armonizadas (minúscula) comparables entre años.
387
+
388
+ ``edad_minima_pet``: si se indica, quien tenga menos años queda fuera de la PET
389
+ (y de la fuerza de trabajo) aunque el archivo del DANE lo marque dentro.
390
+ """
391
+ df = df.copy()
392
+ anio = df["anio"]
393
+
394
+ df["fex"] = _first(df, ["FEX_C18", "FEX_C", "FEX_C_2011"])
395
+ sexo = _num(df, "P3271").fillna(_num(df, "P6020"))
396
+ df["sexo"] = pd.Categorical(sexo.map({1: "Hombre", 2: "Mujer"}), dtype=CATEGORIAS["sexo"])
397
+ df["edad"] = _num(df, "P6040")
398
+ df["grupo_edad"] = pd.cut(df["edad"], bins=[-1, 14, 24, 34, 44, 54, 64, 200], labels=GRUPOS_EDAD)
399
+ df["jefe_hogar"] = (_num(df, "P6050") == 1).astype("Int8")
400
+
401
+ dpto = _num(df, "DPTO")
402
+ df["dpto"] = dpto.astype("Int64")
403
+ df["departamento"] = pd.Categorical(dpto.map(DEPARTAMENTOS), dtype=CATEGORIAS["departamento"])
404
+ clase = _num(df, "CLASE")
405
+ df["zona"] = pd.Categorical(clase.map({1: "Urbano (cabecera)", 2: "Rural (centros poblados y disperso)"}),
406
+ dtype=CATEGORIAS["zona"])
407
+
408
+ # Condición de actividad
409
+ ocupado = (_num(df, "OCI") == 1) | (_num(df, "_EN_OCU") == 1)
410
+ desocupado = ((_num(df, "DSI") == 1) | (_num(df, "_EN_DSI") == 1)) & ~ocupado
411
+ pet = ((_num(df, "PET") == 1) | (_num(df, "PETI") == 1) | (_num(df, "INI") == 1)
412
+ | (_num(df, "_EN_FT") == 1) | (_num(df, "_EN_INI") == 1)
413
+ | ocupado | desocupado)
414
+ if edad_minima_pet is not None:
415
+ en_edad = df["edad"] >= edad_minima_pet
416
+ ocupado, desocupado, pet = ocupado & en_edad, desocupado & en_edad, pet & en_edad
417
+ inactivo = pet & ~ocupado & ~desocupado
418
+ cond = np.select([ocupado, desocupado, inactivo], CONDICIONES[:3], default=CONDICIONES[3])
419
+ df["condicion"] = pd.Categorical(cond, dtype=CATEGORIAS["condicion"])
420
+ df["pet"] = pet.astype("Int8")
421
+ df["fuerza_trabajo"] = (ocupado | desocupado).astype("Int8")
422
+ df["ocupado"] = ocupado.astype("Int8")
423
+ df["desocupado"] = desocupado.astype("Int8")
424
+ df["inactivo"] = inactivo.astype("Int8")
425
+
426
+ # Formalidad (definición por seguridad social): ocupado afiliado a salud
427
+ # (P6090 = 1) y cotizando a pensión (P6920 = 1). Solo para ocupados.
428
+ cotiza = _num(df, "P6920") == 1
429
+ salud = _num(df, "P6090") == 1
430
+ formal = pd.Series(np.where(cotiza & salud, 1, 0), index=df.index).astype("Int8")
431
+ df["formal"] = formal.where(ocupado)
432
+ df["cotiza_pension"] = cotiza.astype("Int8").where(ocupado)
433
+
434
+ ing = _num(df, "INGLABO")
435
+ df["ingreso_laboral"] = ing.where(ocupado)
436
+ df["smlv"] = anio.map(SMLV)
437
+ df["ratio_smlv"] = df["ingreso_laboral"] / df["smlv"]
438
+ df["horas_semana"] = _num(df, "P6800").where(ocupado)
439
+
440
+ pos = _num(df, "P6430")
441
+ pos_map = dict(enumerate(POSICIONES, start=1))
442
+ # Cuestionario de 2022 en adelante: 7 = jornalero y 8 = otro (desaparece el 9). El DDI
443
+ # del DANE para 2022–2025 repite las categorías antiguas, pero en los datos el 7 es
444
+ # 100 % agropecuario y con ingreso. Fila a fila: se pueden juntar años de ambas épocas.
445
+ pos_map_2022 = {**pos_map, 7: "Jornalero o peón", 8: "Otro"}
446
+ del pos_map_2022[9]
447
+ etiqueta = pos.map(pos_map).where(anio < 2022, pos.map(pos_map_2022))
448
+ df["posicion_ocupacional"] = pd.Categorical(etiqueta.where(ocupado), dtype=CATEGORIAS["posicion_ocupacional"])
449
+
450
+ # Fila a fila: al juntar años distintos conviven ambas versiones.
451
+ r4, r3 = _num(df, "RAMA2D_R4"), _num(df, "RAMA2D")
452
+ df["rama2d"] = r4.fillna(r3).astype("Int64")
453
+ df["rama_rev"] = pd.Series(np.where(r4.notna(), 4, np.where(r3.notna(), 3, np.nan)), index=df.index).astype("Int8")
454
+
455
+ educ = _num(df, "P3042").map(_EDUC_P3042).fillna(_num(df, "P6210").map(_EDUC_P6210))
456
+ df["nivel_educativo"] = pd.Categorical(educ, dtype=CATEGORIAS["nivel_educativo"])
457
+
458
+ return df.drop(columns=["_EN_FT", "_EN_OCU", "_EN_DSI", "_EN_INI"], errors="ignore")
459
+
460
+
461
+ # --------------------------------------------------------------------------- #
462
+ # API pública
463
+ # --------------------------------------------------------------------------- #
464
+ def _firma_archivos(mes: MesGEIH) -> list[str]:
465
+ """Ruta, tamaño y fecha de modificación de cada archivo del mes (para las llaves de caché)."""
466
+ stamp = []
467
+ for files in mes.archivos.values():
468
+ for f in files:
469
+ st = f.stat()
470
+ stamp.append(f"{f}:{st.st_size}:{int(st.st_mtime)}")
471
+ return sorted(stamp)
472
+
473
+
474
+ def _cache_path(mes: MesGEIH, columns, modules) -> Path:
475
+ cols = "all" if columns == "all" else ",".join(sorted({c.upper() for c in (columns if columns not in (None, "core") else [])}))
476
+ key = "|".join([_HARMONIZE_VERSION, *_firma_archivos(mes), cols, ",".join(sorted(modules or MODULOS))])
477
+ digest = hashlib.sha1(key.encode()).hexdigest()[:16]
478
+ return config.CACHE_DIR / "geih" / f"{mes.anio}_{mes.mes:02d}_{digest}.parquet"
479
+
480
+
481
+ def load(
482
+ years: Iterable[int] | int,
483
+ months: Iterable[int] | int | None = None,
484
+ columns="core",
485
+ empalme: bool = False,
486
+ harmonize_vars: bool = True,
487
+ modules: Iterable[str] | None = None,
488
+ root=None,
489
+ cache: bool = True,
490
+ progress=None,
491
+ incluir_incompletos: bool = False,
492
+ variables: Iterable[str] | None = None,
493
+ ) -> pd.DataFrame:
494
+ """
495
+ Carga la GEIH (una fila por persona) para los años y meses pedidos.
496
+
497
+ Parámetros
498
+ ----------
499
+ years : int o lista de int
500
+ months : int, lista de int (1–12) o None para todos los disponibles.
501
+ columns : ``"core"`` (por defecto), ``"all"`` o lista de variables DANE extra,
502
+ por ejemplo ``["P6500", "P7070"]``.
503
+ empalme : usar la serie de empalme (2010–2020, marco 2018).
504
+ harmonize_vars : agregar variables armonizadas (``sexo``, ``condicion``...).
505
+ modules : subconjunto de ``dane_data.geih.MODULOS`` a fusionar.
506
+ cache : guarda cada mes procesado en ``~/.cache/dane_data`` (parquet) para
507
+ que la siguiente carga sea casi instantánea.
508
+ progress : función opcional ``(fraccion, mensaje)``.
509
+ incluir_incompletos : por defecto se omiten (con aviso) los meses a los que
510
+ les falta un módulo clave (p. ej. desocupados): con ellos la tasa de
511
+ desocupación saldría 0 %. Los meses omitidos quedan en
512
+ ``df.attrs["meses_omitidos"]``.
513
+ variables : conservar solo estas variables (armonizadas o del DANE; ``anio`` y
514
+ ``mes`` siempre). Cada mes se recorta al leerlo, así que la memoria depende
515
+ solo de lo que se conserva: útil para juntar muchos años. Las variables DANE
516
+ que no están en ``CORE_RAW`` deben pedirse también en ``columns``.
517
+
518
+ Ejemplo
519
+ -------
520
+ >>> df = dd.geih.load(2025, months=[1, 2, 3])
521
+ >>> dd.geih.indicadores(df, por="sexo")
522
+ >>> df = dd.geih.load(range(2007, 2026), variables=["fex", "sexo", "ocupado", "ingreso_laboral"])
523
+ """
524
+ if isinstance(months, int):
525
+ months = [months]
526
+ conservar = None
527
+ if variables is not None:
528
+ conservar = list(dict.fromkeys(["anio", "mes", *([variables] if isinstance(variables, str) else variables)]))
529
+ meses = find_months(years, empalme=empalme, root=root)
530
+ if months is not None:
531
+ months = {int(m) for m in months}
532
+ meses = [m for m in meses if m.mes in months]
533
+ omitidos = [m for m in meses if not m.completo]
534
+ if omitidos and not incluir_incompletos:
535
+ detalle = "; ".join(f"{MESES[m.mes]} {m.anio} (falta {', '.join(m.faltantes)})" for m in omitidos)
536
+ if any("CSV del DANE" in f for m in omitidos for f in m.faltantes):
537
+ detalle += (". Vuelva a descargar esos años con dane_data.geih.download(...): para esos meses "
538
+ "se baja otro formato del ANDA")
539
+ log.warning(f"Se omiten meses incompletos: {detalle}. Use incluir_incompletos=True para cargarlos.")
540
+ meses = [m for m in meses if m.completo]
541
+ if not meses:
542
+ raise FileNotFoundError(
543
+ f"No hay datos GEIH para años={years}, meses={months} en {_root(empalme, root)}. "
544
+ "Descárguelos con dane_data.geih.download(...) o revise dane_data.set_paths(...)."
545
+ )
546
+
547
+ frames = []
548
+ for i, mes in enumerate(meses):
549
+ if progress:
550
+ progress(i / len(meses), f"Leyendo {MESES[mes.mes]} {mes.anio}")
551
+ cpath = _cache_path(mes, columns, modules) if cache else None
552
+ df = None
553
+ if cpath is not None and cpath.exists():
554
+ try:
555
+ df = pd.read_parquet(cpath)
556
+ except Exception: # caché corrupta: se regenera
557
+ df = None
558
+ if df is None:
559
+ try:
560
+ df = load_month(mes, columns=columns, modules=modules)
561
+ except FileNotFoundError as e:
562
+ log.warning(str(e))
563
+ continue
564
+ if cpath is not None:
565
+ try:
566
+ cpath.parent.mkdir(parents=True, exist_ok=True)
567
+ df.to_parquet(cpath, index=False)
568
+ except Exception as e: # columnas de tipo mixto, disco lleno, etc.
569
+ log.debug(f"No se pudo guardar caché {cpath}: {e}")
570
+ # Mes a mes (harmonize es fila a fila): evita armonizar y copiar el frame
571
+ # concatenado de muchos años, que no cabe en memoria.
572
+ if harmonize_vars:
573
+ # El DANE publica el empalme con la PET de 15 años o más, pero el archivo de
574
+ # octubre de 2012 (catálogo 760) la marca con la definición anterior (12+ urbano,
575
+ # 10+ rural). Ver README, "Diferencias con la documentación del DANE".
576
+ df = harmonize(df, edad_minima_pet=15 if empalme else None)
577
+ if conservar is not None:
578
+ df = df[[c for c in conservar if c in df.columns]].copy() # sin copia, la vista retiene el mes entero
579
+ frames.append(df)
580
+ if progress:
581
+ progress(1.0, "Listo")
582
+ if not frames:
583
+ raise FileNotFoundError("Ningún mes pudo leerse (faltan módulos de características).")
584
+
585
+ out = pd.concat(frames, ignore_index=True, sort=False)
586
+ if conservar is not None:
587
+ if faltan := [c for c in conservar if c not in out.columns]:
588
+ log.warning(f"Variables que no existen en los datos cargados: {', '.join(faltan)}.")
589
+ out = out[[c for c in conservar if c in out.columns]]
590
+ out.attrs["meses_omitidos"] = [] if incluir_incompletos else [(m.anio, m.mes, m.faltantes) for m in omitidos]
591
+ return out
592
+
593
+
594
+ def clear_cache() -> int:
595
+ """Borra la caché de meses procesados y de indicadores precalculados. Devuelve el número de archivos borrados."""
596
+ n = 0
597
+ for f in (config.CACHE_DIR / "geih").rglob("*.parquet"):
598
+ f.unlink()
599
+ n += 1
600
+ return n