#!/usr/bin/env python # coding: utf-8 # # SysArmy Surveys: Análisis por género de los resultados de todas las encuestas # # Datos actualizados al 2do período de 2017 # # NOTA: Los primeros dos datasets no contienen información sobre género. # # NOTA2: Los sueldos indicados son llevados a bruto, es decir, algunos encuestados optaron por informar sueldos netos, estos valores netos fueron llevados a bruto mediante un cálculo simple pero a la vez impreciso. Es importante aclarar que la imprecisión de la conversión tiende a la baja del salario, ya que no se tuvieron en cuenta descuentos por ganancias u otros beneficios. # # ### Datasets de sueldos # # | Period | Path | URL | # |---------|------------------|-------------------------------------------------------------------------------------------------------------| # | ~~2014.02~~ | data/2014.02.csv | https://drive.google.com/open?id=0B7UapTwn9AahWkdEWnltRjUwVjQ | # | ~~2015.01~~ | data/2015.01.csv | https://drive.google.com/open?id=0B7UapTwn9AahSHl1aGNfTlNrYWc | # | 2016.01 | data/2016.01.csv | https://drive.google.com/open?id=0B7UapTwn9AahLTJmSE5zOUQ0aGc | # | 2016.02 | data/2016.02/ | https://drive.google.com/open?id=0B7UapTwn9AahUGtySFBjdHBCcWs | # | 2017.01 | data/2017.01/ | https://docs.google.com/spreadsheets/d/1diZG7ruHJ4OgO2-tbnO-ZnhjxjOnYjMVSxl7I-4LT54/edit#gid=1424918437 | # | 2017.02 | data/2017.02/ | https://docs.google.com/spreadsheets/d/1f1CAmzbVtoL66IwXJWEf0Mn-QbMNGZIoCQ_b1HB-91E | # # In[1]: import pandas as pd import numpy as np import re from matplotlib import cm from functools import reduce from IPython.display import display, Markdown as md def getPeriodDF(period, publish_date, csv_path, columns_src, columns_dst, transforms = []): # given a csv_path, extract src columns into dst columns by applying a transform function for a certain period name df_src = pd.read_csv(csv_path) df_dst = pd.DataFrame(columns=['period', 'publish_date']+columns_dst) df_dst[columns_dst] = df_src[columns_src] df_dst['period'] = period df_dst['publish_date'] = pd.to_datetime(publish_date) # apply custom transformations for transform in transforms: df_src, df_dst = transform(df_src, df_dst) return df_dst def transformNormalize(df_src, df_dst): # cast to float the salary column, some datasets gets wrongly parsed due to crappy user input df_dst['salary'] = df_dst['salary'].astype(float) df_dst['age'] = df_dst['age'].astype(str) # normalize stack column re_separator = ' *, *| *; *| *\. *| *\| *| *\/ *| *- *' re_garbage = '"""|\*|nan|=' df_dst['stack'] = df_dst['stack'].apply(lambda s: re.split(re_separator, re.sub(re_garbage, '', str(s)).strip().lower())) # munge the dataset, removing entries that we consider not realistic for our analysis munging_mask = (df_dst.salary > 5000) & (df_dst.salary < 300000) df_src = df_src.loc[munging_mask] df_dst = df_dst.loc[munging_mask] return df_src, df_dst def transformNet2Brute(df_src, df_dst): # filter wether net or brute income mask = np.array(df_src['Bruto o neto?'].str.contains('Neto'), dtype = bool) net_factor = np.array([1.0] * len(df_dst)); # scales up net salary into brute, given Argentina's social security contributions net_factor[ mask ] = 1/0.83 df_dst['salary'] = net_factor * df_dst['salary'] return df_src, df_dst def transformNet2Brute201702(df_src, df_dst): # filter wether net or brute income mask = np.array(df_src['¿Porcentaje, bruto o neto?'].str.contains('Neto'), dtype = bool) net_factor = np.array([1.0] * len(df_dst)); # scales up net salary into brute, given Argentina's social security contributions net_factor[ mask ] = 1/0.83 df_dst['salary'] = net_factor * df_dst['salary'] return df_src, df_dst src_common_cols = [ 'Tengo', 'Años de experiencia', 'Años en el puesto actual', 'Trabajo de', 'Tecnologías que utilizás'] dst_cols = [ 'salary', 'location', 'gender', 'age', 'yoe', 'yip', 'role', 'stack'] df3 = getPeriodDF('2016.01', '2016-02-01', '../data/2016.01.csv', ['Salario mensual (AR$)', 'Trabajo en', 'Soy'] + src_common_cols, dst_cols, [transformNormalize, transformNet2Brute]) df4 = getPeriodDF('2016.02', '2016-08-01', '../data/2016.02/argentina.csv', ['Salario mensual (en tu moneda local)', 'Argentina', 'Soy'] + src_common_cols, dst_cols, [transformNormalize, transformNet2Brute]) df5 = getPeriodDF('2017.01', '2017-02-01', '../data/2017.01/argentina.csv', ['Salario mensual (en tu moneda local)', 'Argentina', 'Soy'] + src_common_cols, dst_cols, [transformNormalize, transformNet2Brute]) df6 = getPeriodDF('2017.02', '2017-09-01', '../data/2017.02/argentina.csv', ['Salario mensual (en tu moneda local)', 'Argentina', 'Me identifico'] + src_common_cols, dst_cols, [transformNormalize, transformNet2Brute201702]) # compute the union of all the datasets as a signe pandas dataframe df = pd.concat([df3, df4, df5, df6]) # extract the list of periods periods = df.period.unique() # extract the list of genders genders = df.gender.unique() # extract the list of yoes yoes = df.yoe.unique() display(md('## Géneros censandos en los datasets')) [display(md('- %s (%d total)' % (g, len(df.query('gender == "%s"' % g))))) for g in genders] # In[2]: # define arbitrary ranges for seniority yoes_jr = ['1 - 2', 'Menos de un año', '1', '2', '3'] yoes_ssr = ['3 - 5', '3', '4', '5', '6'] yoes_sr = ['5 - 7', '8 - 10', '5', '6', '7', '8', '9', '10', '10+'] yoes_ranges = [yoes_jr, yoes_ssr, yoes_sr] yoes_ranges_names = ['Juniors', 'Semi-Seniors', 'Seniors'] display(md(""" ## Años de experiencia El seniority fue simplificado en tres niveles, de los cuales se seleccionaron arbitrariamente los rangos que conforman estos niveles. Para evitar discrepancias, se sobrelaparon rangos en los diferentes niveles. Por ejemplo, un desarrollador con 3 años de experiencia puede considerarse Junior ó Semi-Senior a la vez, eso dependerá de cada caso. ### Sobre la categoría "Otros" Al parecer, solo se registra el grupo "Otros" en el último dataset, donde se lo habría incluído. Por la cantidad baja de resultados, y la ausencia de dimensión tiempo en los datasets, resulta imposible visualilzar dichos resultados. A continuación se detalla las cantidades por segmento y período de la muestra. """)) for i, gender in enumerate(genders): display(md('### %s' % gender)) for j, yoes_range in enumerate(yoes_ranges): display(md('#### %s' % yoes_ranges_names[j])) [display(md('- %s año(s) (%d total)' % (y, len(df.query('gender == @gender & yoe in @y'))))) for y in yoes_range] # In[3]: import matplotlib.patches as mpatches import matplotlib.pyplot as plt get_ipython().run_line_magic('matplotlib', 'inline') plt.style.use(['classic', 'ggplot']) plt.rcParams.update({'figure.figsize': (10.0, 6.0), 'font.size': 8}) genders_colors = cm.rainbow(np.linspace(0, 1, len(genders))) publish_dates = np.unique(df['publish_date']) y = [] for i, gender in enumerate(genders): gender_y = [] for j, date in enumerate(publish_dates): count = df.query('gender == @gender & publish_date == @date').count()[0] gender_y.append(count) y.append(gender_y) # define data points for stackplot x = publish_dates y = np.row_stack(y) percent = np.divide(y, y.sum(axis=0).astype(float)*0.01) fig = plt.figure() ax = fig.add_subplot(111) ax.stackplot(x, percent, colors=genders_colors) ax.set_title('Nivel de participación en las encuestas por género') ax.set_ylabel('Porcentaje (%)') display(md('## Participación en las encuestas por género')) # creating the legend manually plt.legend([mpatches.Patch(color=genders_colors[i]) for i, gender in enumerate(genders)], genders, bbox_to_anchor=(1.05, 1), loc=2, borderaxespad=0.) # In[4]: get_ipython().run_line_magic('matplotlib', 'inline') def getPlotSalarySeries(currencySeries): s = currencySeries.apply(lambda x: float('{0:.2f}'.format(round(x/1000, 1)))) mu, sigma = np.mean(s), np.std(s) median = np.median(s) t = np.linspace(mu - 3*sigma, mu + 3*sigma, 1000) d = sigma**2/mu return mu, sigma, t, d, median, s plt.rcParams.update({'figure.figsize': (10.0, 24.0), 'font.size': 8}) yoes_colors = cm.rainbow(np.linspace(0, 1, 2*len(yoes_ranges))) dfnd = [] fig, ax = plt.subplots(len(yoes_ranges), 1) for i, yoe_range in enumerate(yoes_ranges): for j, gender in enumerate(genders): mus, sigmas, dates = [], [], [] for period in periods: dft = df.query('gender == @gender & period == @period & yoe in @yoe_range') if len(dft) == 0: continue mu, sigma, t, d, m, s = getPlotSalarySeries(dft.salary) mus.append(mu) sigmas.append(sigma) dates.append(np.max(dft['publish_date'])) dfnd += [[gender, yoes_ranges_names[i], period, mu, sigma]] ax[i].plot(dates, mus, '+', label='$\mu$ %s' % gender, ls='-', c=yoes_colors[j]) ax[i].plot(dates, sigmas, '+', label='$\sigma$ %s' % gender, ls=':', c=yoes_colors[j]) ax[i].set_title('%s %s' % (yoes_ranges_names[i], yoe_range)) ax[i].set_xlabel('date'); ax[i].set_ylabel('ingresos brutos [1000 AR$/mo]') ax[i].legend(bbox_to_anchor=(1.05, 1), loc=2, borderaxespad=0.) fig.canvas.draw() # ## Conclusión # # Como es de público conocimiento, en la Industria del Software la desigualdad de género es muy remarcada. Se puede observar sin embargo, una leve tendencia a revertir esta situación (ver primer gráfico). Aunque cabe aclarar, como ya se presentó en el [análisis previo](https://github.com/gerardobort/sysarmy-data/blob/master/notebooks/intra_period_argentina.ipynb), existe una variación del espacio muestral en las encuestas de `SysArmy` a través del tiempo, lo cual puede interferir en los resultados. # # Por otra parte, los niveles de ingresos censados presentan marcadas diferencias, sobre todo para puestos Seniors. Este dato es también conocido en Argentina y el resto del mundo, y pudo corroborarse. Se puede observar también, que la brecha salarial entre distíntos géneros no tiende a cerrarse en el caso de Seniors, sino por el contrario se ensancha. Finalmente, para puestos de nivel medio, sí se observa una mejora en la reducción de la brecha salarial. # In[5]: display(md(""" ## Bonus track Detalle de sueldos por período, seniority, y género. Véase también [CESSI dio a conocer el salario de los Programadores en Argentina](http://www.cessi.org.ar/ver-noticias-cessi-dio-a-conocer-el-salario-de-los-programadores-en-argentina-2129) """)) dfn = pd.DataFrame(dfnd, columns=['Género', 'Seniority', 'period', 'Salario Bruto mensual', 'Desviación estándar']) dfn for i, period in enumerate(reversed(periods)): display(md('### %s' % period)) display(dfn.query('period == @period'))