Trayectorias de movimiento ocupacional en la población boliviana y cómo mejoran o empeoran sus ingresos. Cómo ocupaciones en construcción y comercio elevan el nivel de vida de trabajadores agrícolas.
Con datos de panel de la Encuesta Contínua de Empleo, es posible comprender mejor las trayectorias de movimiento ocupacional en la población boliviana y sus retornos. En este trabajo identifico eventos donde una misma persona reporta el cambio de su ocupación principal entre dos periodos de tiempo. Agregando esta información es posible construir mapas de las opciones laborales más probables y disponibles a sujetos en una ocupación y con diferentes características demográficas, y discernir cómo estas diferentes opciones afectan positiva o negativamente sus ingresos. ¿Qué trayectorias ocupacionales específicas dirigen mejoras en la calidad de vida de la población boliviana?
import pandas as pd
import pdfplumber
import json
import datetime as dt
import matplotlib.pyplot as plt
from matplotlib import ticker, dates, cm, colors
import numpy as np
import re
from collections import ChainMap
from textwrap import wrap
import networkx as nx
import unicodedata
from IPython import display
import math
import plotly.graph_objs as go
from plotly.offline import download_plotlyjs, init_notebook_mode, plot, iplot
init_notebook_mode(connected=False)
pd.options.display.max_columns=500
pd.options.display.max_colwidth=500
pd.options.display.max_rows=100
plt.style.use('publish/estilo.mplstyle')
def get_caeb(pdf_filename):
"""
Construye un diccionario de los códigos y nombres en
la Clasificación de Actividades Económicas de Bolivia.
"""
pdf = pdfplumber.open(pdf_filename)
caeb = {}
for i, page in enumerate(pdf.pages[28:62]):
rows = page.extract_text().split('\n')
rows = rows[[i + 1 for i, row in enumerate(rows) if 'DESCRIPCIÓN' in row][0]:-1]
for row in rows:
categoria = re.findall('^[A-Z]\s\s\s\s+', row)
if len(categoria) > 0:
cat = categoria[0].strip()
caeb[cat] = []
actividad = re.findall('[0-9]+', row)
if len(actividad) > 0:
caeb[cat].append(actividad[0])
actividades = [{actividad:key for actividad in caeb[key]} for key in caeb.keys()]
actividades = dict(ChainMap(*actividades))
caeb = {**actividades, **{key:key for key in caeb.keys()}}
return caeb
def get_cob(pdf_filename):
"""
Construye un diccionario de los códigos y nombres en
la Clasificación de Ocupaciones de Bolivia 2009.
"""
pdf = pdfplumber.open(pdf_filename)
cob = {}
before_nombre = ''
for i, page in enumerate(pdf.pages[29:43]):
p = unicodedata.normalize(u'NFKD', page.extract_text()).encode('ascii', 'ignore').decode('utf8')
rows = re.split('\n(?=[\s0-9])', p)
rows = rows[[i + 1 for i, row in enumerate(rows) if 'DESCRIPCION' in row][0]:-1]
for row in rows:
codigo = re.findall('[0-9]+', row)[0]
nombre = re.findall('[A-Za-z][A-Za-z\s\/\;\,\.\:\(\)\[\]\{\}\-\_]+', row)[0].lower()
if '\n' in nombre:
nombre_split = nombre.split('\n')
nombre = ' '.join([before_nombre, nombre_split[0]])
before_nombre = ' '.join(nombre_split[1:])
else:
nombre = ' '.join([before_nombre, nombre])
before_nombre = ''
cob[codigo] = nombre.strip()
return cob
def flatten_cob(cob, level=2):
"""
Aplana entradas en el diccionario de ocupaciones a
un nivel específico para facilitar operaciones agregadas
"""
cob2 = {}
categories = [c for c in cob.keys() if len(c) == level]
i = -1
for c in cob.keys():
nivel = len(c)
if nivel == level:
i += 1
cob2[c] = cob[categories[i]]
elif nivel > level and c[:level] == categories[i]:
cob2[c] = cob[categories[i]]
elif nivel < level:
cob2[c] = cob[categories[i + 1]]
return cob2
def diccionarios(ocupaciones_nivel):
"""
Retorna los diccionarios necesarios para intepretar
valores en la encuesta.
"""
actividades = {
'A': 'agricultura, ganadería, caza , pesca y silvicultura',
'B': 'explotación de minas y canteras',
'C': 'industria manufacturera',
'D': 'suministro de electricidad, gas, vapor y aire acondicionado',
'E': 'suministro de agua; evacuación de aguas residuales, gestión de desechos y descontaminación',
'F': 'construcción',
'G': 'venta por mayor y por menor; reparación de vehículos automotores y motocicletas',
'H': 'transporte y almacenamiento',
'I': 'actividades de alojamiento y servicio de comidas',
'J': 'información y comunicaciones',
'K': 'intermediación financiera y seguros',
'L': 'actividades inmobiliarias',
'M': 'servicios profesionales y técnicos',
'N': 'actividades de servicios administrativos y de apoyo',
'O': 'administración pública, defensa y planes de seguridad social de afiliación obligatoria',
'P': 'servicios de educación',
'Q': 'servicios de salud y de asistencia social',
'R': 'actividades artísticas, de entretenimiento y recreativas',
'S': 'otras actividades de servicios',
'T': 'actividades de hogares privados como empleadores',
'U': 'servicios de organizaciones y órganos extraterritoriales'
}
niveles_educativos = {
10: 'ninguno y no sabe leer y escribir',
11: 'ninguno pero sabe leer y escribir',
12: 'programa de alfabetización',
13: 'educación inicial o pre-escolar (pre kinder/kinder)',
21: 'sistema escolar antiguo - básico (1 a 5 años)',
22: 'sistema escolar antiguo - intermedio (1 a 3 años)',
23: 'sistema escolar antiguo - medio (1 a 4 años)',
31: 'sistema escolar anterior - primaria (1 a 8 años)',
32: 'sistema escolar anterior - secundaria (1 a 4 años)',
41: 'sistema escolar actual - primaria (1 a 6 años)',
42: 'sistema escolar actual - secundaria (1 a 6 años)',
51: 'educación de adultos (sistema antiguo) - educación básica de adultos (eba)',
52: 'educación de adultos (sistema antiguo) - centro de educación media de adultos (cema)',
61: 'educación alternativa y especial - educación juvenil alternativa (eja)',
62: 'educación alternativa y especial - educación primaria para adultos (epa)',
63: 'educación alternativa y especial - educación secundaria para adultos (esa)',
64: 'educación alternativa y especial - programa nacional de post alfabetización',
65: 'educación alternativa y especial - educación especial',
71: 'educación superior - normal (escuela sup. de formación de maestros)',
72: 'educación superior - universidad pública (licenciatura)',
73: 'educación superior - universidad privada (licenciatura)',
74: 'educación superior - postgrado diplomado',
75: 'educación superior - postgrado maestría',
76: 'educación superior - postgrado doctorado',
77: 'educación superior - técnico de universidad',
78: 'técnico de instituto (duración mayor o igual a 1 año)',
79: 'institutos de formación militar y policial',
80: 'educación técnica de adultos (eta)',
81: 'otros cursos (duración menor a 1 año)'
}
deptos = {
1: 'Chuquisaca',
2: 'La Paz',
3: 'Cochabamba',
4: 'Oruro',
5: 'Potosí',
6: 'Tarija',
7: 'Santa Cruz',
8: 'Beni',
9: 'Pando'
}
caeb = get_caeb('data/CAEB_2011.pdf')
municipios = pd.read_csv('data/municipios.csv', index_col='cod_ine').municipio
cob = get_cob('data/COB_2009.pdf')
ocupaciones = flatten_cob(cob, ocupaciones_nivel)
return caeb, actividades, niveles_educativos, deptos, municipios, ocupaciones, cob
def format_data(dfi, caeb, actividades, niveles_educativos, deptos, municipios, ocupaciones):
"""
Formatea datos de la encuesta con los diccionarios y
las funciones disponibles.
"""
def dateformat_nacimiento(x):
if x[0] != ' ':
try:
return dt.datetime.strptime('{}-{}-{}'.format(x[0], x[1], x[2]), '%Y-%m-%d')
except Exception as e:
return None
else:
return None
def parse_año(dfi):
dfi['año'] = dfi['año'].astype(int)
return dfi
def parse_trimestre(dfi):
dfi['trimestre'] = dfi['trimestre'].astype(int)
return dfi
def parse_dep(dfi):
dfi['dep'] = dfi.dep.map(deptos)
return dfi
def parse_area(dfi):
dfi['area'] = dfi['area'].map({1: 'urbana', 2: 'rural'})
return dfi
def parse_sexo(dfi):
dfi['sexo'] = dfi['sexo'].map({1: 'hombre', 2: 'mujer'})
return dfi
def parse_nacimiento(dfi):
i = dfi.columns.tolist().index('nacimiento_año')
dfi.insert(i, 'nacimiento', dfi[['nacimiento_año', 'nacimiento_mes', 'nacimiento_dia']].apply(lambda x: dateformat_nacimiento(x), axis=1))
dfi.drop(columns=['nacimiento_año', 'nacimiento_mes', 'nacimiento_dia'], inplace=True)
return dfi
def parse_educacion(dfi):
dfi['educacion'] = dfi.educacion.apply(lambda x: int(x) if x != ' ' else None).map(niveles_educativos)
return dfi
def parse_años_estudio(dfi):
dfi['años_estudio'] = pd.to_numeric(dfi.años_estudio, errors='coerce')
return dfi
def parse_nivel_educativo(dfi):
dfi['nivel_educativo'] = pd.to_numeric(dfi.nivel_educativo, errors='coerce').map({0: 'ninguno', 1.0: 'primaria incompleta', 2.0: 'primaria completa', 3.0:'secundaria incompleta', 4.0:'secundaria completa', 5.0:'superior', 7.0:'otro'})
return dfi
def parse_trabaja_semana(dfi):
dfi['trabaja_semana'] = dfi.trabaja_semana.map({'1': 'si', '2': 'no'})
return dfi
def parse_trabaja(dfi):
dfi['trabaja'] = dfi.trabaja.map({'1': 'si', '2': 'no'})
return dfi
def parse_municipio(dfi):
dfi['municipio'] = pd.to_numeric(dfi.municipio, errors='coerce')
return dfi
def parse_actividad(dfi):
dfi['actividad'] = dfi['actividad'].map(caeb).map(actividades)
return dfi
def parse_ocupacion(dfi):
dfi['ocupacion'] = dfi['ocupacion'].map(ocupaciones)
return dfi
def parse_horas_semana(dfi):
dfi['horas_semana'] = pd.to_numeric(dfi.horas_semana, errors='coerce')
return dfi
def parse_tiempo(dfi):
dfi['tiempo_unidad'] = pd.to_numeric(dfi.tiempo_unidad, errors='coerce').map({2: 1/53, 4: 1/12, 8: 1})
dfi['tiempo'] = pd.to_numeric(dfi.tiempo, errors='coerce') * dfi.tiempo_unidad
dfi.drop(columns=['tiempo_unidad'], inplace=True)
return dfi
def parse_nit(dfi):
dfi['nit'] = dfi.nit.map({'1': 'regimen general', '2': 'regimen simplificado', '3': 'no tiene'})
return dfi
def parse_tamaño_empresa(dfi):
dfi['tamaño_empresa'] = pd.to_numeric(dfi.tamaño_empresa, errors='coerce')
return dfi
def parse_salario(dfi):
dfi['salario'] = pd.to_numeric(dfi.salario, errors='coerce')
dfi['salario_frecuencia'] = pd.to_numeric(dfi.salario_frecuencia, errors='coerce').map({1.0: 1.0, 2.0: 1/7, 3.0: 1/15, 4.0: 1/30, 5.0: 1/60, 6.0:1/90, 7.0:1/180, 8.0:1/365})
dfi['salario'] = dfi.salario * dfi.salario_frecuencia
dfi.drop(columns=['salario_frecuencia'], inplace=True)
return dfi
def parse_ingreso(dfi):
dfi['ingreso'] = pd.to_numeric(dfi.ingreso, errors='coerce')
dfi['ingreso_frecuencia'] = pd.to_numeric(dfi.ingreso_frecuencia, errors='coerce').map({1.0: 1.0, 2.0: 1/7, 3.0: 1/15, 4.0: 1/30, 5.0: 1/60, 6.0:1/90, 7.0:1/180, 8.0:1/365})
dfi['ingreso'] = dfi.ingreso * dfi.ingreso_frecuencia
dfi.drop(columns=['ingreso_frecuencia'], inplace=True)
return dfi
def parse_trabaja_semana_2(dfi):
dfi['trabaja_semana_2'] = dfi['trabaja_semana_2'].map({'1': 'si', '2': 'no'})
return dfi
def parse_trabaja_2(dfi):
dfi['trabaja_2'] = dfi['trabaja_2'].map({'1': 'si', '2': 'no'})
return dfi
def parse_ocupacion_2(dfi):
dfi['ocupacion_2'] = dfi['ocupacion_2'].map(ocupaciones)
return dfi
def parse_actividad_2(dfi):
dfi['actividad_2'] = dfi['actividad_2'].map(caeb).map(actividades)
return dfi
parsers = {
'año': parse_año,
'trimestre': parse_trimestre,
'dep': parse_dep,
'area': parse_area,
'sexo': parse_sexo,
'nacimiento_año': parse_nacimiento,
'educacion': parse_educacion,
'años_estudio': parse_años_estudio,
'nivel_educativo': parse_nivel_educativo,
'trabaja_semana': parse_trabaja_semana,
'trabaja': parse_trabaja,
'municipio': parse_municipio,
'actividad': parse_actividad,
'ocupacion': parse_ocupacion,
'horas_semana': parse_horas_semana,
'tiempo': parse_tiempo,
'nit': parse_nit,
'tamaño_empresa': parse_tamaño_empresa,
'salario': parse_salario,
'ingreso': parse_ingreso,
'trabaja_semana_2': parse_trabaja_semana_2,
'trabaja_2': parse_trabaja_2,
'ocupacion_2': parse_ocupacion_2,
'actividad_2': parse_actividad_2
}
for col in parsers.keys():
if col in dfi.columns:
dfi = parsers[col](dfi)
return dfi
def load_data(column_filter):
"""
Carga una serie de columnas de la encuesta, cambia sus nombres
a nombres más fáciles de interpretar y devuelve una tabla
con el orden de columnas provisto.
"""
column_names = {
'id_per_panel': 'id_panel',
'panel': 'panel',
'gestion': 'año',
'trimestre': 'trimestre',
'depto': 'dep',
'area': 'area',
's1_02': 'sexo',
's1_03ba': 'nacimiento_dia',
's1_03bb': 'nacimiento_mes',
's1_03bc': 'nacimiento_año',
's1_07a': 'educacion',
'aestudio': 'años_estudio',
'niv_ed': 'nivel_educativo',
's2_01': 'trabaja_semana',
's2_14': 'trabaja',
's2_39deptocod': 'municipio',
's2_16acod': 'actividad',
's2_15acod': 'ocupacion',
'phrs': 'horas_semana',
's2_19a_t': 'tiempo',
's2_19a_p': 'tiempo_unidad',
's2_23': 'nit',
's2_26': 'tamaño_empresa',
's2_33_v': 'salario',
's2_33_f': 'salario_frecuencia',
's2_38_v': 'ingreso',
's2_38_f': 'ingreso_frecuencia',
's2_42': 'trabaja_semana_2',
's2_43': 'trabaja_2',
's2_44acod': 'ocupacion_2',
's2_45acod': 'actividad_2'
}
dfi = pd.read_csv('data/encuesta_trabajo/ECE_4T15_1T21.csv', encoding='ISO-8859-1', delimiter=';', usecols=column_filter)[column_filter]
dfi.columns = [column_names[col] for col in column_filter]
return dfi
# Carga y formatea datos de la encuesta
column_filter = [
'id_per_panel',
'panel',
"gestion",
"trimestre",
"depto",
"area",
"s1_02",
"s1_03ba",
"s1_03bb",
"s1_03bc",
"s1_07a",
"aestudio",
"niv_ed",
"s2_01",
"s2_14",
"s2_39deptocod",
"s2_16acod",
"s2_15acod",
"phrs",
"s2_19a_t",
"s2_19a_p",
"s2_23",
"s2_26",
"s2_33_v",
"s2_33_f",
"s2_38_v",
"s2_38_f",
's2_42',
's2_44acod',
's2_45acod'
]
caeb, actividades, niveles_educativos, deptos, municipios, ocupaciones, cob = diccionarios(ocupaciones_nivel=5)
df = load_data(column_filter)
df = format_data(df, caeb, actividades, niveles_educativos, deptos, municipios, ocupaciones)
def get_eventos(dfi):
"""
Encuentra eventos donde una misma persona cambia de ocupación,
y devuelve una tabla con todos sus atributos en el periodo de cambio
más datos de ingreso, ocupación y actividad del periodo de su anterior
ocupación.
"""
dfi = dfi.sort_values(['año', 'trimestre'])
ocupacion_base = np.nan
for i, row in dfi.iterrows():
if type(ocupacion_base) != str:
if type(row.ocupacion) == str:
ocupacion_base = row.ocupacion
ingreso_base = row.ingreso
salario_base = row.salario
actividad_base = row.actividad
else:
if type(row.ocupacion) == str:
if row.ocupacion != ocupacion_base:
row_dict = row.to_dict()
row_dict['ocupacion_base'] = ocupacion_base
row_dict['ingreso_base'] = ingreso_base
row_dict['salario_base'] = salario_base
row_dict['actividad_base'] = actividad_base
eventos.append(row_dict)
ocupacion_base = row.ocupacion
ingreso_base = row.ingreso
salario_base = row.salario
actividad_base = row.actividad
# Encuentra eventos de cambio de ocupación entre individuos que participan de la encuesta de panel
no_panel = df.id_panel.value_counts()[df.id_panel.value_counts() == 1].index
eventos = []
for id_panel, dfi in df[(df.ocupacion.notna()) & ~(df.id_panel.isin(no_panel))].groupby('id_panel'):
get_eventos(dfi)
# Construye una tabla con todos los eventos, crea columnas de ganancia en el periodo anterior y actual al cambio, donde incluye
# valores disponibles de ingreso o salario, crea una columna `ganancia_delta` que representa la ganancia del periodo actual al cambio
# como porcentaje de la ganancia en el periodo anterior, para identificar la dirección de saltos en movilidad ocupacional. Además
# agrega columnas de edad para permitir algunos tipos de análisis.
edf = pd.DataFrame(eventos)
edf['ganancia_base'] = edf[['ingreso_base', 'salario_base']].apply(lambda row: row[0] if not math.isnan(row[0]) else row[1], axis=1)
edf['ganancia'] = edf[['ingreso', 'salario']].apply(lambda row: row[0] if not math.isnan(row[0]) else row[1], axis=1)
edf = edf[(edf.ganancia.notna()) & (edf.ganancia_base.notna())]
edf['ganancia_delta'] = edf['ganancia'] / edf['ganancia_base']
edf['edad'] = 2020 - edf.nacimiento.dt.year
edf['edad_range'] = pd.cut(edf.edad, bins=range(0,120,5), labels=range(0,115,5))
display.display(display.Markdown('Entre 2015 y el primer trimestre de 2021, identifico {} eventos. Un ejemplo de evento:'.format(len(edf))))
display.display(edf.sample()[['año', 'trimestre', 'dep', 'sexo', 'ocupacion_base', 'ocupacion', 'ganancia_base', 'ganancia', 'ganancia_delta']].T)
Entre 2015 y el primer trimestre de 2021, identifico 71801 eventos. Un ejemplo de evento:
38279 | |
---|---|
año | 2017 |
trimestre | 4 |
dep | Potosí |
sexo | mujer |
ocupacion_base | trabajadoras del hogar |
ocupacion | cocineros |
ganancia_base | 13.3333 |
ganancia | 16.6667 |
ganancia_delta | 1.25 |
def get_movilidad(edfi, min_sample=5):
"""
Agrega saltos entre cada par de ocupaciones, y retorna una tabla
con la ocupación en el periodo anterior, la ocupación a la cual
se traslada, el número de eventos con este salto particular y
el cambio promedio sobre la ganancia. Adicionalmente es posible
retornar sólo saltos con un número mínimo de eventos.
"""
dfi = edfi.groupby(['ocupacion_base', 'ocupacion'])
movilidad = pd.concat([dfi.size(), dfi.ganancia_delta.mean()], axis=1)
movilidad.columns = ['muestra', 'delta']
movilidad = movilidad.reset_index()
movilidad = movilidad[movilidad.muestra >= min_sample]
return movilidad
def reverse_cob(to_level=1):
"""
Retorna un diccionario para asociar cada valor de COB a valores
en un nivel de la nomenclatura. Es útil para agregar ocupaciones
en menos categorías.
"""
cob_rev = {cob[k]:k for k in cob.keys()}
cob_1 = flatten_cob(cob, to_level)
return pd.Series(cob_rev).map(cob_1).to_dict()
def ocupacion_sample(i=False):
if i == False:
i = mov.ocupacion_base.sample().iloc[0]
display.display(display.Markdown('Transiciones desde **{}**'.format(i.strip())))
di = mov[(mov.ocupacion_base == i) & (mov.muestra >= 5)].set_index('ocupacion')[['muestra', 'delta']].sort_values('delta', ascending=False)
di.delta = di.delta * 100
display.display(di.rename(columns={'muestra': 'Número de eventos', 'delta': '% de diferencia en ganancia'.format(i)}))
mov = get_movilidad(edf, 1)
ocupaciones_reverse = reverse_cob()
oc = 'trabajadoras del hogar'
display.display(display.Markdown('Estos eventos describen {} posibles transiciones entre pares de ocupaciones, que generan en promedio una ganancia correspondiente a {:.1%} de la ganancia en la anterior ocupación. Es fácil perderse en exploraciones sobre el universo de opciones laborales para diferentes tipos de personas. Por ejemplo, siguiendo el anterior ejemplo ¿cuáles son las transiciones más probables y rentables desde la ocupación de {}?:'.format(len(mov), mov.delta.mean(), oc)))
ocupacion_sample(oc)
Estos eventos describen 15179 posibles transiciones entre pares de ocupaciones, que generan en promedio una ganancia correspondiente a 156.6% de la ganancia en la anterior ocupación. Es fácil perderse en exploraciones sobre el universo de opciones laborales para diferentes tipos de personas. Por ejemplo, siguiendo el anterior ejemplo ¿cuáles son las transiciones más probables y rentables desde la ocupación de trabajadoras del hogar?:
Transiciones desde trabajadoras del hogar
Número de eventos | % de diferencia en ganancia | |
---|---|---|
ocupacion | ||
sastres, modistos y otros confeccionistas de prendas de vestir | 21 | 221.287993 |
vendedores en puestos fijos y moviles (vendedores en kioscos, puestos mercado y puestos moviles) | 47 | 208.250543 |
trabajadores de la elaboracion de productos de panaderia y pasteleria (panadero, pastelero, repostero) | 34 | 161.560653 |
cocineros | 222 | 160.162460 |
garzones, meseras y afines | 18 | 157.229447 |
embaladores o empacadores manuales (fraccionadores, etiquetadores y seleccionadores manuales) | 6 | 154.298077 |
trabajadores agricolas de cultivo de cereales (arroz, trigo, maiz, etc.) | 5 | 145.997391 |
vendedores en tiendas y almacenes | 58 | 141.751243 |
trabajadores en la elaboracion de bebidas | 15 | 138.402930 |
vendedores ambulantes de productos comestibles | 9 | 132.711640 |
limpiadores de viviendas, oficinas, hoteles y otras edificaciones | 421 | 128.129115 |
peluqueros y peinadores | 7 | 126.455857 |
otros trabajadores de la industria de alimentos n.c.p. | 6 | 125.790816 |
acompanantes y asistentes personales de sus empleadores (cuidadores de ancianos, discapacitados, etc.) | 16 | 125.790171 |
lavanderas y planchadoras manuales | 76 | 124.210588 |
secretarios en general | 11 | 119.864014 |
vendedores ambulantes de productos no comestibles | 6 | 111.761294 |
cuidadores de ninos y auxiliares de maestros (nineras) | 104 | 110.029740 |
ayudantes de cocina (lavaplatos, peladores de papa, etc.) | 33 | 107.548559 |
trabajadores de los cuidados personales en salud a domicilio (cuidador de salud de anciano a domicilio) | 7 | 87.568027 |
vendedores a domicilio (vendedor puerta a puerta) | 16 | 75.190591 |
tejedores de punto de prendas de vestir y accesorios del vestir | 13 | 71.446509 |
Sin embargo, para tomar una vista más completa del universo de transiciones ocupacionales construyo una red donde cada nodo es una ocupación y cada enlace una transición de una a otra ocupación que ocurre al menos 10 veces. Enlaces grises representan transiciones que mejoran ingresos en promedio y enlaces azules transiciones que los empeoran. Estos colores también se aplican a los bordes de cada nodo, donde nodos para ocupaciones que en promedio mejoran o empeoran ingresos son grises y azules respectivamente. El grosor de cada borde refleja cuán intensos son estos cambios. El tamaño de los nodos representa el número de eventos donde individuos adoptan cada ocupación. Finalmente, el color representa la clasificación de cada ocupación en el nivel más general de la Clasificación de Ocupaciones de Bolivia.
def plot_graph(mov, ocupaciones_reverse):
"""
Dibuja una red donde cada nodo es una ocupación y cada enlace un salto entre dos ocupaciones.
Si el efecto del salto es positivo sobre la ganancia en promedio, el enlace es gris, y si es
negativo el enlace es azul. Si saltos a una ocupación son en promedio positivos, el contorno
del nodo es gris, y si saltos son en promedio negativos el contorno es azul. El grueso del
contorno de cada nodo refleja cuán negativos o positivos son en promedio saltos a cada
ocupación. El tamaño de un nodo representa el número de eventos donde individuos saltan
a cada ocupación. El color de los nodos corresponde a la posición de la ocupación en el
nivel más alto de la clasificación COB.
"""
def delta_scale(mov):
delta_size = mov.groupby('ocupacion').delta.mean()
delta_size = pd.concat([1 - (1 / delta_size[delta_size < 1]), (delta_size[delta_size >= 1] - 1)])
delta_size.loc[delta_size < -1] = -1
delta_size.loc[delta_size > 1] = 1
return delta_size.to_dict()
def node_scale(mov):
muestras_destino = mov.groupby('ocupacion').muestra.sum()
return (np.log(muestras_destino) ** 2.4).to_dict()
ocupaciones_colores = {
'militares': '#b9c7e6',
'directivos de la administracion publica y empresas': '#d7c6f5',
'empleados de oficina': '#f2bfdc',
'profesionales cientificos e intelectuales': '#f5c0a9',
'tecnicos de nivel medio': '#edd6be',
'operadores de instalaciones, maquinarias y ensambladores': '#f7e7ab',
'trabajadores de la construccion, industria manufacturera y otros oficios':'#e7e8c3',
'trabajadores agricolas, pecuarios, forestales, acuicultores y pesqueros': '#bfd9ab',
'trabajadores de los servicios y vendedores': '#bcd2d4',
'trabajadores no calificados':'#e7e7e7'
}
linecolor = '#6e6e6e'
background = '#fcfcfc'
edgeline_colors = {True: '#c9c7c3', False: '#979cd1'}
nodeline_colors = {True: linecolor, False: '#5763e6'}
colormap = colors.ListedColormap(cm.get_cmap('PRGn')(np.linspace(.2,.8, 255)))
delta_size = delta_scale(mov)
muestras_destino = node_scale(mov)
f, ax = plt.subplots(1,1,figsize=(12,12), dpi=100)
f.set_facecolor(background)
g2 = nx.from_pandas_edgelist(mov[(mov.muestra >= 10)].rename(columns={'muestra': 'weight'}), 'ocupacion_base', 'ocupacion', ['weight', 'delta'], create_using=nx.DiGraph())
pos = nx.spring_layout(g2, k=1.2, iterations=30)
nodes = g2.nodes()
node_size = [muestras_destino[n] for n in nodes]
node_color = [ocupaciones_colores[ocupaciones_reverse[n]] for n in nodes]
nodeline_color = [nodeline_colors[ delta_size[n] > 0 ] for n in nodes]
nodeline_width = [abs(delta_size[n]) * 1.5 for n in nodes]
nx.draw_networkx_nodes(g2, pos, nodelist=nodes, node_size=node_size, node_color=node_color, ax=ax, edgecolors=nodeline_color, linewidths=nodeline_width)
edges_positive = [(u ,v) for u,v,e in g2.edges(data=True) if e['delta'] >= 1]
edges_negative = [(u ,v) for u,v,e in g2.edges(data=True) if e['delta'] < 1]
for edges, direction in zip([edges_positive, edges_negative], [True, False]):
pos_i = {**{u:pos[u] for (u,v) in edges}, **{v:pos[v] for (u,v) in edges}}
nx.draw_networkx_edges(g2, pos_i, edgelist=edges, alpha=.8, width=.3, ax=ax, edge_color=edgeline_colors[direction], arrowsize=5, connectionstyle="arc3,rad=0.1")
for ocupaciones_group, esquina, x, ha, xlim in zip([list(ocupaciones_colores.keys())[:5], list(ocupaciones_colores.keys())[5:]], [.457, .46], [.4, 1.6], ['right', 'left'], [(0,3), (-2,2)]):
cax = f.add_axes([esquina, -.045, .1, .15])
cax.scatter(x=len(ocupaciones_group) * [1], y=range(5), color=[ocupaciones_colores[oc] for oc in ocupaciones_group], s=100, linewidth=.8, edgecolor=linecolor)
cax.set_xlim(xlim)
cax.set_ylim(4.65,-.5)
for oc, position in zip(ocupaciones_group, range(5)):
cax.annotate('\n'.join(wrap(oc, 100)), xy=(x, position), xycoords='data', ha=ha, va='center', fontsize=8, color=linecolor, alpha=.8)
cax.set_axis_off()
filename = 'plots/transicion_ocupaciones.png'
f.savefig(filename, bbox_inches='tight', pad_inches=.4, dpi=200)
plt.show()
display.display(display.Markdown('<p class="caption"> <em>Transición entre ocupaciones en Bolivia (<a href="https://github.com/mauforonda/notas/raw/main/{}">imagen en alta resolución</a>) </em> </p>'.format(filename)))
plot_graph(mov, ocupaciones_reverse)
Transición entre ocupaciones en Bolivia (imagen en alta resolución)
Una característica de esta red es la centralidad de ocupaciones agrícolas, de construcción, operación de maquinaria y comercio. En el intercambio intenso entre estas ocupaciones destaca la posición desigual de trabajos agrícolas. Transiciones fuera de agricultura generan los cambios más positivos y frecuentes en la red. Esta observación podría reflejar movimientos temporales de ocupación, donde individuos trabajan en la tierra por unos meses y se dedican a la construcción y comercio en otros, guiados por la temporalidad y riesgo en la actividad agrícola. Es claro que transiciones fuera de ocupaciones agrícolas y hacia sectores como el comercio, que florecen en la informalidad e indiferencia estatal, se encuentran entre las intervenciones de desarrollo más importantes de la última media década.
Otra forma de observar este fenómeno es representando flujos entre tipos de ocupaciones. El siguiente diagrama representa el número de eventos y su efecto promedio sobre ganancias, para transciones entre cada tipo de ocupación. Cada franja representa transiciones de un tipo de ocupación, izquierda, hacia otra, derecha. Los colores de las barras que representan tipos de ocupación son iguales a los de la red. El ancho de una franja refleja el número de eventos de transición, el color indica si el efecto sobre ganancias es en promedio positivo (gris) o negativo (azul), y la intensidad de este color corresponde a la intensidad del efecto. Suavizo los colores en transiciones dentro del mismo tipo de ocupación. La gráfica es interactiva y muestra los valores en cada transición moviendo el cursor sobre ella.
def plot_sankey(mov, ocupaciones_reverse):
def nodes_links(m1):
linecolor = '#6e6e6e'
positive_colormap = colors.LinearSegmentedColormap.from_list("", ['#c9c7c3', linecolor])
negative_colormap = cm.get_cmap('BuPu')
ocupaciones_colores = {
'militares': '#b9c7e6',
'directivos de la administracion publica y empresas': '#d7c6f5',
'empleados de oficina': '#f2bfdc',
'profesionales cientificos e intelectuales': '#f5c0a9',
'tecnicos de nivel medio': '#edd6be',
'operadores de instalaciones, maquinarias y ensambladores': '#f7e7ab',
'trabajadores de la construccion, industria manufacturera y otros oficios':'#e7e8c3',
'trabajadores agricolas, pecuarios, forestales, acuicultores y pesqueros': '#bfd9ab',
'trabajadores de los servicios y vendedores': '#bcd2d4',
'trabajadores no calificados':'#e7e7e7'
}
mov['o1'] = mov.ocupacion_base.map(ocupaciones_reverse)
mov['o2'] = mov.ocupacion.map(ocupaciones_reverse)
nodes = pd.DataFrame(m1.o1.unique(), columns=['label'])
nodes['color'] = nodes.label.map(ocupaciones_colores)
nodes_len = len(nodes)
nodes = pd.concat([nodes, nodes]).reset_index(drop=True)
node_map = nodes.iloc[:nodes_len].reset_index().set_index('label')['index'].to_dict()
links = m1.copy()
delta_positive = (((links[links.delta >= 1].delta) - 1) ** .5)
delta_positive.loc[delta_positive > 2] = 2
delta_negative = (((1 / (links[links.delta < 1].delta)) - 1) ** .5)
delta_negative.loc[delta_negative > 2] = 2
colores = []
maxvalue = pd.concat([delta_positive, delta_negative]).max()
for delta_i, colormap in zip([delta_positive, delta_negative], [positive_colormap, negative_colormap]):
colores.extend([colors.to_hex(colormap(i)) for i in delta_i.div(maxvalue).tolist()])
links['color'] = colores
links.loc[links.o1 == links.o2, 'color'] = links.loc[links.o1 == links.o2, 'color'] + '1A'
links.loc[links.o1 != links.o2, 'color'] = links.loc[links.o1 != links.o2, 'color'] + '80'
links['delta_string'] = links.delta.apply(lambda x: '{:.1%}'.format(x))
for name, col, section in zip(['desde', 'hacia'], ['o1', 'o2'], [nodes.iloc[nodes_len:], nodes.iloc[:nodes_len]]):
links[name] = links[col].map(section.reset_index().set_index('label')['index'].to_dict())
return nodes, links
m1 = pd.concat([mov[(mov.muestra >= 10)].groupby(['o1', 'o2']).muestra.sum(), mov[(mov.muestra >= 10)].groupby(['o1', 'o2']).delta.mean()], axis=1).sort_values('delta', ascending=False).reset_index()
nodes, links = nodes_links(m1)
linecolor = '#6e6e6e'
background = '#fcfcfc'
data_trace = dict(
type = 'sankey',
domain = dict(x=[0,1], y=[0,1]),
textfont=dict(color="rgba(0,0,0,0)", size=1),
orientation = 'h',
valueformat = '.0f',
node = dict(
pad = 10,
thickness = 10,
line = dict(color=linecolor, width=.8),
label = nodes.label,
color = nodes.color,
customdata = nodes.label,
hovertemplate = '<b>%{customdata}</b><extra></extra>',
hoverlabel = {'align': 'left', 'bordercolor': linecolor, 'font': {'family': 'NYTMag Sans WEB', 'color': linecolor, 'size': 13}},
),
link = dict(
source = links.desde,
target = links.hacia,
value = links.muestra,
color = links.color,
customdata = links.delta_string,
hovertemplate = '<b>%{value}</b> eventos<br />desde <b>%{source.customdata}</b><br />hasta <b>%{target.customdata}</b><br />que multiplican ganancias por <b>%{customdata}</b><extra></extra>',
hoverlabel = {'align': 'left', 'bgcolor': background, 'bordercolor': linecolor, 'font': {'family': 'NYTMag Sans WEB', 'color': linecolor, 'size': 10}}
)
)
layout = dict(
height = 900,
# width = 600,
font = dict(size=10),
paper_bgcolor=background,
plot_bgcolor=background,
margin = {'l': 30, 'r': 30, 't': 30, 'b': 30}
)
config = dict(
displayModeBar = False,
responsive = True
)
iplot({'data': [data_trace], 'layout':layout}, config=config, validate=False)
plot_sankey(mov, ocupaciones_reverse)
Finalmente, construyo una visualización interactiva para explorar la frecuencia y rentabilidad de cambios entre ocupaciones. En este gráfico, el ancho de cada franja representa la frecuencia de cada transición y el color refleja cuán rentable es en promedio, donde colores más cálidos y fríos corresponden a cambios más positivos o negativos respectivamente. Por limitaciones de plotly, la librería que utilizo para realizar la visualización, la interacción no es completamente funcional en dispositivos móviles.
def compact_transiciones(mov, min_transiciones=10):
movi = mov.copy()
m = movi.groupby('ocupacion_base').muestra.sum()
movi = movi[movi.ocupacion_base.isin(m[m > min_transiciones].index)]
colormap = colors.ListedColormap(cm.get_cmap('PuOr_r')(np.linspace(.2,.8, 255)))
divnorm = colors.TwoSlopeNorm(vmin=0.33, vcenter=1, vmax=3)
scaled_cm = cm.ScalarMappable(norm=divnorm, cmap=colormap)
movi['c'] = movi.delta.apply(lambda x: colors.to_hex(scaled_cm.to_rgba(x)))
transiciones = []
for i, dfi in movi.groupby('ocupacion_base'):
dfi.insert(0, 'p', (dfi.muestra.div(dfi.muestra.sum())))
t = {'o': i}
dfi = dfi.rename(columns = {'ocupacion': 'o', 'delta': 'd'})
t['t'] = dfi.sort_values('p', ascending=False)[['o', 'p', 'd', 'c']].to_dict(orient='records')
transiciones.append(t)
return transiciones
def sankey_parameters(transicion):
linecolor = '#6e6e6e'
background = '#fcfcfc'
node_color = '#f1f1f1'
data_trace = dict(
type = 'sankey',
domain = dict(x=[0,1], y=[0,1]),
textfont= dict(color="rgba(0,0,0,0)", size=1),
orientation = 'h',
valueformat = '.0f',
node = dict(
pad = 10,
thickness = 10,
line = dict(color=linecolor, width=.5),
label = [transicion['o']] + [t['o'] for t in transicion['t']],
color = [node_color] + [t['c'] for t in transicion['t']],
customdata = [transicion['o']] + [t['o'] for t in transicion['t']],
hovertemplate = '<b>%{customdata}</b><extra></extra>',
hoverlabel = {'align': 'left', 'bgcolor': background, 'bordercolor': linecolor, 'font': {'family': 'Roboto', 'color': linecolor, 'size': 15}},
),
link = dict(
source = [0] * (len(transicion['t'])),
target = [i+1 for i in range(len(transicion['t']))],
value = [t['p'] for t in transicion['t']],
color = [t['c'] for t in transicion['t']],
customdata = [t['d'] for t in transicion['t']],
hovertemplate = 'Transición a <b>%{target.customdata}</b> <br />que ocurre en <b>%{value:.1%} de los casos</b><br />y multiplica ingresos por <b>%{customdata:.1%}</b><extra></extra>',
hoverlabel = {'align': 'left', 'bgcolor': background, 'bordercolor': linecolor, 'font': {'family': 'Roboto', 'color': linecolor, 'size': 13}}
)
)
return data_trace
def plot_sankey_all(transiciones):
template = """<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@700&family=Roboto:wght@400;700&display=swap" rel="stylesheet">
<script data-goatcounter="https://mauforonda.goatcounter.com/count"
async src="//gc.zgo.at/count.js"></script>
<script src="https://cdn.plot.ly/plotly-latest.min.js"></script>
<style type="text/css">
html {{
font-family: 'Roboto', sans-serif;
display: flex;
justify-content: center;
background: #fcfcfc;
min-height: 98vh;
}}
body {{
font-family: 'Roboto', sans-serif;
max-width: 1500px;
width: 100%;
overflow: scroll;
margin:0;
}}
.heading {{
padding: 20px;
height: 5%;
}}
.heading_title {{
font-size: 2.5rem;
font-weight: bold;
text-transform: uppercase;
text-align: center;
}}
.heading_title a {{
color: #cedbe6;
text-decoration: unset;
}}
.heading_title a:hover{{
color: #bcc6f4;
}}
.heading_subtitle {{
font-size: .8rem;
text-align: center;
color: #cedbe6;
}}
.footer {{
padding: 20px;
border-top: 1px solid #e3e6ea;
height: auto;
}}
.contact {{
text-align: center;
}}
.contacto_tipo {{
padding: 10px;
font-size: .7rem;
color: #a2b1bd;
opacity: .7;
}}
.contacto_valor {{
font-size: .7rem;
color: #a2b1bd;
}}
.warning {{
display: none;
padding: 10px 20px;
border: 1px solid #baa9f7;
border-radius: 5px;
color: #797197;
margin: 20px 5px;
background: #e3def4;
text-align: center;
font-size: 12px;
}}
.content {{
display: flex;
flex-direction:row;
align-items: stretch;
height: 85%
}}
.content div:nth-of-type(2){{
flex-grow:1;
}}
.context {{
color: #6e6e6e;
text-align: left;
font-size: 13px;
font-style: italic;
max-width: 20%;
min-width: 200px;
margin-bottom: auto;
margin-top: 12%;
}}
.context p {{
margin: 15px;
text-align: center;
}}
@media only screen and (max-width: 610px) {{
.warning {{
display: block;
}}
.context {{
display: none;
}}
.heading {{
height: 15%;
}}
}}
</style>
</head>
<body>
<div class="heading">
<div class="heading_title"><a href="https://mauforonda.github.io/notas/">Notas</a></div>
<div class="heading_subtitle">de mauforonda</div>
<div class="warning">La interacción no está soportada en dispositivos móviles</div>
</div>
<div class="content">
<div class="context"><p>Cada franja es una transición entre 2 ocupaciones, de izquierda a derecha.</p><p>El grosor indica cuán frecuente es cada transición y el color cuán positivos son sus retornos. Colores más cálidos corresponden a retornos más positivos.</p><p>En base a todas las transiciones entre ocupaciones registradas en datos de panel de la Encuesta Contínua de Empleo entre 2015 y 2021, publicada por el INE.</p><p>Para más información sobre cómo se produce este gráfico y hallazgos relacionados ver <a href="https://mauforonda.github.io/notas/transicion_entre_ocupaciones.html">Transición entre ocupaciones</a>.</p></div>
{}
</div>
<div class="footer">
<div class="contact">
<span class="contacto_tipo">contacto</span>
<span class="contacto_valor">mauriforonda@gmail.com</span>
</div>
</body>
</html>"""
background = '#fcfcfc'
linecolor = '#6e6e6e'
layout = dict(
template="plotly_white",
font = dict(size=10),
paper_bgcolor=background,
plot_bgcolor=background,
margin = {'l': 30, 'r': 30, 't': 120, 'b': 30},
title = {'font': {'family': 'Roboto', 'color': linecolor, 'size': 20}, 'text': '<b>CAMBIOS</b> entre <b>OCUPACIONES</b>', 'y': .98, 'x': 0.03}
)
config = dict(
displayModeBar = False,
responsive = True,
)
buttons = [{'method': 'animate', 'label': t['o'], 'args': [{'data': [sankey_parameters(t)]}]} for t in transiciones]
updatemenus = [dict(
buttons = buttons,
bgcolor = background,
pad={"r": 10, "t": 10},
showactive=False,
x=0,
xanchor="left",
y=1.1,
yanchor="top",
font = {
'family': 'Roboto',
'color': linecolor,
'size': 12
}
)]
data_trace = sankey_parameters(transiciones[0])
f = go.Figure({'data': [data_trace], 'layout':layout})
f.update_layout(
updatemenus=updatemenus,
)
html = template.format(f.to_html(config=config, full_html=False, div_id='plotlee'))
with open('interactivo/cambios_de_ocupacion.html', 'w+') as f:
f.write(html)
transiciones = compact_transiciones(mov)
plot_sankey_all(transiciones)