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.
- dane_data/__init__.py +26 -0
- dane_data/__main__.py +7 -0
- dane_data/_hooks.py +24 -0
- dane_data/_io.py +128 -0
- dane_data/app/.streamlit/config.toml +8 -0
- dane_data/app/Inicio.py +22 -0
- dane_data/app/_ui.py +361 -0
- dane_data/app/paginas/descargar.py +101 -0
- dane_data/app/paginas/diccionario.py +62 -0
- dane_data/app/paginas/eam_industria.py +129 -0
- dane_data/app/paginas/eam_microdatos.py +56 -0
- dane_data/app/paginas/geih_explorar.py +219 -0
- dane_data/app/paginas/geih_mercado.py +152 -0
- dane_data/app/paginas/inicio.py +79 -0
- dane_data/cli.py +149 -0
- dane_data/config.py +80 -0
- dane_data/data/__init__.py +0 -0
- dane_data/data/eam_diccionario.json +3235 -0
- dane_data/data/geih_diccionario.json +8159 -0
- dane_data/eam/__init__.py +49 -0
- dane_data/eam/downloader.py +402 -0
- dane_data/eam/indicadores.py +135 -0
- dane_data/eam/loader.py +219 -0
- dane_data/geih/__init__.py +62 -0
- dane_data/geih/agregados.py +358 -0
- dane_data/geih/downloader.py +676 -0
- dane_data/geih/indicadores.py +82 -0
- dane_data/geih/loader.py +600 -0
- dane_data/referencias.py +79 -0
- dane_data/stats.py +109 -0
- dane_data-0.2.1.dist-info/METADATA +265 -0
- dane_data-0.2.1.dist-info/RECORD +36 -0
- dane_data-0.2.1.dist-info/WHEEL +5 -0
- dane_data-0.2.1.dist-info/entry_points.txt +2 -0
- dane_data-0.2.1.dist-info/licenses/LICENSE +21 -0
- dane_data-0.2.1.dist-info/top_level.txt +1 -0
|
@@ -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]
|
dane_data/geih/loader.py
ADDED
|
@@ -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
|