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
dane_data/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""
|
|
2
|
+
dane-data — microdatos del DANE (GEIH y EAM) listos para analizar.
|
|
3
|
+
|
|
4
|
+
import dane_data as dd
|
|
5
|
+
dd.set_paths(geih="~/datos/GEIH/data", eam="~/datos/EAM/data", save=True)
|
|
6
|
+
|
|
7
|
+
df = dd.geih.load(2025, months=[1, 2, 3])
|
|
8
|
+
dd.geih.indicadores(df, por="sexo")
|
|
9
|
+
|
|
10
|
+
eam = dd.eam.load(range(2015, 2025))
|
|
11
|
+
dd.eam.resumen(eam, por=["anio", "division"])
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from . import eam, geih
|
|
15
|
+
from ._hooks import DownloadCancelled
|
|
16
|
+
from .config import get_path, set_paths, show_paths
|
|
17
|
+
from .referencias import DEPARTAMENTOS, MESES, SMLV, buscar_variable, describir, diccionario
|
|
18
|
+
from .stats import tabla, weighted_mean, weighted_quantile
|
|
19
|
+
|
|
20
|
+
__version__ = "0.2.1"
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"geih", "eam", "set_paths", "get_path", "show_paths", "tabla", "weighted_mean",
|
|
24
|
+
"weighted_quantile", "buscar_variable", "describir", "diccionario", "SMLV",
|
|
25
|
+
"DEPARTAMENTOS", "MESES", "DownloadCancelled",
|
|
26
|
+
]
|
dane_data/__main__.py
ADDED
dane_data/_hooks.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Ganchos de progreso y cancelación compartidos por los descargadores."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Callable, Optional
|
|
6
|
+
|
|
7
|
+
ProgressFn = Callable[[Optional[float], str], None]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DownloadCancelled(Exception):
|
|
11
|
+
"""El usuario pidió detener la descarga."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ProgressMixin:
|
|
15
|
+
progress: ProgressFn | None = None
|
|
16
|
+
should_stop: Callable[[], bool] | None = None
|
|
17
|
+
|
|
18
|
+
def _emit(self, fraction: float | None, message: str) -> None:
|
|
19
|
+
if self.progress is not None:
|
|
20
|
+
self.progress(fraction, message)
|
|
21
|
+
|
|
22
|
+
def _check_stop(self) -> None:
|
|
23
|
+
if self.should_stop is not None and self.should_stop():
|
|
24
|
+
raise DownloadCancelled("Descarga cancelada por el usuario.")
|
dane_data/_io.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Lectura robusta de los archivos planos del DANE.
|
|
2
|
+
|
|
3
|
+
Los archivos cambian de separador (``;``, ``,``, tabulador), de codificación
|
|
4
|
+
(UTF-8 con BOM, Latin-1) y de separador decimal (coma en la GEIH anterior a
|
|
5
|
+
2020 y en algunas celdas de la EAM) entre años. ``read_table`` detecta todo eso
|
|
6
|
+
automáticamente.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Callable, Iterable
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
import pandas as pd
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def sniff(path: Path) -> tuple[str, str]:
|
|
19
|
+
"""Detecta (separador, codificación) leyendo el encabezado."""
|
|
20
|
+
# Siempre cerrado: en Windows un archivo abierto no se puede reemplazar al volver a descargar.
|
|
21
|
+
with open(path, "rb") as fh:
|
|
22
|
+
raw = fh.read(64_000)
|
|
23
|
+
try:
|
|
24
|
+
text = raw.decode("utf-8-sig")
|
|
25
|
+
encoding = "utf-8-sig"
|
|
26
|
+
except UnicodeDecodeError:
|
|
27
|
+
# Puede ser un corte a mitad de carácter multibyte: reintentar sin el final
|
|
28
|
+
try:
|
|
29
|
+
text = raw[:-3].decode("utf-8-sig")
|
|
30
|
+
encoding = "utf-8-sig"
|
|
31
|
+
except UnicodeDecodeError:
|
|
32
|
+
text = raw.decode("latin-1")
|
|
33
|
+
encoding = "latin-1"
|
|
34
|
+
header = text.splitlines()[0] if text else ""
|
|
35
|
+
sep = max((";", ",", "\t", "|"), key=header.count)
|
|
36
|
+
return sep, encoding
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _coerce_numeric(df: pd.DataFrame) -> pd.DataFrame:
|
|
40
|
+
"""
|
|
41
|
+
Convierte a número las columnas de texto que en realidad son numéricas:
|
|
42
|
+
celdas en blanco escritas como ``" "`` y coma decimal (``"71,22"``).
|
|
43
|
+
"""
|
|
44
|
+
for col in df.columns:
|
|
45
|
+
s = df[col]
|
|
46
|
+
if not (pd.api.types.is_object_dtype(s) or pd.api.types.is_string_dtype(s)):
|
|
47
|
+
continue
|
|
48
|
+
s = s.astype("string").str.strip().replace("", pd.NA)
|
|
49
|
+
nonnull = s.notna().sum()
|
|
50
|
+
if nonnull == 0:
|
|
51
|
+
df[col] = np.nan
|
|
52
|
+
continue
|
|
53
|
+
# Basta con que aparezca una coma, no la mayoría: en la EAM (1998, 1999, 2002,
|
|
54
|
+
# 2006) casi todo es entero y solo las plantas anonimizadas por promedio traen
|
|
55
|
+
# coma decimal ("83779570,6666667"); decidir con una muestra las volvía NaN.
|
|
56
|
+
# Si la columna es texto genuino, la conversión de abajo falla y no se toca.
|
|
57
|
+
if s.str.contains(",", regex=False).any():
|
|
58
|
+
s = s.str.replace(",", ".", regex=False)
|
|
59
|
+
converted = pd.to_numeric(s, errors="coerce").astype("float64")
|
|
60
|
+
# Solo si no se pierden datos (texto genuino se deja como está)
|
|
61
|
+
if converted.notna().sum() >= nonnull * 0.99:
|
|
62
|
+
if converted.notna().all() and (converted % 1 == 0).all():
|
|
63
|
+
converted = converted.astype("int64")
|
|
64
|
+
df[col] = converted
|
|
65
|
+
return df
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def normalize_columns(cols: Iterable[str], case: str = "upper") -> list[str]:
|
|
69
|
+
out = []
|
|
70
|
+
for c in cols:
|
|
71
|
+
c = str(c).replace("", "").strip()
|
|
72
|
+
out.append(c.upper() if case == "upper" else c.lower())
|
|
73
|
+
return out
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def columnas(path: Path, case: str = "upper") -> list[str]:
|
|
77
|
+
"""Nombres de columna normalizados de un archivo del DANE (texto o Stata), sin leer los datos."""
|
|
78
|
+
path = Path(path)
|
|
79
|
+
if path.suffix.lower() == ".dta":
|
|
80
|
+
with pd.read_stata(path, iterator=True) as reader:
|
|
81
|
+
return normalize_columns(reader.variable_labels(), case)
|
|
82
|
+
sep, enc = sniff(path)
|
|
83
|
+
with open(path, encoding=enc, errors="replace") as fh:
|
|
84
|
+
return normalize_columns(fh.readline().rstrip("\r\n").split(sep), case)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def read_table(
|
|
88
|
+
path: Path,
|
|
89
|
+
columns: Iterable[str] | Callable[[str], bool] | None = None,
|
|
90
|
+
case: str = "upper",
|
|
91
|
+
) -> pd.DataFrame:
|
|
92
|
+
"""
|
|
93
|
+
Lee un CSV/TXT del DANE detectando separador, codificación y coma decimal.
|
|
94
|
+
También lee Stata (``.dta``), el formato que se usa cuando el CSV del ANDA está dañado.
|
|
95
|
+
|
|
96
|
+
``columns`` puede ser una lista de nombres (en cualquier mayúscula/minúscula)
|
|
97
|
+
o una función ``nombre_normalizado -> bool``. Los nombres de columna del
|
|
98
|
+
resultado quedan normalizados (``case='upper'`` o ``'lower'``).
|
|
99
|
+
"""
|
|
100
|
+
path = Path(path)
|
|
101
|
+
usecols = None
|
|
102
|
+
if columns is not None:
|
|
103
|
+
if callable(columns):
|
|
104
|
+
pred = columns
|
|
105
|
+
else:
|
|
106
|
+
wanted = {normalize_columns([c], case)[0] for c in columns}
|
|
107
|
+
pred = lambda c: c in wanted # noqa: E731
|
|
108
|
+
usecols = lambda c: pred(normalize_columns([c], case)[0]) # noqa: E731
|
|
109
|
+
|
|
110
|
+
if path.suffix.lower() == ".dta":
|
|
111
|
+
with pd.read_stata(path, iterator=True) as reader:
|
|
112
|
+
nombres = list(reader.variable_labels())
|
|
113
|
+
elegidas = [c for c in nombres if usecols is None or usecols(c)]
|
|
114
|
+
if not elegidas:
|
|
115
|
+
return pd.DataFrame()
|
|
116
|
+
df = pd.read_stata(path, columns=elegidas, convert_categoricals=False)
|
|
117
|
+
df.columns = normalize_columns(df.columns, case)
|
|
118
|
+
return _coerce_numeric(df.loc[:, ~df.columns.duplicated()])
|
|
119
|
+
|
|
120
|
+
sep, encoding = sniff(path)
|
|
121
|
+
kwargs = dict(sep=sep, usecols=usecols, low_memory=False)
|
|
122
|
+
try:
|
|
123
|
+
df = pd.read_csv(path, encoding=encoding, **kwargs)
|
|
124
|
+
except UnicodeDecodeError:
|
|
125
|
+
df = pd.read_csv(path, encoding="latin-1", **kwargs)
|
|
126
|
+
df.columns = normalize_columns(df.columns, case)
|
|
127
|
+
df = df.loc[:, ~df.columns.duplicated()]
|
|
128
|
+
return _coerce_numeric(df)
|
dane_data/app/Inicio.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Punto de entrada de la app: `dane-data app` o `streamlit run src/dane-data/app/Inicio.py`."""
|
|
2
|
+
|
|
3
|
+
import streamlit as st
|
|
4
|
+
|
|
5
|
+
st.set_page_config(page_title="dane-data · Microdatos DANE", page_icon="📊", layout="wide")
|
|
6
|
+
|
|
7
|
+
paginas = {
|
|
8
|
+
"": [st.Page("paginas/inicio.py", title="Inicio", icon="🏠", default=True)],
|
|
9
|
+
"GEIH · Hogares y mercado laboral": [
|
|
10
|
+
st.Page("paginas/geih_mercado.py", title="Indicadores laborales", icon="👥"),
|
|
11
|
+
st.Page("paginas/geih_explorar.py", title="Tablas y microdatos", icon="🔎"),
|
|
12
|
+
],
|
|
13
|
+
"EAM · Industria manufacturera": [
|
|
14
|
+
st.Page("paginas/eam_industria.py", title="Indicadores industriales", icon="🏭"),
|
|
15
|
+
st.Page("paginas/eam_microdatos.py", title="Panel y microdatos", icon="🧱"),
|
|
16
|
+
],
|
|
17
|
+
"Herramientas": [
|
|
18
|
+
st.Page("paginas/descargar.py", title="Descargar datos", icon="⬇️"),
|
|
19
|
+
st.Page("paginas/diccionario.py", title="Diccionario de variables", icon="📚"),
|
|
20
|
+
],
|
|
21
|
+
}
|
|
22
|
+
st.navigation(paginas).run()
|
dane_data/app/_ui.py
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
"""Piezas compartidas de la app: gráficos, caché de datos, descargas y avisos."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import logging
|
|
7
|
+
from collections import deque
|
|
8
|
+
|
|
9
|
+
import pandas as pd
|
|
10
|
+
import plotly.express as px
|
|
11
|
+
import plotly.graph_objects as go
|
|
12
|
+
import streamlit as st
|
|
13
|
+
|
|
14
|
+
import dane_data as dd
|
|
15
|
+
|
|
16
|
+
# Paleta categórica validada (orden fijo; nunca se recicla). Ver skill dataviz.
|
|
17
|
+
PALETA = ["#2a78d6", "#eb6834", "#1baf7a", "#eda100", "#e87ba4", "#008300", "#4a3aa7", "#e34948"]
|
|
18
|
+
COLOR_UNICO = PALETA[0]
|
|
19
|
+
MAX_SERIES = len(PALETA)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# --------------------------------------------------------------------------- #
|
|
23
|
+
# Formato
|
|
24
|
+
# --------------------------------------------------------------------------- #
|
|
25
|
+
def fmt_num(x: float, dec: int = 0) -> str:
|
|
26
|
+
if pd.isna(x):
|
|
27
|
+
return "—"
|
|
28
|
+
return f"{x:,.{dec}f}".replace(",", "X").replace(".", ",").replace("X", ".")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def fmt_pesos(x: float) -> str:
|
|
32
|
+
return "—" if pd.isna(x) else "$ " + fmt_num(x)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def fmt_pct(x: float) -> str:
|
|
36
|
+
return "—" if pd.isna(x) else fmt_num(x, 1) + " %"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def fmt_grande(x: float) -> str:
|
|
40
|
+
"""12.345.678 → '12,3 millones'."""
|
|
41
|
+
if pd.isna(x):
|
|
42
|
+
return "—"
|
|
43
|
+
for div, suf in ((1e12, "billones"), (1e9, "mil millones"), (1e6, "millones"), (1e3, "mil")):
|
|
44
|
+
if abs(x) >= div:
|
|
45
|
+
return f"{fmt_num(x / div, 1)} {suf}"
|
|
46
|
+
return fmt_num(x)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# --------------------------------------------------------------------------- #
|
|
50
|
+
# Gráficos
|
|
51
|
+
# --------------------------------------------------------------------------- #
|
|
52
|
+
def _estilo(fig: go.Figure, titulo_y: str | None = None, pct: bool = False) -> go.Figure:
|
|
53
|
+
fig.update_layout(
|
|
54
|
+
separators=",.", # decimales con coma y miles con punto (español)
|
|
55
|
+
margin=dict(l=10, r=10, t=40, b=10),
|
|
56
|
+
legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="left", x=0, title=None),
|
|
57
|
+
hoverlabel=dict(namelength=-1),
|
|
58
|
+
font=dict(size=13),
|
|
59
|
+
)
|
|
60
|
+
fig.update_xaxes(showgrid=False, title=None)
|
|
61
|
+
# Eje desde cero: evita que cambios de décimas parezcan enormes.
|
|
62
|
+
fig.update_yaxes(gridcolor="rgba(128,128,128,0.18)", zeroline=False, title=titulo_y, rangemode="tozero",
|
|
63
|
+
ticksuffix=" %" if pct else None, tickformat=",~f")
|
|
64
|
+
return fig
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def linea(df: pd.DataFrame, x: str, y: str, color: str | None = None, titulo_y: str | None = None,
|
|
68
|
+
pct: bool = False) -> go.Figure:
|
|
69
|
+
fig = px.line(df, x=x, y=y, color=color, markers=True, color_discrete_sequence=PALETA)
|
|
70
|
+
fig.update_traces(line=dict(width=2), marker=dict(size=8))
|
|
71
|
+
fig = _estilo(fig, titulo_y, pct)
|
|
72
|
+
fig.update_layout(hovermode="x unified")
|
|
73
|
+
if pd.api.types.is_integer_dtype(df[x]) or x == "anio":
|
|
74
|
+
fig.update_xaxes(dtick=1 if df[x].nunique() <= 15 else None, tickformat="d")
|
|
75
|
+
return fig
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def barras(df: pd.DataFrame, x: str, y: str, color: str | None = None, titulo_y: str | None = None,
|
|
79
|
+
pct: bool = False, horizontal: bool = False) -> go.Figure:
|
|
80
|
+
if horizontal:
|
|
81
|
+
fig = px.bar(df, y=x, x=y, color=color, orientation="h", barmode="group",
|
|
82
|
+
color_discrete_sequence=PALETA)
|
|
83
|
+
fig.update_yaxes(categoryorder="total ascending", title=None)
|
|
84
|
+
fig.update_xaxes(gridcolor="rgba(128,128,128,0.18)", title=titulo_y, ticksuffix=" %" if pct else None,
|
|
85
|
+
tickformat=",~f")
|
|
86
|
+
fig.update_layout(separators=",.", margin=dict(l=10, r=10, t=40, b=10), legend=dict(orientation="h", y=1.02, title=None),
|
|
87
|
+
height=max(350, 24 * df[x].nunique() * (df[color].nunique() if color else 1)))
|
|
88
|
+
else:
|
|
89
|
+
fig = px.bar(df, x=x, y=y, color=color, barmode="group", color_discrete_sequence=PALETA)
|
|
90
|
+
fig = _estilo(fig, titulo_y, pct)
|
|
91
|
+
fig.update_traces(marker_line_width=0)
|
|
92
|
+
fig.update_layout(bargap=0.25, bargroupgap=0.08)
|
|
93
|
+
return fig
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def grafico(fig: go.Figure) -> None:
|
|
97
|
+
st.plotly_chart(fig, width="stretch", config={"displaylogo": False, "locale": "es"})
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# --------------------------------------------------------------------------- #
|
|
101
|
+
# Datos (caché en memoria de la sesión del servidor)
|
|
102
|
+
# --------------------------------------------------------------------------- #
|
|
103
|
+
def _barra(texto: str):
|
|
104
|
+
barra = st.progress(0.0, text=texto)
|
|
105
|
+
return barra, lambda f, m: barra.progress(min(f, 1.0), text=m)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# Los resultados de la GEIH salen de dd.geih.serie_*: se calculan año por año y quedan en disco, así
|
|
109
|
+
# que nunca hay más de un año de microdatos en memoria. `meses_disp` va en la llave para que un mes
|
|
110
|
+
# recién descargado se vea.
|
|
111
|
+
@st.cache_data(max_entries=50, show_spinner=False)
|
|
112
|
+
def serie_geih(anios: tuple, por: str | None, anual: bool, empalme: bool, filtros: dict, meses: tuple | None,
|
|
113
|
+
meses_disp: tuple, rutas: tuple) -> pd.DataFrame:
|
|
114
|
+
barra, progreso = _barra("Preparando indicadores…")
|
|
115
|
+
res = dd.geih.serie_indicadores(list(anios), por=por, anual=anual, empalme=empalme, progress=progreso,
|
|
116
|
+
filtros=filtros or None, meses=list(meses) if meses else None)
|
|
117
|
+
barra.empty()
|
|
118
|
+
return res
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@st.cache_data(max_entries=100, show_spinner=False)
|
|
122
|
+
def tabla_geih(anios: tuple, por: tuple, variable: str | None, estadistico: str, poblacion: str | None,
|
|
123
|
+
filtros: dict, meses: tuple | None, dropna: bool, columnas: tuple, empalme: bool,
|
|
124
|
+
meses_disp: tuple, rutas: tuple) -> pd.DataFrame:
|
|
125
|
+
barra, progreso = _barra("Preparando la tabla…")
|
|
126
|
+
res = dd.geih.serie_tabla(list(anios), list(por), variable, estadistico, poblacion, filtros or None,
|
|
127
|
+
list(meses) if meses else None, dropna, list(columnas) or "core", empalme,
|
|
128
|
+
progress=progreso)
|
|
129
|
+
barra.empty()
|
|
130
|
+
return res
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@st.cache_data(max_entries=50, show_spinner=False)
|
|
134
|
+
def frecuencias_geih(anios: tuple, variable: str | None, por: tuple, poblacion: str | None, filtros: dict,
|
|
135
|
+
meses: tuple | None, columnas: tuple, empalme: bool, meses_disp: tuple,
|
|
136
|
+
rutas: tuple) -> pd.DataFrame:
|
|
137
|
+
barra, progreso = _barra("Preparando la distribución…")
|
|
138
|
+
res = dd.geih.serie_frecuencias(list(anios), variable, list(por), poblacion, filtros or None,
|
|
139
|
+
list(meses) if meses else None, list(columnas) or "core", empalme,
|
|
140
|
+
progress=progreso)
|
|
141
|
+
barra.empty()
|
|
142
|
+
return res
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@st.cache_data(max_entries=5, show_spinner="Leyendo un mes de microdatos…")
|
|
146
|
+
def muestra_geih(anio: int, mes: int, variables: tuple, columnas: tuple, empalme: bool, rutas: tuple) -> pd.DataFrame:
|
|
147
|
+
"""Un solo mes de microdatos (vista previa, tipos de las variables adicionales)."""
|
|
148
|
+
return dd.geih.load(anio, months=mes, columns=list(columnas) or "core", empalme=empalme,
|
|
149
|
+
variables=list(variables))
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def args_py(**kw) -> str:
|
|
153
|
+
"""Argumentos para el código equivalente: solo los que no tienen su valor por defecto."""
|
|
154
|
+
return "".join(f", {k}={v!r}" for k, v in kw.items() if v not in (None, False, {}, (), [], "core"))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@st.cache_resource(max_entries=2, show_spinner=False)
|
|
158
|
+
def cargar_eam(anios: tuple, extra: tuple, rutas: tuple) -> pd.DataFrame:
|
|
159
|
+
return dd.eam.load(list(anios), columns=list(extra) or "core")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def rutas_actuales() -> tuple:
|
|
163
|
+
"""Parte de la llave de caché: si el usuario cambia de carpeta, se recarga."""
|
|
164
|
+
return tuple(sorted(dd.show_paths().items()))
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@st.cache_data(ttl=30, show_spinner=False)
|
|
168
|
+
def disponibles_geih(empalme: bool, rutas: tuple) -> pd.DataFrame:
|
|
169
|
+
return dd.geih.available(empalme=empalme)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@st.cache_data(ttl=30, show_spinner=False)
|
|
173
|
+
def disponibles_eam(rutas: tuple) -> pd.DataFrame:
|
|
174
|
+
return dd.eam.available()
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# --------------------------------------------------------------------------- #
|
|
178
|
+
# Exportar
|
|
179
|
+
# --------------------------------------------------------------------------- #
|
|
180
|
+
def botones_descarga(df: pd.DataFrame, nombre: str, key: str) -> None:
|
|
181
|
+
c1, c2, c3 = st.columns(3)
|
|
182
|
+
c1.download_button("⬇️ CSV", df.to_csv(index=False).encode("utf-8-sig"), f"{nombre}.csv",
|
|
183
|
+
"text/csv", key=f"{key}_csv", width="stretch")
|
|
184
|
+
if len(df) < 1_000_000:
|
|
185
|
+
buf = io.BytesIO()
|
|
186
|
+
df.to_excel(buf, index=False)
|
|
187
|
+
c2.download_button("⬇️ Excel", buf.getvalue(), f"{nombre}.xlsx", key=f"{key}_xlsx", width="stretch")
|
|
188
|
+
else:
|
|
189
|
+
c2.caption("Excel no admite más de 1 millón de filas.")
|
|
190
|
+
try:
|
|
191
|
+
buf = io.BytesIO()
|
|
192
|
+
d = df.copy()
|
|
193
|
+
for c in d.columns: # Stata no acepta categorías/nullable ni nombres largos
|
|
194
|
+
if isinstance(d[c].dtype, pd.CategoricalDtype) or pd.api.types.is_string_dtype(d[c]):
|
|
195
|
+
d[c] = d[c].astype(object).where(d[c].notna(), "").astype(str)
|
|
196
|
+
elif pd.api.types.is_extension_array_dtype(d[c]):
|
|
197
|
+
d[c] = d[c].astype("float64")
|
|
198
|
+
d.columns = [c[:32] for c in d.columns]
|
|
199
|
+
d.to_stata(buf, write_index=False, version=118)
|
|
200
|
+
c3.download_button("⬇️ Stata (.dta)", buf.getvalue(), f"{nombre}.dta", key=f"{key}_dta", width="stretch")
|
|
201
|
+
except Exception as e: # noqa: BLE001
|
|
202
|
+
c3.caption(f"Stata no disponible: {e}")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def codigo(texto: str) -> None:
|
|
206
|
+
with st.expander("🐍 Ver el código Python equivalente"):
|
|
207
|
+
st.caption("Copie esto en un notebook para reproducir o extender el análisis con la librería `dane-data`.")
|
|
208
|
+
st.code(texto.strip(), language="python")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# --------------------------------------------------------------------------- #
|
|
212
|
+
# Logs de descarga en pantalla
|
|
213
|
+
# --------------------------------------------------------------------------- #
|
|
214
|
+
class LogEnPantalla(logging.Handler):
|
|
215
|
+
def __init__(self, contenedor, lineas: int = 12):
|
|
216
|
+
super().__init__(level=logging.INFO)
|
|
217
|
+
self.contenedor = contenedor
|
|
218
|
+
self.buffer: deque[str] = deque(maxlen=lineas)
|
|
219
|
+
self.setFormatter(logging.Formatter("%(asctime)s %(message)s", "%H:%M:%S"))
|
|
220
|
+
|
|
221
|
+
def emit(self, record):
|
|
222
|
+
msg = self.format(record).strip()
|
|
223
|
+
if msg:
|
|
224
|
+
self.buffer.append(msg)
|
|
225
|
+
self.contenedor.code("\n".join(self.buffer), language=None)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def sin_datos(encuesta: str) -> None:
|
|
229
|
+
st.warning(f"No se encontraron datos de la {encuesta} en la carpeta configurada.")
|
|
230
|
+
st.page_link("paginas/descargar.py", label="Ir a ⬇️ Descargar datos", icon="⬇️")
|
|
231
|
+
st.page_link("paginas/inicio.py", label="o configurar la carpeta en 🏠 Inicio", icon="🏠")
|
|
232
|
+
st.stop()
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
# --------------------------------------------------------------------------- #
|
|
236
|
+
# Selector GEIH (barra lateral compartida)
|
|
237
|
+
# --------------------------------------------------------------------------- #
|
|
238
|
+
AYUDA_ANIOS = ("Los resultados se calculan año por año y quedan guardados: la primera vez tardan unos "
|
|
239
|
+
"segundos por año y después son instantáneos.")
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _rangos(anios) -> str:
|
|
243
|
+
"""[2007, 2008, 2009, 2021] -> "2007–2009 y 2021"."""
|
|
244
|
+
tramos = []
|
|
245
|
+
for a in sorted(anios):
|
|
246
|
+
if tramos and a == tramos[-1][1] + 1:
|
|
247
|
+
tramos[-1][1] = a
|
|
248
|
+
else:
|
|
249
|
+
tramos.append([a, a])
|
|
250
|
+
textos = [f"{i}–{f}" if f > i else str(i) for i, f in tramos]
|
|
251
|
+
return textos[0] if len(textos) == 1 else ", ".join(textos[:-1]) + " y " + textos[-1]
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def aviso_marcos(anios, empalme: bool) -> None:
|
|
255
|
+
"""Advierte cuando los años elegidos mezclan marcos de la GEIH: la serie salta por método, no por el mercado."""
|
|
256
|
+
grupos: dict[str, list[int]] = {}
|
|
257
|
+
for a in anios:
|
|
258
|
+
grupos.setdefault(dd.geih.marco_muestral(a, empalme), []).append(a)
|
|
259
|
+
if len(grupos) < 2:
|
|
260
|
+
return
|
|
261
|
+
partes = [f"{_rangos(v)} {'usan' if len(v) > 1 else 'usa'} {dd.geih.MARCOS[k]}" for k, v in grupos.items()]
|
|
262
|
+
texto = ("⚠️ **Los años elegidos mezclan dos versiones de la GEIH que no son comparables.** "
|
|
263
|
+
+ "; ".join(partes) + ". Entre esos años la serie salta por el cambio de método, no por el mercado "
|
|
264
|
+
"laboral (la población en edad de trabajar cambia ~5 %).")
|
|
265
|
+
if any(2010 <= a <= 2020 for a in anios):
|
|
266
|
+
texto += " Para comparar 2010–2020 sin saltos use la serie de empalme."
|
|
267
|
+
st.warning(texto)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def seleccion_geih(clave: str = "geih", varios_anios: bool = True, ayuda_anios: str = AYUDA_ANIOS) -> dict:
|
|
271
|
+
"""Dibuja la barra lateral de la GEIH y devuelve la selección, sin cargar datos."""
|
|
272
|
+
with st.sidebar:
|
|
273
|
+
st.header("Datos GEIH")
|
|
274
|
+
serie = st.radio("Serie", ["Principal", "Empalme (2010–2020)"], horizontal=True, key=f"{clave}_serie",
|
|
275
|
+
help="La serie de empalme re-pondera 2010–2020 con el censo 2018, para comparar "
|
|
276
|
+
"con los datos desde 2021 sin saltos.")
|
|
277
|
+
empalme = serie != "Principal"
|
|
278
|
+
av = disponibles_geih(empalme, rutas_actuales())
|
|
279
|
+
if av.empty:
|
|
280
|
+
sin_datos("GEIH")
|
|
281
|
+
av = av[av.completo]
|
|
282
|
+
anios_disp = sorted(av.anio.unique())
|
|
283
|
+
defecto = anios_disp[-2:] if varios_anios else anios_disp[-1:]
|
|
284
|
+
anios = st.multiselect("Años", anios_disp, default=defecto, key=f"{clave}_anios", help=ayuda_anios)
|
|
285
|
+
if not anios:
|
|
286
|
+
st.info("Elija al menos un año.")
|
|
287
|
+
st.stop()
|
|
288
|
+
meses_disp = sorted(av[av.anio.isin(anios)].mes.unique())
|
|
289
|
+
todos = st.toggle("Todos los meses", True, key=f"{clave}_todos")
|
|
290
|
+
meses = None
|
|
291
|
+
if not todos:
|
|
292
|
+
meses = st.multiselect("Meses", meses_disp, default=meses_disp[:1], format_func=dd.MESES.get,
|
|
293
|
+
key=f"{clave}_meses")
|
|
294
|
+
if not meses:
|
|
295
|
+
st.stop()
|
|
296
|
+
|
|
297
|
+
st.header("Filtros")
|
|
298
|
+
sexo = st.multiselect("Sexo", ["Hombre", "Mujer"], key=f"{clave}_sexo", placeholder="Todos")
|
|
299
|
+
zona = st.multiselect("Zona", ["Urbano (cabecera)", "Rural (centros poblados y disperso)"],
|
|
300
|
+
key=f"{clave}_zona", placeholder="Todas")
|
|
301
|
+
edad = st.slider("Edad", 0, 100, (0, 100), key=f"{clave}_edad")
|
|
302
|
+
dptos = st.multiselect("Departamento", sorted(dd.DEPARTAMENTOS.values()), key=f"{clave}_dpto",
|
|
303
|
+
placeholder="Todos")
|
|
304
|
+
|
|
305
|
+
partes = [f"{'empalme ' if empalme else ''}{', '.join(map(str, sorted(anios)))}"]
|
|
306
|
+
if meses:
|
|
307
|
+
partes.append(", ".join(dd.MESES[m] for m in sorted(meses)))
|
|
308
|
+
for nombre, val in (("sexo", sexo), ("zona", zona), ("dpto", dptos)):
|
|
309
|
+
if val:
|
|
310
|
+
partes.append(f"{nombre}: {', '.join(val)}")
|
|
311
|
+
if edad != (0, 100):
|
|
312
|
+
partes.append(f"edad {edad[0]}–{edad[1]}")
|
|
313
|
+
|
|
314
|
+
elegidos = av[av.anio.isin(anios)]
|
|
315
|
+
seleccion = {
|
|
316
|
+
"anios": sorted(map(int, anios)), "meses": sorted(map(int, meses)) if meses else None, "empalme": empalme,
|
|
317
|
+
"sexo": sexo, "zona": zona, "edad": edad, "dptos": dptos, "descripcion": " · ".join(partes),
|
|
318
|
+
"meses_disponibles": tuple(zip(elegidos.anio.tolist(), elegidos.mes.tolist())),
|
|
319
|
+
"filtrada": bool(meses or sexo or zona or dptos or edad != (0, 100)),
|
|
320
|
+
# en el formato de dd.geih.filtrar
|
|
321
|
+
"filtros": {k: v for k, v in (("sexo", list(sexo)), ("zona", list(zona)),
|
|
322
|
+
("edad", tuple(edad) if edad != (0, 100) else None),
|
|
323
|
+
("departamento", list(dptos))) if v},
|
|
324
|
+
}
|
|
325
|
+
aviso_marcos(seleccion["anios"], empalme)
|
|
326
|
+
return seleccion
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
# --------------------------------------------------------------------------- #
|
|
330
|
+
# Selector EAM (barra lateral compartida)
|
|
331
|
+
# --------------------------------------------------------------------------- #
|
|
332
|
+
def selector_eam(extra: tuple = (), clave: str = "eam"):
|
|
333
|
+
with st.sidebar:
|
|
334
|
+
st.header("Datos EAM")
|
|
335
|
+
av = disponibles_eam(rutas_actuales())
|
|
336
|
+
if av.empty:
|
|
337
|
+
sin_datos("EAM")
|
|
338
|
+
anios_disp = sorted(av.anio.unique())
|
|
339
|
+
lo, hi = st.select_slider("Años", options=anios_disp,
|
|
340
|
+
value=(max(anios_disp[0], anios_disp[-1] - 10), anios_disp[-1]), key=f"{clave}_rango")
|
|
341
|
+
anios = [a for a in anios_disp if lo <= a <= hi]
|
|
342
|
+
st.caption("CIIU Rev. 2 hasta 2000, Rev. 3 2001–2011 y Rev. 4 desde 2012 (según los archivos del DANE): "
|
|
343
|
+
"los sectores no son comparables entre revisiones.")
|
|
344
|
+
df = cargar_eam(tuple(anios), tuple(sorted(extra)), rutas_actuales())
|
|
345
|
+
with st.sidebar:
|
|
346
|
+
st.header("Filtros")
|
|
347
|
+
sectores = st.multiselect("Sector (división CIIU)", sorted(df["sector"].dropna().unique()),
|
|
348
|
+
key=f"{clave}_sector", placeholder="Todos")
|
|
349
|
+
dptos = st.multiselect("Departamento", sorted(df["departamento"].dropna().unique()),
|
|
350
|
+
key=f"{clave}_dpto", placeholder="Todos")
|
|
351
|
+
if sectores:
|
|
352
|
+
df = df[df["sector"].isin(sectores)]
|
|
353
|
+
if dptos:
|
|
354
|
+
df = df[df["departamento"].isin(dptos)]
|
|
355
|
+
carga = f"eam = dd.eam.load(range({lo}, {hi + 1})" + (f", columns={sorted(extra)!r}" if extra else "") + ")"
|
|
356
|
+
if sectores:
|
|
357
|
+
carga += f'\neam = eam[eam["sector"].isin({sectores!r})]'
|
|
358
|
+
if dptos:
|
|
359
|
+
carga += f'\neam = eam[eam["departamento"].isin({dptos!r})]'
|
|
360
|
+
desc = f"{lo}–{hi}" + (f" · {', '.join(sectores)}" if sectores else "") + (f" · {', '.join(dptos)}" if dptos else "")
|
|
361
|
+
return df, desc, {"carga": carga, "anios": anios}
|