dane-data 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,101 @@
1
+ import logging
2
+
3
+ import streamlit as st
4
+
5
+ import dane_data as dd
6
+ from dane_data.app._ui import LogEnPantalla, disponibles_eam, disponibles_geih, rutas_actuales
7
+
8
+ st.title("⬇️ Descargar datos del DANE")
9
+ st.markdown(
10
+ "Los archivos se bajan del **Archivo Nacional de Datos (ANDA)** del DANE y se guardan en su computador. "
11
+ "La descarga es **incremental**: si se interrumpe, al repetirla continúa donde iba."
12
+ )
13
+
14
+ encuesta = st.radio("Encuesta", ["GEIH (hogares, mensual)", "EAM (industria, anual)"], horizontal=True)
15
+ rutas = rutas_actuales()
16
+
17
+ if encuesta.startswith("GEIH"):
18
+ serie = st.radio("Serie", ["Principal (2007–2026)", "Empalme (2010–2020)"], horizontal=True,
19
+ help="El empalme re-pondera 2010–2020 con el censo 2018 para empalmar con 2021 en adelante.")
20
+ empalme = serie.startswith("Empalme")
21
+ anios_ok = dd.geih.ANIOS_EMPALME if empalme else dd.geih.ANIOS_DISPONIBLES
22
+ ya = disponibles_geih(empalme, rutas)
23
+ tiene = set(ya.anio.unique()) if len(ya) else set()
24
+ anios = st.multiselect("Años", anios_ok, default=[anios_ok[-1]],
25
+ format_func=lambda a: f"{a} ✓" if a in tiene else str(a))
26
+ st.caption("✓ = ya descargado (total o parcialmente).")
27
+ NOMBRES = {
28
+ "caracteristicas": "Características generales (edad, sexo, educación, salud)",
29
+ "fuerza_trabajo": "Fuerza de trabajo",
30
+ "ocupados": "Ocupados (empleo, ingresos, formalidad)",
31
+ "desocupados": "Desocupados",
32
+ "inactivos": "Inactivos",
33
+ "vivienda": "Vivienda y hogar",
34
+ "otras_formas_trabajo": "Otras formas de trabajo",
35
+ }
36
+ with st.expander("Opciones avanzadas"):
37
+ modulos = st.multiselect("Módulos", list(NOMBRES), default=list(NOMBRES), format_func=NOMBRES.get,
38
+ help="Para los indicadores laborales se necesitan al menos: características, "
39
+ "fuerza de trabajo, ocupados y desocupados.")
40
+ prueba = st.toggle("Solo el primer mes de cada año (para probar)")
41
+ borrar = st.toggle("Borrar los ZIP después de extraer (ahorra ~40 % de espacio)", value=True)
42
+ st.info(f"📦 Tamaño aproximado: **{0.6 * len(anios) if not prueba else 0.05 * len(anios):.1f} GB** · "
43
+ f"destino: `{dd.get_path('geih_empalme' if empalme else 'geih')}`")
44
+ lanzar = lambda prog, stop: dd.geih.download( # noqa: E731
45
+ anios, modules=modulos, empalme=empalme, delete_zips=borrar, limit_files=1 if prueba else None,
46
+ progress=prog, should_stop=stop)
47
+ codigo_py = (f"dd.geih.download({anios!r}" + (", empalme=True" if empalme else "")
48
+ + (", limit_files=1" if prueba else "") + ")")
49
+ else:
50
+ ya = disponibles_eam(rutas)
51
+ tiene = set(ya.anio.unique()) if len(ya) else set()
52
+ todos = dd.eam.ANIOS_DISPONIBLES
53
+ faltan = [a for a in todos if a not in tiene]
54
+ # Valor inicial por session_state (no `default`) para que el botón de abajo pueda
55
+ # cambiar la selección: un `if st.button(...)` la perdía al pulsar "Descargar".
56
+ if "descargar_eam_anios" not in st.session_state:
57
+ st.session_state["descargar_eam_anios"] = faltan[-5:]
58
+ anios = st.multiselect("Años", todos, key="descargar_eam_anios",
59
+ format_func=lambda a: f"{a} ✓" if a in tiene else str(a))
60
+ c1, c2 = st.columns(2)
61
+ c1.button("Seleccionar toda la serie 1992–2024",
62
+ on_click=lambda: st.session_state.update(descargar_eam_anios=list(todos)))
63
+ tic = st.toggle("Incluir módulo de TIC (2008–2018)")
64
+ borrar = st.toggle("Borrar los ZIP después de extraer", value=True)
65
+ st.info(f"📦 Tamaño aproximado: **{10 * len(anios)} MB** · destino: `{dd.get_path('eam')}`")
66
+ lanzar = lambda prog, stop: dd.eam.download(anios, include_tic=tic, delete_zips=borrar, # noqa: E731
67
+ progress=prog, should_stop=stop)
68
+ codigo_py = f"dd.eam.download({anios!r})"
69
+
70
+ if st.button("⬇️ Descargar", type="primary", disabled=not anios):
71
+ barra = st.progress(0.0, text="Conectando con el DANE…")
72
+ detalle = st.empty()
73
+ logs = st.empty()
74
+ handler = LogEnPantalla(logs)
75
+ logger = logging.getLogger("dane_data")
76
+ logger.setLevel(logging.INFO)
77
+ logger.addHandler(handler)
78
+ estado = {"f": 0.0}
79
+
80
+ def progreso(f, msg):
81
+ if f is not None:
82
+ estado["f"] = f
83
+ barra.progress(min(f, 1.0), text=msg)
84
+ else:
85
+ detalle.caption(msg)
86
+
87
+ try:
88
+ with st.spinner("Descargando… (puede tardar varios minutos; para cancelar use **Stop** arriba a la derecha)"):
89
+ lanzar(progreso, None)
90
+ barra.progress(1.0, text="¡Listo!")
91
+ st.success("Descarga terminada. Ya puede analizar los datos en las demás páginas.")
92
+ st.cache_data.clear()
93
+ st.cache_resource.clear()
94
+ except Exception as e: # noqa: BLE001
95
+ st.error(f"La descarga falló: {e}. Revise su conexión e intente de nuevo; lo ya descargado se conserva.")
96
+ finally:
97
+ logger.removeHandler(handler)
98
+
99
+ with st.expander("🐍 Hacerlo desde Python o la terminal"):
100
+ st.code(f"import dane_data as dd\n{codigo_py}", language="python")
101
+ st.code("dane-data descargar geih --anios 2024 2025\ndane-data descargar eam --anios 2022 2023 2024", language="bash")
@@ -0,0 +1,62 @@
1
+ import pandas as pd
2
+ import streamlit as st
3
+
4
+ import dane_data as dd
5
+
6
+ st.title("📚 Diccionario de variables")
7
+ st.markdown(
8
+ "Busque variables por código o por palabras de la pregunta. Fuente: metadatos oficiales del DANE "
9
+ "(GEIH 2025 y EAM 2024). Los nombres pueden variar en años antiguos."
10
+ )
11
+
12
+ c1, c2 = st.columns([1, 3])
13
+ encuesta = c1.radio("Encuesta", ["GEIH", "EAM"], horizontal=True)
14
+ texto = c2.text_input("Buscar", placeholder="p. ej. pensión, arriendo, horas, salarios, P6920")
15
+
16
+ res = dd.buscar_variable(texto or "", encuesta.lower())
17
+ st.caption(f"{len(res)} variables")
18
+ evento = st.dataframe(res, hide_index=True, width="stretch", on_select="rerun", selection_mode="single-row",
19
+ column_config={"pregunta": st.column_config.TextColumn(width="large")})
20
+
21
+ filas = evento.selection.rows if evento and evento.selection else []
22
+ if filas:
23
+ var = res.iloc[filas[0]]["variable"]
24
+ info = dd.diccionario(encuesta.lower())[var]
25
+ st.subheader(var)
26
+ st.markdown(f"**{info['etiqueta']}**")
27
+ if info["pregunta"] and info["pregunta"] != info["etiqueta"]:
28
+ st.markdown(f"> {info['pregunta']}")
29
+ if info.get("modulos"):
30
+ st.caption("Módulo(s): " + ", ".join(m.replace(".NSDstat", "") for m in info["modulos"]))
31
+ if info["categorias"]:
32
+ st.markdown("**Categorías**")
33
+ st.dataframe(pd.DataFrame(list(info["categorias"].items()), columns=["código", "significado"]),
34
+ hide_index=True)
35
+ st.code(f'df = dd.{encuesta.lower()}.load(2024, columns=["{var if encuesta == "GEIH" else var.lower()}"])',
36
+ language="python")
37
+ else:
38
+ st.caption("👆 Seleccione una fila para ver sus categorías.")
39
+
40
+ with st.expander("Variables armonizadas por dane-data (iguales en todos los años)"):
41
+ if encuesta == "GEIH":
42
+ st.markdown("""
43
+ | Variable | Descripción |
44
+ |---|---|
45
+ | `anio`, `mes` | Año y mes de la encuesta |
46
+ | `fex` | Factor de expansión mensual (FEX_C18 / FEX_C / FEX_C_2011 según la época) |
47
+ | `sexo`, `edad`, `grupo_edad` | Sexo (P3271 o P6020), edad (P6040) y grupos de edad |
48
+ | `departamento`, `dpto`, `zona` | Departamento y zona urbana/rural (CLASE) |
49
+ | `condicion` | Ocupado / Desocupado / Inactivo / Fuera de la PET |
50
+ | `pet`, `fuerza_trabajo`, `ocupado`, `desocupado`, `inactivo` | Indicadoras 0/1 |
51
+ | `formal` | 1 si el ocupado está afiliado a salud (P6090) y cotiza a pensión (P6920) |
52
+ | `ingreso_laboral`, `ratio_smlv`, `smlv` | INGLABO, su razón frente al salario mínimo y el salario mínimo del año |
53
+ | `horas_semana` | Horas trabajadas normalmente (P6800) |
54
+ | `posicion_ocupacional` | P6430 con etiquetas |
55
+ | `nivel_educativo` | Ninguno / Primaria / Secundaria / Media / Superior (P3042 o P6210) |
56
+ | `rama2d`, `rama_rev` | Rama de actividad a 2 dígitos y su revisión CIIU (3 o 4) |
57
+ """)
58
+ else:
59
+ st.dataframe([{"variable": k, "descripción": v} for k, v in dd.eam.ETIQUETAS.items()]
60
+ + [{"variable": "ciiu / ciiu_rev", "descripción": "Código CIIU y su revisión (2, 3 o 4)"},
61
+ {"variable": "division / sector", "descripción": "CIIU a 2 dígitos y su nombre"},
62
+ {"variable": "departamento", "descripción": "Nombre del departamento"}], hide_index=True)
@@ -0,0 +1,129 @@
1
+ import pandas as pd
2
+ import streamlit as st
3
+
4
+ import dane_data as dd
5
+ from dane_data.app._ui import (MAX_SERIES, barras, botones_descarga, codigo, fmt_num, fmt_pct, grafico,
6
+ linea, selector_eam)
7
+
8
+ st.title("🏭 Indicadores de la industria manufacturera")
9
+ eam, descripcion, params = selector_eam(clave="eam")
10
+ st.caption(f"**Datos:** EAM {descripcion} · {fmt_num(len(eam))} registros establecimiento-año")
11
+
12
+ VARIABLES = {
13
+ "Valor agregado": ("valor_agregado", "monetaria"),
14
+ "Producción bruta": ("produccion_bruta", "monetaria"),
15
+ "Personal ocupado total": ("personal_total", "personas"),
16
+ "Productividad laboral (VA por persona)": ("productividad_laboral", "monetaria"),
17
+ "Remuneración media por empleado": ("remuneracion_media", "monetaria"),
18
+ "Participación laboral en el VA (%)": ("participacion_laboral", "pct"),
19
+ "Valor agregado / producción (%)": ("tasa_valor_agregado", "pct"),
20
+ "Número de establecimientos": ("establecimientos", "conteo"),
21
+ "Inversión bruta": ("inversion_bruta", "monetaria"),
22
+ "Activos fijos": ("activos_fijos", "monetaria"),
23
+ "Energía eléctrica (kWh)": ("energia_kwh", "conteo"),
24
+ }
25
+ DESAGREGAR = {"Total industria": None, "Sector (división CIIU)": "sector", "Departamento": "departamento"}
26
+
27
+ # ---------------------------------------------------------------- KPIs
28
+ tot = dd.eam.resumen(eam, por="anio")
29
+ u = tot.iloc[-1]
30
+ p = tot.iloc[-2] if len(tot) > 1 else None
31
+
32
+
33
+ def var(col, pp=False):
34
+ if p is None or pd.isna(p[col]) or p[col] == 0:
35
+ return None
36
+ return f"{u[col] - p[col]:+.1f} p.p." if pp else f"{100 * (u[col] / p[col] - 1):+.1f} % vs {int(p['anio'])}"
37
+
38
+
39
+ st.markdown(f"##### Resumen {int(u['anio'])}")
40
+ k = st.columns(5)
41
+ k[0].metric("Establecimientos", fmt_num(u.establecimientos), var("establecimientos"))
42
+ k[1].metric("Personal ocupado", fmt_num(u.personal_total), var("personal_total"))
43
+ k[2].metric("Valor agregado (billones $)", fmt_num(u.valor_agregado / 1e9, 1), var("valor_agregado"),
44
+ help="Pesos corrientes. Los microdatos de la EAM vienen en miles de pesos.")
45
+ k[3].metric("Productividad (millones $/persona)", fmt_num(u.productividad_laboral / 1e3, 1), var("productividad_laboral"),
46
+ help=dd.eam.GLOSARIO["Productividad laboral"])
47
+ k[4].metric("Participación laboral", fmt_pct(u.participacion_laboral), var("participacion_laboral", pp=True),
48
+ help=dd.eam.GLOSARIO["Participación laboral"])
49
+ st.divider()
50
+
51
+ # ---------------------------------------------------------------- Gráfico
52
+ c1, c2, c3 = st.columns([2, 2, 1.3])
53
+ nombre_var = c1.selectbox("Variable", list(VARIABLES))
54
+ nombre_por = c2.selectbox("Desagregar por", list(DESAGREGAR))
55
+ vista = c3.radio("Vista", ["Evolución", "Un año"], horizontal=True)
56
+ col, tipo = VARIABLES[nombre_var]
57
+ por = DESAGREGAR[nombre_por]
58
+
59
+ res = dd.eam.resumen(eam, por=["anio"] + ([por] if por else []))
60
+ # Escala para graficar: los microdatos vienen en miles de pesos.
61
+ POR_PERSONA = ("productividad_laboral", "remuneracion_media")
62
+ if tipo == "monetaria":
63
+ div, unidad = (1e3, "millones de $ por persona") if col in POR_PERSONA else (1e6, "miles de millones de $")
64
+ res["_y"] = res[col] / div
65
+ else:
66
+ unidad = None
67
+ res["_y"] = res[col]
68
+ if por:
69
+ res = res[res[por].notna()]
70
+ revs = eam.groupby("anio")["ciiu_rev"].first().nunique()
71
+ if por == "sector" and revs > 1 and vista == "Evolución":
72
+ st.warning("⚠️ Los años elegidos mezclan revisiones de la CIIU: los sectores cambian de definición y "
73
+ "las series se cortan. Elija años de una sola revisión (p. ej. 2012 en adelante).")
74
+
75
+ if vista == "Evolución":
76
+ indice = False
77
+ if tipo == "monetaria":
78
+ indice = st.toggle("Ver como índice (primer año = 100)",
79
+ help="Los valores están en pesos corrientes: incluyen inflación. El índice facilita "
80
+ "comparar crecimientos entre grupos de distinto tamaño.")
81
+ g = res.copy()
82
+ if por and g[por].nunique() > MAX_SERIES:
83
+ orden = g.groupby(por)[col].sum().sort_values(ascending=False).index.tolist()
84
+ elegidos = st.multiselect(f"Hay {len(orden)} grupos; elija hasta {MAX_SERIES}", orden, default=orden[:5],
85
+ max_selections=MAX_SERIES)
86
+ g = g[g[por].isin(elegidos)]
87
+ y, titulo = "_y", nombre_var + (f" ({unidad})" if unidad else "")
88
+ if indice:
89
+ base = g.sort_values("anio").groupby(por)[col].transform("first") if por else g[col].iloc[0]
90
+ g["indice"] = 100 * g[col] / base
91
+ y, titulo = "indice", f"{nombre_var} (índice, {g['anio'].min()} = 100)"
92
+ grafico(linea(g, x="anio", y=y, color=por, titulo_y=titulo, pct=tipo == "pct"))
93
+ else:
94
+ anio = st.select_slider("Año", sorted(res["anio"].unique()), value=res["anio"].max())
95
+ g = res[res["anio"] == anio]
96
+ if por:
97
+ grafico(barras(g, x=por, y="_y", titulo_y=nombre_var + (f" ({unidad})" if unidad else ""),
98
+ pct=tipo == "pct", horizontal=True))
99
+ if tipo in ("monetaria", "personas", "conteo") and col not in ("productividad_laboral", "remuneracion_media"):
100
+ top = g.sort_values(col, ascending=False).head(3)
101
+ share = 100 * top[col].sum() / g[col].sum()
102
+ st.caption(f"Los 3 primeros ({', '.join(top[por].astype(str))}) concentran el **{fmt_pct(share)}** "
103
+ f"del total en {anio}.")
104
+ else:
105
+ v = g[col].iloc[0]
106
+ st.metric(nombre_var + (f" ({unidad})" if unidad else ""),
107
+ fmt_pct(v) if tipo == "pct" else fmt_num(g["_y"].iloc[0], 1))
108
+
109
+ if tipo == "monetaria":
110
+ st.caption("💡 Valores en **pesos corrientes**: incluyen inflación (los microdatos vienen en miles de pesos). Para comparar en términos reales use un deflactor "
111
+ "(IPP o deflactor del PIB) con `dd.eam.deflactar` en Python.")
112
+
113
+ with st.expander("📋 Tabla completa"):
114
+ res = res.drop(columns="_y")
115
+ st.dataframe(res, hide_index=True, width="stretch",
116
+ column_config={"anio": st.column_config.NumberColumn("Año", format="%d")})
117
+ botones_descarga(res, f"resumen_eam_{params['anios'][0]}_{params['anios'][-1]}", "eamres")
118
+
119
+ with st.expander("📖 Glosario"):
120
+ for k_, v in dd.eam.GLOSARIO.items():
121
+ st.markdown(f"**{k_}.** {v}")
122
+
123
+ codigo(f'''
124
+ import dane_data as dd
125
+
126
+ {params["carga"]}
127
+ res = dd.eam.resumen(eam, por={["anio"] + ([por] if por else [])!r})
128
+ res
129
+ ''')
@@ -0,0 +1,56 @@
1
+ import streamlit as st
2
+
3
+ import dane_data as dd
4
+ from dane_data.app._ui import fmt_num, barras, botones_descarga, codigo, grafico, linea, selector_eam
5
+
6
+ st.title("🧱 Panel y microdatos EAM")
7
+
8
+ dic = dd.diccionario("eam")
9
+ with st.sidebar:
10
+ extra = st.multiselect(
11
+ "Variables DANE adicionales", sorted(v.lower() for v in dic if v.lower() not in dd.eam.CORE),
12
+ key="eamm_extra", format_func=lambda v: f"{v} — {dic.get(v.upper(), {}).get('etiqueta', '')[:60]}",
13
+ help="Por ejemplo c3r10c1 (sueldos de obreros) o c4r5c1. Nombres del diccionario 2024; "
14
+ "muchas variables de capítulo cambian de nombre entre épocas.",
15
+ )
16
+ eam, descripcion, params = selector_eam(extra=tuple(extra), clave="eamm")
17
+ st.caption(f"**Datos:** EAM {descripcion} · {fmt_num(len(eam))} registros")
18
+
19
+ tab1, tab2 = st.tabs(["🔁 Panel de establecimientos", "🗂️ Microdatos"])
20
+
21
+ with tab1:
22
+ st.markdown(
23
+ "Cada establecimiento tiene un identificador (`nordest`) que se repite año tras año, lo que permite "
24
+ "construir un **panel**. Un panel **balanceado** conserva solo las plantas observadas en *todos* los años."
25
+ )
26
+ sup = dd.eam.supervivencia(eam)
27
+ if len(sup):
28
+ grafico(linea(sup, x="anio", y="pct_siguen", titulo_y="% que reaparece al año siguiente", pct=True))
29
+ st.caption("Caídas bruscas suelen indicar cambios de identificador o de cobertura, no cierres reales.")
30
+ bal = st.toggle("Panel balanceado")
31
+ pan = dd.eam.panel(eam, balanceado=bal)
32
+ n_est = pan.index.get_level_values("nordest").nunique()
33
+ c = st.columns(3)
34
+ c[0].metric("Establecimientos", f"{n_est:,}".replace(",", "."))
35
+ c[1].metric("Años", pan.index.get_level_values("anio").nunique())
36
+ c[2].metric("Observaciones", f"{fmt_num(len(pan))}")
37
+ por_anio = pan.reset_index().groupby("anio").size().rename("establecimientos").reset_index()
38
+ grafico(barras(por_anio, x="anio", y="establecimientos", titulo_y="Establecimientos"))
39
+ if st.toggle("Preparar panel para descargar", key="pan_dl"):
40
+ botones_descarga(pan.reset_index(), f"panel_eam{'_balanceado' if bal else ''}", "pan")
41
+ codigo(f'''
42
+ import dane_data as dd
43
+
44
+ {params["carga"]}
45
+ panel = dd.eam.panel(eam, balanceado={bal}) # índice (nordest, anio), listo para linearmodels/statsmodels
46
+ dd.eam.supervivencia(eam)
47
+ ''')
48
+
49
+ with tab2:
50
+ st.markdown("Variables armonizadas: `anio`, `ciiu`, `ciiu_rev`, `division`, `sector`, `departamento`. "
51
+ "El resto son las originales del DANE en minúscula.")
52
+ with st.expander("¿Qué significa cada variable?"):
53
+ st.dataframe([{"variable": k, "descripción": v} for k, v in dd.eam.ETIQUETAS.items()], hide_index=True)
54
+ st.dataframe(eam.head(1000), hide_index=True, width="stretch")
55
+ if st.toggle("Preparar archivos para descargar", key="micro_dl"):
56
+ botones_descarga(eam, "microdatos_eam", "eammicro")
@@ -0,0 +1,219 @@
1
+ import numpy as np
2
+ import pandas as pd
3
+ import plotly.graph_objects as go
4
+ import streamlit as st
5
+
6
+ import dane_data as dd
7
+ from dane_data.app._ui import (fmt_num, MAX_SERIES, PALETA, _barra, _estilo, args_py, barras, botones_descarga,
8
+ codigo, fmt_pct, fmt_pesos, frecuencias_geih, grafico, muestra_geih, rutas_actuales,
9
+ seleccion_geih, tabla_geih)
10
+
11
+ st.title("🔎 Tablas y microdatos GEIH")
12
+
13
+ POBLACIONES = {
14
+ "Toda la población": None,
15
+ "En edad de trabajar (PET)": "pet",
16
+ "Fuerza de trabajo": "fuerza_trabajo",
17
+ "Ocupados": "ocupado",
18
+ "Desocupados": "desocupado",
19
+ "Inactivos": "inactivo",
20
+ }
21
+ CATEGORICAS = {
22
+ "Año": "anio", "Mes": "mes", "Sexo": "sexo", "Zona": "zona", "Grupo de edad": "grupo_edad",
23
+ "Nivel educativo": "nivel_educativo", "Departamento": "departamento",
24
+ "Condición de actividad": "condicion", "Posición ocupacional": "posicion_ocupacional",
25
+ "Formal (seguridad social)": "formal", "Jefe de hogar": "jefe_hogar",
26
+ "Rama de actividad (2 díg.)": "rama2d",
27
+ }
28
+ NUMERICAS = {
29
+ "Ingreso laboral": "ingreso_laboral", "Ingreso / salario mínimo": "ratio_smlv",
30
+ "Horas trabajadas por semana": "horas_semana", "Edad": "edad",
31
+ }
32
+
33
+ # Variables DANE adicionales: las del diccionario 2025 y las que dane-data lee siempre (CORE_RAW)
34
+ dic = dd.diccionario("geih")
35
+
36
+
37
+ def etiqueta(v: str, largo: int) -> str:
38
+ return f"{v} — {dic.get(v, {}).get('etiqueta', '')[:largo]}"
39
+
40
+
41
+ with st.sidebar:
42
+ extra = st.multiselect(
43
+ "Variables DANE adicionales", sorted(set(dic) | set(dd.geih.CORE_RAW)), key="exp_extra",
44
+ format_func=lambda v: etiqueta(v, 60),
45
+ help="Busque por código o por texto de la pregunta (p. ej. 'arriendo', 'P6500'). "
46
+ "Nombres según el diccionario 2025; en años antiguos algunas no existen.",
47
+ )
48
+ sel = seleccion_geih(clave="exp")
49
+ anios = tuple(sel["anios"])
50
+ # Las variables de CORE_RAW se leen siempre: pedirlas en `columns` solo crearía otra caché.
51
+ columnas = tuple(sorted(e for e in extra if e not in dd.geih.CORE_RAW))
52
+ # Todas las tablas salen de dd.geih.serie_*: año por año y guardadas en disco, nunca todos los años en memoria.
53
+ comunes = dict(filtros=sel["filtros"], meses=tuple(sel["meses"]) if sel["meses"] else None, columnas=columnas,
54
+ empalme=sel["empalme"], meses_disp=sel["meses_disponibles"], rutas=rutas_actuales())
55
+ args_comunes = args_py(filtros=sel["filtros"], meses=sel["meses"], columns=list(columnas), empalme=sel["empalme"])
56
+
57
+ pob = st.radio("Población", list(POBLACIONES), horizontal=True, index=3)
58
+ poblacion = POBLACIONES[pob]
59
+ n_total = int(tabla_geih(anios, (), None, "total", poblacion, dropna=False, **comunes)["n_muestra"].sum())
60
+ st.caption(f"**Datos:** {sel['descripcion']} · {pob.lower()} · {fmt_num(n_total)} personas en la muestra")
61
+ if not n_total:
62
+ st.warning("Ninguna persona cumple los filtros elegidos.")
63
+ st.stop()
64
+
65
+ # Un solo mes de microdatos: para saber el tipo de las variables adicionales y para la vista previa.
66
+ anio_m, mes_m = next((a, m) for a, m in sel["meses_disponibles"] if not sel["meses"] or m in sel["meses"])
67
+ variables = list(dict.fromkeys(["fex", *filter(None, POBLACIONES.values()), *CATEGORICAS.values(),
68
+ *NUMERICAS.values(), *extra]))
69
+ muestra = dd.geih.filtrar(muestra_geih(anio_m, mes_m, tuple(variables), columnas, sel["empalme"], rutas_actuales()),
70
+ sel["filtros"], poblacion)
71
+ for v in extra:
72
+ if v in muestra.columns:
73
+ if pd.api.types.is_numeric_dtype(muestra[v]) and muestra[v].nunique() > 15:
74
+ NUMERICAS[etiqueta(v, 40)] = v
75
+ else:
76
+ CATEGORICAS[etiqueta(v, 40)] = v
77
+
78
+ tab1, tab2, tab3 = st.tabs(["📊 Tabla ponderada", "💰 Ingresos y salario mínimo", "🗂️ Microdatos"])
79
+
80
+ # ---------------------------------------------------------------- Tabla ponderada
81
+ with tab1:
82
+ c1, c2, c3 = st.columns(3)
83
+ estad = c1.selectbox("¿Qué quiere calcular?", ["Distribución (%)", "Número de personas", "Promedio de…",
84
+ "Mediana de…"])
85
+ filas = c2.selectbox("Filas", list(CATEGORICAS), index=2)
86
+ cols_opts = ["(ninguna)"] + [k for k in CATEGORICAS if k != filas]
87
+ columnas_tabla = c3.selectbox("Columnas / comparar por", cols_opts, index=cols_opts.index("Año"))
88
+ variable = None
89
+ if estad in ("Promedio de…", "Mediana de…"):
90
+ variable = NUMERICAS[st.selectbox("Variable", list(NUMERICAS))]
91
+
92
+ f, c = CATEGORICAS[filas], (CATEGORICAS[columnas_tabla] if columnas_tabla != "(ninguna)" else None)
93
+ por = [c, f] if c else [f]
94
+ est = {"Distribución (%)": "porcentaje", "Número de personas": "total", "Promedio de…": "media",
95
+ "Mediana de…": "mediana"}[estad]
96
+ # dropna: sin las personas con valor vacío en filas o columnas (p. ej. la rama de quien no trabaja)
97
+ t = tabla_geih(anios, tuple(por), variable, est, poblacion, dropna=True, **comunes)
98
+
99
+ if t.empty:
100
+ st.info("No hay datos para esta combinación (puede ser una variable que esos años no existe).")
101
+ else:
102
+ if c:
103
+ t[c] = t[c].astype(str)
104
+ t[f] = t[f].astype(str)
105
+ if c and t[c].nunique() <= MAX_SERIES:
106
+ fig = barras(t, x=f, y=est, color=c, pct=est == "porcentaje", horizontal=t[f].nunique() > 8)
107
+ else:
108
+ fig = barras(t, x=f, y=est, pct=est == "porcentaje", horizontal=t[f].nunique() > 8)
109
+ grafico(fig)
110
+
111
+ if c:
112
+ pivot = t.pivot(index=f, columns=c, values=est)
113
+ st.dataframe(pivot.style.format("{:,.1f}" if est == "porcentaje" else "{:,.0f}"), width="stretch")
114
+ else:
115
+ st.dataframe(t, hide_index=True, width="stretch")
116
+ if (t["n_muestra"] < 100).any():
117
+ st.warning("⚠️ Algunas celdas tienen menos de 100 observaciones: interprételas con cautela.")
118
+ st.caption("Distribución (%): porcentaje de cada fila dentro de cada columna. Cifras ponderadas con el "
119
+ "factor de expansión (promedio mensual).")
120
+ botones_descarga(t, "tabla_geih", "tab")
121
+ codigo(f'''
122
+ import dane_data as dd
123
+
124
+ # Igual a dd.tabla(...) sobre los microdatos filtrados, pero año por año y guardado en caché.
125
+ t = dd.geih.serie_tabla({list(anios)!r}, por={por!r}{args_py(variable=variable)}\
126
+ {f', estadistico="{est}"' if est != "total" else ""}{args_py(poblacion=poblacion)}, dropna=True{args_comunes})
127
+ t
128
+ ''')
129
+
130
+ # ---------------------------------------------------------------- Ingresos
131
+ with tab2:
132
+ st.markdown("**Ocupados con ingreso laboral** (con los filtros de la barra lateral)")
133
+ freq = frecuencias_geih(anios, "ingreso_laboral", (), "ocupado", **{**comunes, "columnas": ()})
134
+ oc = freq[freq["ingreso_laboral"] > 0]
135
+ if oc.empty:
136
+ st.info("No hay ocupados con ingreso en la selección (la GEIH 2007 no trae ingresos laborales).")
137
+ else:
138
+ w = oc["peso"]
139
+ r = oc["ingreso_laboral"] / oc["anio"].map(dd.SMLV)
140
+ tramos = pd.cut(r, [0, 0.5, 0.95, 1.05, 1.22, 1.5, 2, 4, np.inf],
141
+ labels=["< 0,5", "0,5–0,95", "≈ 1 (0,95–1,05)", "1,05–1,22", "1,22–1,5", "1,5–2", "2–4",
142
+ "4 o más"])
143
+ dist = (w.groupby(tramos, observed=False).sum() / w.sum() * 100).rename("porcentaje").reset_index()
144
+ dist.columns = ["Salarios mínimos", "porcentaje"]
145
+
146
+ k = st.columns(3)
147
+ k[0].metric("Ingreso mediano", fmt_pesos(dd.weighted_quantile(oc["ingreso_laboral"], w, 0.5)))
148
+ k[1].metric("Ganan menos de 1 SMLV", fmt_pct(100 * w[r < 0.95].sum() / w.sum()))
149
+ k[2].metric("Ganan ≈ 1 SMLV (±5 %)", fmt_pct(100 * w[(r >= 0.95) & (r <= 1.05)].sum() / w.sum()),
150
+ help="La acumulación en el mínimo muestra cuánto 'muerde' el salario mínimo.")
151
+
152
+ st.markdown("**Distribución de los ocupados según su ingreso en salarios mínimos**")
153
+ grafico(barras(dist, x="Salarios mínimos", y="porcentaje", titulo_y="% de ocupados", pct=True))
154
+
155
+ st.markdown("**Histograma del ingreso / salario mínimo** (hasta 4 SMLV)")
156
+ hist, edges = np.histogram(r[r <= 4], bins=80, weights=w[r <= 4])
157
+ fig = go.Figure(go.Bar(x=(edges[:-1] + edges[1:]) / 2, y=100 * hist / w.sum(), marker_color=PALETA[0],
158
+ marker_line_width=0,
159
+ hovertemplate="%{x:.2f} SMLV: %{y:.2f} %<extra></extra>"))
160
+ fig.add_vline(x=1, line_dash="dash", line_width=1.5, line_color="gray",
161
+ annotation_text="1 salario mínimo", annotation_position="top right")
162
+ fig = _estilo(fig, "% de ocupados", pct=True)
163
+ fig.update_layout(bargap=0.05)
164
+ fig.update_xaxes(title="Ingreso laboral / salario mínimo del año")
165
+ grafico(fig)
166
+ st.caption("Salario mínimo usado por año: " +
167
+ ", ".join(f"{a}: {fmt_pesos(dd.SMLV.get(a))}" for a in anios))
168
+ codigo(f'''
169
+ import dane_data as dd
170
+
171
+ # Distribución ponderada del ingreso de los ocupados (año por año, guardada en caché)
172
+ f = dd.geih.serie_frecuencias({list(anios)!r}, "ingreso_laboral", poblacion="ocupado"\
173
+ {args_py(filtros=sel["filtros"], meses=sel["meses"], empalme=sel["empalme"])})
174
+ f = f[f.ingreso_laboral > 0]
175
+ ratio = f.ingreso_laboral / f.anio.map(dd.SMLV)
176
+ dd.weighted_quantile(f.ingreso_laboral, f.peso, 0.5) # ingreso mediano
177
+ 100 * f.peso[ratio < 0.95].sum() / f.peso.sum() # % que gana menos de 1 SMLV
178
+ ''')
179
+
180
+ # ---------------------------------------------------------------- Microdatos
181
+ with tab3:
182
+ st.markdown(f"Vista previa de **{dd.MESES[mes_m]} {anio_m}**, el primer mes de la selección. Las variables en "
183
+ "**minúscula** son armonizadas por `dane-data`; las de **MAYÚSCULA** son las originales del DANE "
184
+ "que elija en «Variables DANE adicionales».")
185
+ armonizadas = ["anio", "mes", "fex", "sexo", "edad", "grupo_edad", "departamento", "zona", "condicion",
186
+ "formal", "ingreso_laboral", "ratio_smlv", "horas_semana", "posicion_ocupacional",
187
+ "nivel_educativo", "rama2d"]
188
+ # Las variables adicionales se ofrecen aunque falten en este mes: en otros meses pueden estar.
189
+ todas = list(dict.fromkeys([*muestra.columns, *extra]))
190
+ elegidas = st.multiselect("Columnas", todas, default=[c for c in armonizadas + list(extra) if c in todas])
191
+ st.dataframe(muestra.reindex(columns=elegidas).head(1000), hide_index=True, width="stretch")
192
+ st.caption(f"{fmt_num(len(muestra))} filas en {dd.MESES[mes_m].lower()} de {anio_m}; "
193
+ f"{fmt_num(n_total)} en toda la selección.")
194
+ if st.toggle("Descargar este mes (CSV, Excel o Stata)"):
195
+ botones_descarga(muestra.reindex(columns=elegidas), f"microdatos_geih_{anio_m}_{mes_m:02d}", "micro")
196
+
197
+ st.markdown("**Exportar todos los meses elegidos**")
198
+ nombre = f"microdatos_geih_{anios[0]}" + (f"_{anios[-1]}" if len(anios) > 1 else "")
199
+ destino = (dd.get_path("geih_empalme" if sel["empalme"] else "geih") / "exportaciones"
200
+ / f"{nombre}{'_empalme' if sel['empalme'] else ''}.csv")
201
+ st.caption(f"Se escribe un CSV de {fmt_num(n_total)} filas en `{destino}`, un mes a la vez: no hace falta "
202
+ "tener todos los meses en memoria.")
203
+ if st.button("Exportar a CSV", key="exportar"):
204
+ barra, progreso = _barra("Exportando…")
205
+ filas = dd.geih.exportar(list(anios), destino, variables=elegidas, poblacion=poblacion,
206
+ filtros=sel["filtros"] or None, meses=sel["meses"], columns=list(columnas) or "core",
207
+ empalme=sel["empalme"], progress=progreso)
208
+ barra.empty()
209
+ st.success(f"Listo: {fmt_num(filas)} filas en `{destino}`.")
210
+ if destino.stat().st_size < 100e6:
211
+ st.download_button("⬇️ Descargar el CSV", destino.read_bytes(), destino.name, "text/csv")
212
+ codigo(f'''
213
+ import dane_data as dd
214
+
215
+ # Escribe los microdatos armonizados mes a mes, sin juntarlos en memoria
216
+ dd.geih.exportar({list(anios)!r}, "{destino.name}", variables={elegidas!r}{args_py(poblacion=poblacion)}{args_comunes})
217
+ ''')
218
+ st.caption("💡 `fex` es el factor de expansión **mensual**: si junta varios meses, divida por el número "
219
+ "de meses (o use `dd.geih.serie_tabla`, que lo hace solo).")