"""
Carga y armonización de microdatos GEIH ya descargados.
La GEIH cambia de estructura según la época:
* 2007–2021: cada módulo viene partido por dominio (``Area``, ``Cabecera``,
``Resto``). ``Area`` (13/23 ciudades) es un subconjunto de ``Cabecera``, así
que el total nacional es ``Cabecera + Resto``. Desocupados e inactivos
vienen en módulos separados. Factor de expansión ``FEX_C_2011`` (coma decimal).
* 2020 y serie de empalme: un archivo por módulo, factor ``FEX_C``.
* 2022 en adelante (Marco 2018): módulo único "No ocupados" (desocupados +
fuera de la fuerza de trabajo), factor ``FEX_C18``, sexo en ``P3271``.
``load`` oculta todo eso y entrega una persona por fila con variables
armonizadas en minúscula (``anio``, ``mes``, ``fex``, ``sexo``, ``edad``,
``condicion``, ``formal``, ``ingreso_laboral``, ...). Las variables originales
del DANE se conservan en MAYÚSCULA.
"""
from __future__ import annotations
import hashlib
import logging
import re
import uuid
from dataclasses import dataclass, field
from functools import lru_cache
from pathlib import Path
from typing import Iterable
import numpy as np
import pandas as pd
from .. import config
from .._io import columnas, normalize_columns, read_table, sniff
from ..referencias import DEPARTAMENTOS, MESES, SMLV, strip_accents
log = logging.getLogger("dane_data.geih")
KEYS_PERSONA = ["DIRECTORIO", "SECUENCIA_P", "ORDEN"]
KEYS_HOGAR = ["DIRECTORIO", "SECUENCIA_P"]
# Variables originales que se leen siempre (si existen) para poder armonizar.
CORE_RAW = [
*KEYS_PERSONA, "HOGAR", "MES", "PERIODO", "DPTO", "AREA", "CLASE",
"FEX_C18", "FEX_C", "FEX_C_2011",
"P6020", "P3271", "P6040", "P6050",
"OCI", "DSI", "INI", "FFT", "FT", "PET", "PETI",
"INGLABO", "P6090", "P6920", "P6430", "P6800",
"RAMA2D", "RAMA2D_R4", "P6210", "P3042",
]
MODULOS = ("caracteristicas", "fuerza", "ocupados", "no_ocupados", "otras", "vivienda")
_MESES_PREFIJO = {
"ene": 1, "feb": 2, "mar": 3, "abr": 4, "may": 5, "jun": 6, "jul": 7,
"ago": 8, "sep": 9, "set": 9, "oct": 10, "nov": 11, "dic": 12,
}
# Nombres de módulo con plural opcional: el DANE publica "No ocupado.CSV" (marzo 2024),
# "No ocupados.CSV", "Desocupados", la errata "Deocupados", "Ocupados04.csv" (2013), etc.
_RE_NO_OCU = re.compile(r"\bno ocupados?(?![a-z])")
_RE_DESOCU = re.compile(r"\bd(?:e|es)ocupados?(?![a-z])")
_RE_INACT = re.compile(r"\binactivos?(?![a-z])")
_RE_OCU = re.compile(r"\bocupados?(?![a-z])")
_HARMONIZE_VERSION = "7"
# Formatos de los microdatos. Se baja el CSV salvo donde el del ANDA está dañado (ver
# CSV_DANADOS en el descargador); dentro de una carpeta se prefiere el formato alterno.
EXTENSIONES = {".dta": 0, ".txt": 1, ".csv": 2}
_RE_MILES = re.compile(r"^\d{1,3}(\.\d{3}){2,}$")
# --------------------------------------------------------------------------- #
# Descubrimiento de archivos
# --------------------------------------------------------------------------- #
def classify_file(filename: str) -> tuple[str | None, str | None]:
"""Devuelve (módulo, dominio) a partir del nombre de archivo del DANE."""
n = strip_accents(Path(filename).name)
dominio = None
for d in ("area", "cabecera", "resto"):
if re.match(rf"^{d}\b", n):
dominio = d
if _RE_NO_OCU.search(n) or _RE_DESOCU.search(n) or _RE_INACT.search(n):
modulo = "no_ocupados"
elif _RE_OCU.search(n):
modulo = "ocupados"
elif "fuerza de trabajo" in n:
modulo = "fuerza"
elif "caracter" in n or "personas" in n:
modulo = "caracteristicas"
elif "hogar" in n or "vivienda" in n:
modulo = "vivienda"
elif "otras" in n:
modulo = "otras"
else:
modulo = None
return modulo, dominio
def _sello(path: Path) -> tuple[int, int]:
"""Fecha y tamaño del archivo: parte de la clave de las cachés de abajo, para que un archivo
reemplazado en la misma ruta (con la app abierta) se vuelva a revisar."""
try:
st = path.stat()
except OSError:
return (0, 0)
return (st.st_mtime_ns, st.st_size)
def _header(path: Path) -> frozenset[str]:
return _header_sellado(path, _sello(path))
def _defecto(path: Path) -> str | None:
return _defecto_sellado(path, _sello(path))
@lru_cache(maxsize=4096)
def _header_sellado(path: Path, sello: tuple[int, int]) -> frozenset[str]:
return frozenset(columnas(path))
@lru_cache(maxsize=4096)
def _defecto_sellado(path: Path, sello: tuple[int, int]) -> str | None:
"""
Defectos conocidos de los CSV del ANDA que hacen inservible un archivo (ver README,
"Diferencias con la documentación del DANE"). En septiembre de 2021 el CSV viene
separado por espacios, y el texto libre también los tiene. En 2008 y 2011 el factor
de expansión perdió el punto decimal ("849.491.032.387.525" es 84,9491032387525).
"""
if path.suffix.lower() == ".dta":
return None
sep, enc = sniff(path)
with open(path, encoding=enc, errors="replace") as fh:
encabezado = fh.readline().rstrip("\r\n").split(sep)
filas = [fh.readline().rstrip("\r\n").split(sep) for _ in range(20)]
if len(encabezado) == 1:
return "un archivo legible (el CSV del DANE viene separado por espacios)"
fex = [i for i, c in enumerate(normalize_columns(encabezado)) if c.startswith("FEX")]
valores = [f[i].strip() for f in filas for i in fex if len(f) > i and f[i].strip()]
if valores and sum(bool(_RE_MILES.match(v)) for v in valores) > len(valores) / 2:
return "un factor de expansión legible (el CSV del DANE lo trae sin punto decimal)"
return None
def month_from_folder(name: str) -> int | None:
for word in re.findall(r"[a-z]+", strip_accents(name)):
if word[:3] in _MESES_PREFIJO and not word.startswith("marco"):
return _MESES_PREFIJO[word[:3]]
return None
@dataclass
class MesGEIH:
anio: int
mes: int
carpeta: Path
archivos: dict[str, list[Path]] = field(default_factory=dict)
@property
def modulos(self) -> list[str]:
return sorted(self.archivos)
@property
def faltantes(self) -> list[str]:
"""
Información necesaria para los indicadores laborales que no está en disco.
En 2020 y en la serie de empalme las marcas OCI/DSI/INI vienen en el
módulo de características, así que se revisan los encabezados.
"""
if "caracteristicas" not in self.archivos:
return ["caracteristicas"]
if danos := sorted({d for f in self.archivos["caracteristicas"] if (d := _defecto(f))}):
return danos
cols = set().union(*(_header(f) for f in self.archivos["caracteristicas"]))
nombres = [strip_accents(f.name) for f in self.archivos.get("no_ocupados", [])]
falta = []
if "ocupados" not in self.archivos and "OCI" not in cols:
falta.append("ocupados")
if not any(_RE_NO_OCU.search(n) or _RE_DESOCU.search(n) for n in nombres) and "DSI" not in cols:
falta.append("desocupados")
if "fuerza" not in self.archivos and not cols & {"PET", "PETI", "INI"}:
falta.append("fuerza")
return falta
@property
def completo(self) -> bool:
return not self.faltantes
def _root(empalme: bool, root) -> Path:
if root is not None:
return Path(root).expanduser()
return config.get_path("geih_empalme" if empalme else "geih")
def _csv_dir(root: Path) -> Path:
return root / "datos_csv" if (root / "datos_csv").is_dir() else root
def _pick_files(files: list[Path]) -> dict[str, list[Path]]:
"""Agrupa por módulo y elige el dominio nacional (Cabecera + Resto)."""
grouped: dict[str, dict[str | None, list[Path]]] = {}
for f in files:
modulo, dominio = classify_file(f.name)
if modulo:
grouped.setdefault(modulo, {}).setdefault(dominio, []).append(f)
out = {}
for modulo, by_dom in grouped.items():
if "cabecera" in by_dom or "resto" in by_dom:
chosen = by_dom.get("cabecera", []) + by_dom.get("resto", [])
elif None in by_dom:
chosen = by_dom[None]
else:
chosen = by_dom["area"]
# Desocupados + Inactivos (antes de 2022) son dos archivos del mismo
# módulo lógico; se conservan ambos. Duplicados exactos de nombre no.
seen, uniq = set(), []
# Archivos del nivel superior primero; las copias en subcarpetas se ignoran.
for f in sorted(chosen, key=lambda p: (len(p.parts), EXTENSIONES.get(p.suffix.lower(), 9), str(p))):
key = re.sub(r"\s*\(\d+\)", "", f.stem.lower())
if key not in seen:
seen.add(key)
uniq.append(f)
out[modulo] = uniq
return out
[documentos]
def find_months(years: Iterable[int] | int | None = None, empalme: bool = False, root=None) -> list[MesGEIH]:
"""
Meses de la GEIH que hay en disco.
Recorre ``<carpeta>/datos_csv/<año>/<mes>/`` y reconoce el mes aunque la carpeta tenga el nombre
que trae el ZIP del DANE (``Enero 2024``, ``ENE_2021``, ``CSV 13``...). Busca los archivos en
subcarpetas y, de cada módulo, elige el total nacional (Cabecera + Resto).
Parámetros
----------
years
Año o lista de años. ``None`` para todos.
empalme
Buscar en la carpeta de la serie de empalme.
root
Carpeta raíz. Por defecto, la configurada (``dd.get_path``).
Devuelve
--------
list of MesGEIH
Uno por mes, ordenados. Cada uno tiene ``anio``, ``mes``, ``carpeta``, ``archivos``
(``{módulo: [rutas]}``), ``modulos``, ``faltantes`` (lo que falta para los indicadores
laborales) y ``completo``.
Véase también
-------------
available : Lo mismo como tabla.
load_month : Leer un mes.
Ejemplos
--------
>>> import dane_data as dd
>>> meses = dd.geih.find_months(2024) # doctest: +SKIP
>>> meses[0].anio, meses[0].mes, meses[0].completo # doctest: +SKIP
(2024, 1, True)
>>> meses[0].modulos # doctest: +SKIP
['caracteristicas', 'fuerza', 'no_ocupados', 'ocupados', 'otras', 'vivienda']
"""
base = _csv_dir(_root(empalme, root))
if not base.is_dir():
return []
if years is not None:
years = {int(y) for y in ([years] if isinstance(years, (int, str)) else years)}
result = []
for ydir in sorted(p for p in base.iterdir() if p.is_dir() and p.name.isdigit()):
anio = int(ydir.name)
if years is not None and anio not in years:
continue
for mdir in sorted(p for p in ydir.iterdir() if p.is_dir()):
mes = month_from_folder(mdir.name)
# rglob: algunos ZIP del DANE traen subcarpetas (p. ej. "CSV 13/No ocupados.CSV")
files = [f for f in mdir.rglob("*") if f.is_file() and f.suffix.lower() in EXTENSIONES]
if mes is None or not files:
continue
result.append(MesGEIH(anio, mes, mdir, _pick_files(files)))
# Un mes puede quedar en dos carpetas: la del CSV dañado del ANDA y la del formato que
# lo reemplaza. Se usa una sola (sumar las dos duplicaría el mes): la completa, la que
# trae más módulos y, si empatan, la más reciente.
por_mes: dict[tuple[int, int], list[MesGEIH]] = {}
for m in result:
por_mes.setdefault((m.anio, m.mes), []).append(m)
result = []
for (anio, mes), ms in por_mes.items():
if len(ms) > 1:
ms = sorted(ms, key=lambda m: (m.completo, len(m.archivos), _mtime(m)), reverse=True)
if ms[1].completo: # el caso esperado (una dañada y su reemplazo) no merece aviso
log.warning(f"{MESES[mes]} {anio} está completo en varias carpetas; se usa "
f"{ms[0].carpeta.name!r} y se ignora "
f"{', '.join(repr(m.carpeta.name) for m in ms[1:] if m.completo)}.")
result.append(ms[0])
return sorted(result, key=lambda m: (m.anio, m.mes))
def _mtime(mes: MesGEIH) -> float:
"""Fecha del archivo más reciente del mes (0 si no tiene archivos reconocidos o desaparecieron)."""
fechas = []
for f in (f for fs in mes.archivos.values() for f in fs):
try:
fechas.append(f.stat().st_mtime)
except OSError:
pass
return max(fechas, default=0.0)
[documentos]
def available(empalme: bool = False, root=None) -> pd.DataFrame:
"""
Tabla de los meses de la GEIH descargados.
Parámetros
----------
empalme
Revisar la serie de empalme en vez de la principal.
root
Carpeta raíz. Por defecto, la configurada (``dd.get_path``).
Devuelve
--------
DataFrame
Una fila por mes con ``anio``, ``mes``, ``nombre_mes``, ``carpeta``, ``modulos``, ``completo`` y
``faltantes``. ``load`` omite los meses con ``completo=False``.
Véase también
-------------
download : Descargar lo que falta.
find_months : Los mismos meses como objetos.
Ejemplos
--------
>>> import dane_data as dd
>>> av = dd.geih.available() # doctest: +SKIP
>>> av.groupby("anio")["completo"].sum().tail(3) # doctest: +SKIP
anio
2024 12
2025 12
2026 8
Name: completo, dtype: int64
"""
rows = [
{"anio": m.anio, "mes": m.mes, "nombre_mes": MESES[m.mes], "carpeta": m.carpeta.name,
"modulos": ", ".join(m.modulos), "completo": m.completo, "faltantes": ", ".join(m.faltantes)}
for m in find_months(empalme=empalme, root=root)
]
return pd.DataFrame(rows, columns=["anio", "mes", "nombre_mes", "carpeta", "modulos", "completo", "faltantes"])
# --------------------------------------------------------------------------- #
# Lectura de un mes
# --------------------------------------------------------------------------- #
def _merge_new_columns(base: pd.DataFrame, other: pd.DataFrame, keys: list[str]) -> pd.DataFrame:
keys = [k for k in keys if k in base.columns and k in other.columns]
extra = [c for c in other.columns if c not in base.columns]
# El factor de expansión es el de características. Otros módulos pueden traer uno
# distinto con otro nombre (febrero de 2011: FEX_C en "Otras actividades" junto a
# FEX_C_2011), que harmonize preferiría.
if any(c.startswith("FEX") for c in base.columns):
extra = [c for c in extra if not c.startswith("FEX")]
if not keys or not extra:
return base
other = other[keys + extra].drop_duplicates(subset=keys)
for k in keys: # tipos compatibles para el merge
if base[k].dtype != other[k].dtype:
base[k] = pd.to_numeric(base[k], errors="coerce")
other[k] = pd.to_numeric(other[k], errors="coerce")
return base.merge(other, on=keys, how="left")
def _markers(path: Path) -> dict[str, int]:
"""
Marcas de pertenencia a un módulo. En años antiguos (p. ej. 2007) no existen
las variables OCI/DSI/INI: estar en el archivo de Ocupados es ser ocupado.
"""
n = strip_accents(path.name)
if "fuerza de trabajo" in n:
return {"_EN_FT": 1}
if _RE_DESOCU.search(n):
return {"_EN_DSI": 1}
if "inactivos" in n:
return {"_EN_INI": 1}
if _RE_OCU.search(n) and not _RE_NO_OCU.search(n):
return {"_EN_OCU": 1}
return {}
def _read_module(files: list[Path], columns) -> pd.DataFrame:
frames = []
for f in files:
df = read_table(f, columns=columns)
if len(df.columns):
frames.append(df.assign(**_markers(f)))
if not frames:
return pd.DataFrame()
return pd.concat(frames, ignore_index=True, sort=False) if len(frames) > 1 else frames[0]
[documentos]
def load_month(mes: MesGEIH, columns="core", modules: Iterable[str] | None = None) -> pd.DataFrame:
"""
Une los módulos de un mes en una tabla de personas, sin armonizar.
Parte del módulo de características generales (una fila por persona) y le agrega las columnas
nuevas de los demás módulos con la llave de persona (``DIRECTORIO``, ``SECUENCIA_P``, ``ORDEN``), o
la de hogar (``DIRECTORIO``, ``SECUENCIA_P``) para vivienda. Es el primer paso de ``load``, que
además guarda el resultado en caché y lo armoniza.
Parámetros
----------
mes
Un mes de ``find_months``.
columns
``"core"`` (las de ``CORE_RAW``), ``"all"`` (todas) o una lista de variables DANE que se suman a
las de ``CORE_RAW``.
modules
Módulos a unir (de ``MODULOS``). Por defecto, todos los que haya.
Devuelve
--------
DataFrame
Una fila por persona, con ``anio``, ``mes`` y las variables originales del DANE en mayúscula.
Lanza
-----
FileNotFoundError
Si el mes no tiene el módulo de características generales.
Véase también
-------------
load : Varios meses, con caché y armonizados.
harmonize : Agregar las variables armonizadas.
Ejemplos
--------
>>> import dane_data as dd
>>> mes = dd.geih.find_months(2024)[0] # doctest: +SKIP
>>> crudo = dd.geih.load_month(mes) # doctest: +SKIP
>>> crudo.shape # doctest: +SKIP
(70648, 29)
"""
columns, modules = _lista_columnas(columns), _lista(modules)
if columns == "all":
selector = None
else:
wanted = set(CORE_RAW) | ({c.upper() for c in columns} if columns not in (None, "core") else set())
selector = wanted.__contains__
modules = set(modules or MODULOS)
if "caracteristicas" not in mes.archivos:
raise FileNotFoundError(f"{mes.carpeta}: falta el módulo de características generales")
base = _read_module(mes.archivos["caracteristicas"], selector)
for modulo in ("fuerza", "ocupados", "no_ocupados", "otras"):
if modulo not in mes.archivos or modulo not in modules:
continue
df = _read_module(mes.archivos[modulo], selector)
base = _merge_new_columns(base, df, KEYS_PERSONA)
if "vivienda" in mes.archivos and "vivienda" in modules:
df = _read_module(mes.archivos["vivienda"], selector)
base = _merge_new_columns(base, df, KEYS_HOGAR)
ids = pd.DataFrame({"anio": mes.anio, "mes": mes.mes}, index=base.index)
return pd.concat([ids, base], axis=1)
# --------------------------------------------------------------------------- #
# Armonización
# --------------------------------------------------------------------------- #
def _num(df: pd.DataFrame, col: str) -> pd.Series:
if col in df.columns:
return pd.to_numeric(df[col], errors="coerce").astype("float64")
return pd.Series(np.nan, index=df.index)
def _first(df: pd.DataFrame, cols: list[str]) -> pd.Series:
out = pd.Series(np.nan, index=df.index)
for c in cols:
if c in df.columns:
out = out.fillna(pd.to_numeric(df[c], errors="coerce").astype("float64"))
return out
_EDUC_P3042 = {1: "Ninguno", 2: "Ninguno", 3: "Primaria", 4: "Secundaria", 5: "Media", 6: "Media",
7: "Media", 8: "Superior", 9: "Superior", 10: "Superior", 11: "Superior",
12: "Superior", 13: "Superior"}
_EDUC_P6210 = {1: "Ninguno", 2: "Ninguno", 3: "Primaria", 4: "Secundaria", 5: "Media", 6: "Superior"}
NIVELES_EDUC = ["Ninguno", "Primaria", "Secundaria", "Media", "Superior"]
CONDICIONES = ["Ocupado", "Desocupado", "Inactivo", "Fuera de la PET"]
GRUPOS_EDAD = ["0–14", "15–24", "25–34", "35–44", "45–54", "55–64", "65+"]
POSICIONES = ["Empleado particular", "Empleado del gobierno", "Empleado doméstico", "Cuenta propia",
"Patrón o empleador", "Familiar sin remuneración", "Sin remuneración (otros hogares)",
"Jornalero o peón", "Otro"]
# Categorías fijas: los meses se armonizan por separado y, al concatenarlos, solo
# conservan el tipo categórico si todos tienen exactamente las mismas categorías.
CATEGORIAS = {
"sexo": pd.CategoricalDtype(["Hombre", "Mujer"]),
"zona": pd.CategoricalDtype(["Urbano (cabecera)", "Rural (centros poblados y disperso)"]),
"grupo_edad": pd.CategoricalDtype(GRUPOS_EDAD, ordered=True),
"nivel_educativo": pd.CategoricalDtype(NIVELES_EDUC, ordered=True),
"departamento": pd.CategoricalDtype(sorted(set(DEPARTAMENTOS.values()))),
"condicion": pd.CategoricalDtype(CONDICIONES),
"posicion_ocupacional": pd.CategoricalDtype(POSICIONES),
}
MARCOS = {
"censo 2005": "proyecciones de población del censo 2005 y PET de 12 años o más (10 en la zona rural)",
"CNPV 2018": "proyecciones de población del CNPV 2018 y PET de 15 años o más",
}
[documentos]
def marco_muestral(anio: int, empalme: bool = False) -> str:
"""
Marco muestral de los factores de expansión de un año.
Los niveles y las tasas no son comparables entre marcos: cambian las proyecciones de población y la
edad mínima de la PET. En la serie principal, 2020 ya viene con el CNPV 2018 pero 2021 todavía con
el censo 2005; desde 2022, con el CNPV 2018. La serie de empalme lleva 2010–2020 al CNPV 2018.
Parámetros
----------
anio
Año de la encuesta.
empalme
Si el año es de la serie de empalme.
Devuelve
--------
str
``"censo 2005"`` o ``"CNPV 2018"``, las claves de ``MARCOS``.
Véase también
-------------
MARCOS : Qué significa cada marco.
Ejemplos
--------
>>> import dane_data as dd
>>> dd.geih.marco_muestral(2021)
'censo 2005'
>>> dd.geih.marco_muestral(2022)
'CNPV 2018'
>>> dd.geih.marco_muestral(2015, empalme=True)
'CNPV 2018'
>>> dd.geih.MARCOS["CNPV 2018"]
'proyecciones de población del CNPV 2018 y PET de 15 años o más'
"""
return "CNPV 2018" if empalme or anio == 2020 or anio >= 2022 else "censo 2005"
[documentos]
def harmonize(df: pd.DataFrame, edad_minima_pet: int | None = None) -> pd.DataFrame:
"""
Agrega las variables armonizadas, iguales en todos los años.
Cada variable se construye fila a fila desde la variable del DANE que exista en esa época (por
ejemplo, ``sexo`` desde ``P3271`` o ``P6020``), así que se pueden juntar años distintos. Las
variables armonizadas van en minúscula; las originales, en mayúscula, no se tocan. ``load`` la
aplica a cada mes.
Parámetros
----------
df
Un mes (o varios) de ``load_month``: variables del DANE con ``anio`` y ``mes``.
edad_minima_pet
Si se indica, quien tenga menos años queda fuera de la PET (y de la fuerza de trabajo) aunque el
archivo del DANE lo marque dentro. ``load`` usa 15 para la serie de empalme.
Devuelve
--------
DataFrame
Una copia de ``df`` con ``fex``, ``sexo``, ``edad``, ``grupo_edad``, ``jefe_hogar``, ``dpto``,
``departamento``, ``zona``, ``condicion``, ``pet``, ``fuerza_trabajo``, ``ocupado``,
``desocupado``, ``inactivo``, ``formal``, ``cotiza_pension``, ``ingreso_laboral``, ``smlv``,
``ratio_smlv``, ``horas_semana``, ``posicion_ocupacional``, ``rama2d``, ``rama_rev`` y
``nivel_educativo``.
Véase también
-------------
load : Carga y armoniza.
CATEGORIAS : Categorías de las variables categóricas.
Notas
-----
La guía «Variables armonizadas» explica de qué variable del DANE sale cada una y cómo se define. Las
variables de ocupados (``formal``, ``ingreso_laboral``, ``horas_semana``,
``posicion_ocupacional``) quedan vacías para quien no está ocupado.
Ejemplos
--------
>>> import dane_data as dd
>>> crudo = dd.geih.load_month(dd.geih.find_months(2024)[0]) # doctest: +SKIP
>>> df = dd.geih.harmonize(crudo) # doctest: +SKIP
>>> df["condicion"].value_counts() # doctest: +SKIP
"""
df = df.copy()
anio = df["anio"]
df["fex"] = _first(df, ["FEX_C18", "FEX_C", "FEX_C_2011"])
sexo = _num(df, "P3271").fillna(_num(df, "P6020"))
df["sexo"] = pd.Categorical(sexo.map({1: "Hombre", 2: "Mujer"}), dtype=CATEGORIAS["sexo"])
df["edad"] = _num(df, "P6040")
df["grupo_edad"] = pd.cut(df["edad"], bins=[-1, 14, 24, 34, 44, 54, 64, 200], labels=GRUPOS_EDAD)
df["jefe_hogar"] = (_num(df, "P6050") == 1).astype("Int8")
dpto = _num(df, "DPTO")
df["dpto"] = dpto.astype("Int64")
df["departamento"] = pd.Categorical(dpto.map(DEPARTAMENTOS), dtype=CATEGORIAS["departamento"])
clase = _num(df, "CLASE")
df["zona"] = pd.Categorical(clase.map({1: "Urbano (cabecera)", 2: "Rural (centros poblados y disperso)"}),
dtype=CATEGORIAS["zona"])
# Condición de actividad
ocupado = (_num(df, "OCI") == 1) | (_num(df, "_EN_OCU") == 1)
desocupado = ((_num(df, "DSI") == 1) | (_num(df, "_EN_DSI") == 1)) & ~ocupado
pet = ((_num(df, "PET") == 1) | (_num(df, "PETI") == 1) | (_num(df, "INI") == 1)
| (_num(df, "_EN_FT") == 1) | (_num(df, "_EN_INI") == 1)
| ocupado | desocupado)
if edad_minima_pet is not None:
en_edad = df["edad"] >= edad_minima_pet
ocupado, desocupado, pet = ocupado & en_edad, desocupado & en_edad, pet & en_edad
inactivo = pet & ~ocupado & ~desocupado
cond = np.select([ocupado, desocupado, inactivo], CONDICIONES[:3], default=CONDICIONES[3])
df["condicion"] = pd.Categorical(cond, dtype=CATEGORIAS["condicion"])
df["pet"] = pet.astype("Int8")
df["fuerza_trabajo"] = (ocupado | desocupado).astype("Int8")
df["ocupado"] = ocupado.astype("Int8")
df["desocupado"] = desocupado.astype("Int8")
df["inactivo"] = inactivo.astype("Int8")
# Formalidad (definición por seguridad social): ocupado afiliado a salud
# (P6090 = 1) y cotizando a pensión (P6920 = 1). Solo para ocupados.
cotiza = _num(df, "P6920") == 1
salud = _num(df, "P6090") == 1
formal = pd.Series(np.where(cotiza & salud, 1, 0), index=df.index).astype("Int8")
df["formal"] = formal.where(ocupado)
df["cotiza_pension"] = cotiza.astype("Int8").where(ocupado)
ing = _num(df, "INGLABO")
df["ingreso_laboral"] = ing.where(ocupado)
df["smlv"] = anio.map(SMLV)
df["ratio_smlv"] = df["ingreso_laboral"] / df["smlv"]
df["horas_semana"] = _num(df, "P6800").where(ocupado)
pos = _num(df, "P6430")
pos_map = dict(enumerate(POSICIONES, start=1))
# Cuestionario de 2022 en adelante: 7 = jornalero y 8 = otro (desaparece el 9). El DDI
# del DANE para 2022–2025 repite las categorías antiguas, pero en los datos el 7 es
# 100 % agropecuario y con ingreso. Fila a fila: se pueden juntar años de ambas épocas.
pos_map_2022 = {**pos_map, 7: "Jornalero o peón", 8: "Otro"}
del pos_map_2022[9]
etiqueta = pos.map(pos_map).where(anio < 2022, pos.map(pos_map_2022))
df["posicion_ocupacional"] = pd.Categorical(etiqueta.where(ocupado), dtype=CATEGORIAS["posicion_ocupacional"])
# Fila a fila: al juntar años distintos conviven ambas versiones.
r4, r3 = _num(df, "RAMA2D_R4"), _num(df, "RAMA2D")
df["rama2d"] = r4.fillna(r3).astype("Int64")
df["rama_rev"] = pd.Series(np.where(r4.notna(), 4, np.where(r3.notna(), 3, np.nan)), index=df.index).astype("Int8")
# where y no fillna: con dos columnas de texto, fillna avisa en pandas 2 que dejará de convertir el tipo
nuevo, viejo = _num(df, "P3042").map(_EDUC_P3042), _num(df, "P6210").map(_EDUC_P6210)
educ = nuevo.where(nuevo.notna(), viejo)
df["nivel_educativo"] = pd.Categorical(educ, dtype=CATEGORIAS["nivel_educativo"])
return df.drop(columns=["_EN_FT", "_EN_OCU", "_EN_DSI", "_EN_INI"], errors="ignore")
# --------------------------------------------------------------------------- #
# API pública
# --------------------------------------------------------------------------- #
def _firma_archivos(mes: MesGEIH) -> list[str]:
"""Ruta, tamaño y fecha de modificación de cada archivo del mes (para las llaves de caché)."""
stamp = []
for files in mes.archivos.values():
for f in files:
st = f.stat()
stamp.append(f"{f}:{st.st_size}:{int(st.st_mtime)}")
return sorted(stamp)
def _lista(x):
"""Un solo nombre como lista (``list("P6500")`` lo partiría en letras); ``None`` se deja igual."""
return [x] if isinstance(x, str) else x
def _lista_columnas(columns):
"""Como ``_lista``, salvo los valores especiales ``"core"`` y ``"all"``."""
return columns if columns in ("core", "all") else _lista(columns)
def _cache_path(mes: MesGEIH, columns, modules) -> Path:
cols = "all" if columns == "all" else ",".join(sorted({c.upper() for c in (columns if columns not in (None, "core") else [])}))
key = "|".join([_HARMONIZE_VERSION, *_firma_archivos(mes), cols, ",".join(sorted(modules or MODULOS))])
digest = hashlib.sha1(key.encode()).hexdigest()[:16]
return config.CACHE_DIR / "geih" / f"{mes.anio}_{mes.mes:02d}_{digest}.parquet"
[documentos]
def load(
years: Iterable[int] | int,
months: Iterable[int] | int | None = None,
columns="core",
empalme: bool = False,
harmonize_vars: bool = True,
modules: Iterable[str] | None = None,
root=None,
cache: bool = True,
progress=None,
incluir_incompletos: bool = False,
variables: Iterable[str] | None = None,
) -> pd.DataFrame:
"""
Carga la GEIH: una fila por persona, con los módulos unidos y las variables armonizadas.
Es la puerta de entrada a los microdatos. Para cada mes, une los módulos (``load_month``), guarda el
resultado en caché, agrega las variables armonizadas (``harmonize``) y se queda con las columnas
pedidas; al final junta todos los meses.
Parámetros
----------
years
Año o lista de años.
months
Mes o lista de meses (1–12); None para todos los disponibles.
columns
``"core"`` (por defecto), ``"all"`` o lista de variables DANE extra,
por ejemplo ``["P6500", "P7070"]``.
empalme
Usar la serie de empalme (2010–2020, marco 2018).
harmonize_vars
Agregar variables armonizadas (``sexo``, ``condicion``...).
modules
Subconjunto de ``dane_data.geih.MODULOS`` a fusionar.
cache
Guarda cada mes procesado en ``~/.cache/dane_data`` (parquet) para
que la siguiente carga sea casi instantánea.
progress
Función opcional ``(fraccion, mensaje)``.
incluir_incompletos
Por defecto se omiten (con aviso) los meses a los que
les falta un módulo clave (p. ej. desocupados): con ellos la tasa de
desocupación saldría 0 %. Los meses omitidos quedan en
``df.attrs["meses_omitidos"]``.
variables
Conservar solo estas variables (armonizadas o del DANE; ``anio`` y
``mes`` siempre). Cada mes se recorta al leerlo, así que la memoria depende
solo de lo que se conserva: útil para juntar muchos años. Las variables DANE
que no están en ``CORE_RAW`` deben pedirse también en ``columns``.
Devuelve
--------
DataFrame
Una fila por persona y mes: ``anio``, ``mes``, las variables armonizadas en minúscula y las del
DANE en mayúscula. ``df.attrs["meses_omitidos"]`` lista los meses incompletos que se saltaron.
Lanza
-----
FileNotFoundError
Si no hay ningún mes completo para lo pedido.
Véase también
-------------
indicadores : Indicadores del mercado laboral.
serie_indicadores : Indicadores de muchos años sin cargarlos en memoria.
dane_data.tabla : Tablas ponderadas.
available : Qué meses hay en disco.
Notas
-----
Un año completo tiene del orden de 800 000 registros. Con ``variables`` la memoria depende solo de las columnas
que se conservan, porque cada mes se recorta antes de juntarlos.
La primera lectura de un mes tarda unos segundos; las siguientes salen de la caché
(``~/.cache/dane_data/geih``). La caché se invalida sola si cambian los archivos del mes.
Ejemplos
--------
>>> import dane_data as dd
>>> df = dd.geih.load(2025, months=[1, 2, 3]) # doctest: +SKIP
>>> dd.geih.indicadores(df, por="sexo") # doctest: +SKIP
Variables del DANE que no están en ``CORE_RAW``:
>>> df = dd.geih.load(2024, columns=["P6500", "P7070"]) # doctest: +SKIP
Muchos años, solo con lo necesario:
>>> df = dd.geih.load(range(2007, 2026), variables=["fex", "sexo", "ocupado", "ingreso_laboral"]) # doctest: +SKIP
"""
columns, modules = _lista_columnas(columns), _lista(modules)
if isinstance(months, int):
months = [months]
conservar = None
if variables is not None:
conservar = list(dict.fromkeys(["anio", "mes", *([variables] if isinstance(variables, str) else variables)]))
meses = find_months(years, empalme=empalme, root=root)
if months is not None:
months = {int(m) for m in months}
meses = [m for m in meses if m.mes in months]
omitidos = [m for m in meses if not m.completo]
if omitidos and not incluir_incompletos:
detalle = "; ".join(f"{MESES[m.mes]} {m.anio} (falta {', '.join(m.faltantes)})" for m in omitidos)
if any("CSV del DANE" in f for m in omitidos for f in m.faltantes):
detalle += (". Vuelva a descargar esos años con dane_data.geih.download(...): para esos meses "
"se baja otro formato del ANDA")
log.warning(f"Se omiten meses incompletos: {detalle}. Use incluir_incompletos=True para cargarlos.")
meses = [m for m in meses if m.completo]
if not meses:
raise FileNotFoundError(
f"No hay datos GEIH para años={years}, meses={months} en {_root(empalme, root)}. "
"Descárguelos con dane_data.geih.download(...) o revise dane_data.set_paths(...)."
)
frames = []
for i, mes in enumerate(meses):
if progress:
progress(i / len(meses), f"Leyendo {MESES[mes.mes]} {mes.anio}")
cpath = _cache_path(mes, columns, modules) if cache else None
df = None
if cpath is not None and cpath.exists():
try:
df = pd.read_parquet(cpath)
except Exception: # caché corrupta: se regenera
df = None
if df is None:
try:
df = load_month(mes, columns=columns, modules=modules)
except FileNotFoundError as e:
log.warning(str(e))
continue
if cpath is not None:
try:
_guardar_parquet(df, cpath)
except Exception as e: # columnas de tipo mixto, disco lleno, etc.
log.debug(f"No se pudo guardar caché {cpath}: {e}")
# Mes a mes (harmonize es fila a fila): evita armonizar y copiar el frame
# concatenado de muchos años, que no cabe en memoria.
if harmonize_vars:
# El DANE publica el empalme con la PET de 15 años o más, pero el archivo de
# octubre de 2012 (catálogo 760) la marca con la definición anterior (12+ urbano,
# 10+ rural). Ver README, "Diferencias con la documentación del DANE".
df = harmonize(df, edad_minima_pet=15 if empalme else None)
if conservar is not None:
df = df[[c for c in conservar if c in df.columns]].copy() # sin copia, la vista retiene el mes entero
frames.append(df)
if progress:
progress(1.0, "Listo")
if not frames:
raise FileNotFoundError("Ningún mes pudo leerse (faltan módulos de características).")
out = pd.concat(frames, ignore_index=True, sort=False)
if conservar is not None:
if faltan := [c for c in conservar if c not in out.columns]:
log.warning(f"Variables que no existen en los datos cargados: {', '.join(faltan)}.")
out = out[[c for c in conservar if c in out.columns]]
out.attrs["meses_omitidos"] = [] if incluir_incompletos else [(m.anio, m.mes, m.faltantes) for m in omitidos]
return out
[documentos]
def clear_cache() -> int:
"""
Borra la caché de la GEIH.
Incluye los meses ya leídos por ``load`` y los resultados por año de ``serie_indicadores``,
``serie_tabla`` y ``serie_frecuencias``. Nunca hace falta para tener resultados correctos (la caché
se invalida sola cuando cambian los datos o el código); sirve para liberar espacio.
Devuelve
--------
int
Número de archivos borrados.
Ejemplos
--------
>>> import dane_data as dd
>>> dd.geih.clear_cache() # doctest: +SKIP
412
"""
n = 0
for f in (config.CACHE_DIR / "geih").rglob("*"):
if f.suffix in (".parquet", ".tmp"): # .tmp: escrituras que se interrumpieron
f.unlink(missing_ok=True)
n += 1
return n
def _guardar_parquet(df: pd.DataFrame, ruta: Path) -> None:
"""
Escribe la caché con otro nombre y la renombra al terminar, así nunca se lee un parquet a medias.
El nombre temporal es único: las sesiones de la app son hilos del mismo proceso y pueden calcular
lo mismo a la vez.
"""
ruta.parent.mkdir(parents=True, exist_ok=True)
tmp = ruta.with_name(f"{ruta.name}.{uuid.uuid4().hex}.tmp")
try:
df.to_parquet(tmp, index=False)
tmp.replace(ruta)
finally:
tmp.unlink(missing_ok=True)