En este proyecto se realiza el analisis y limpieza de un conjunto de datos para construir un modelo de MAchine Learning y finalmente desarrollar una aplicacion web denominada Prediccion de Salarios para Desarrolladores de Software
, la tarea principal de la aplicación es predecir el salario en funcion de algunos datos que el usuario debe ingresar.
Para realizar el analisis, lectura y limpieza del conjunto de datos vamos a utilizar las librerias numpy y pandas.
Para la visualizacion de los datos seleccionados utilizaremos la libreria matplotlib.
La construccióin del modelo de machine Learning lo realizaremos con la libreria scikit-learn
Y finalmente la implementacion y construcción de la aplicación web usaremos la libreria Streamlit
El dataset sobre el cual se trabajará, es sobre una encuesta pública a desarrolladores de software que descargamos desde la página .... y el nombre del archivo dataset que usaremos es: survey_results_public.csv ubicado dentro de la carpeta del proyecto. El dataset es de una encuesta pública abierta del año 2020, contiene informacion de 65000 desarrolladores diferentes.
#!pip install matplotlib
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import sklearn
Lectura del dataset¶
df = pd.read_csv("survey_results_public.csv")
df.head()
Respondent | MainBranch | Hobbyist | Age | Age1stCode | CompFreq | CompTotal | ConvertedComp | Country | CurrencyDesc | ... | SurveyEase | SurveyLength | Trans | UndergradMajor | WebframeDesireNextYear | WebframeWorkedWith | WelcomeChange | WorkWeekHrs | YearsCode | YearsCodePro | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | 1 | I am a developer by profession | Yes | NaN | 13 | Monthly | NaN | NaN | Germany | European Euro | ... | Neither easy nor difficult | Appropriate in length | No | Computer science, computer engineering, or sof... | ASP.NET Core | ASP.NET;ASP.NET Core | Just as welcome now as I felt last year | 50.0 | 36 | 27 |
1 | 2 | I am a developer by profession | No | NaN | 19 | NaN | NaN | NaN | United Kingdom | Pound sterling | ... | NaN | NaN | NaN | Computer science, computer engineering, or sof... | NaN | NaN | Somewhat more welcome now than last year | NaN | 7 | 4 |
2 | 3 | I code primarily as a hobby | Yes | NaN | 15 | NaN | NaN | NaN | Russian Federation | NaN | ... | Neither easy nor difficult | Appropriate in length | NaN | NaN | NaN | NaN | Somewhat more welcome now than last year | NaN | 4 | NaN |
3 | 4 | I am a developer by profession | Yes | 25.0 | 18 | NaN | NaN | NaN | Albania | Albanian lek | ... | NaN | NaN | No | Computer science, computer engineering, or sof... | NaN | NaN | Somewhat less welcome now than last year | 40.0 | 7 | 4 |
4 | 5 | I used to be a developer by profession, but no... | Yes | 31.0 | 16 | NaN | NaN | NaN | United States | NaN | ... | Easy | Too short | No | Computer science, computer engineering, or sof... | Django;Ruby on Rails | Ruby on Rails | Just as welcome now as I felt last year | NaN | 15 | 8 |
5 rows × 61 columns
Como se puede ver el dataset es muy grande, contiene 61 columnas, y en las primeras 5 filas se pueden apreciar muchos datos faltantes como NaN, asi que comenzamos por la limpieza de los datos.
Para fines del proyecto solo mantendremos algunas columnas, tales como:
- Country: Que es el País
- EdLevel: Es el Nivel de Educación
- YearsCodePro: Años de experiencia profesional
- Employment: Si el desarrollador se mantiene traba jando a tiempo completo
- ConvertedComp: Compensacion convertida, que es el salario convertido a dolares estadounidenses, y este es un salario anual
Limpieza del Dataset¶
df = df[["Country", "EdLevel", "YearsCodePro", "Employment", "ConvertedComp"]]
df = df.rename({"ConvertedComp": "Salary"}, axis=1)
df.head()
Country | EdLevel | YearsCodePro | Employment | Salary | |
---|---|---|---|---|---|
0 | Germany | Master’s degree (M.A., M.S., M.Eng., MBA, etc.) | 27 | Independent contractor, freelancer, or self-em... | NaN |
1 | United Kingdom | Bachelor’s degree (B.A., B.S., B.Eng., etc.) | 4 | Employed full-time | NaN |
2 | Russian Federation | NaN | NaN | NaN | NaN |
3 | Albania | Master’s degree (M.A., M.S., M.Eng., MBA, etc.) | 4 | NaN | NaN |
4 | United States | Bachelor’s degree (B.A., B.S., B.Eng., etc.) | 8 | Employed full-time | NaN |
Ahora podemos ver que solo tenemos las columnas que seleccionamos y tambien el cambio de nombre de la columna "ConvertedComp" a "Salary". Entonces las columnas con las columnas y puntos de datos elegidos podremos hacer predicciones de la caracteristica objetivo "Salary".
A su vez tambien podemos ver que la columna objetivo Salary tiene mucha informacion faltante donde el encuestado no ingreso el salario, asi que para el entrenamiento de los datos solo tomaremos en cuenta los datos donde el salario este disponible. Por lo que tendremos que eliminar todas las filas donde exista informacion faltante o NaN, esto lo haremos con la instruccion notnull
df = df[df["Salary"].notnull()]
df.head()
Country | EdLevel | YearsCodePro | Employment | Salary | |
---|---|---|---|---|---|
7 | United States | Bachelor’s degree (B.A., B.S., B.Eng., etc.) | 13 | Employed full-time | 116000.0 |
9 | United Kingdom | Master’s degree (M.A., M.S., M.Eng., MBA, etc.) | 4 | Employed full-time | 32315.0 |
10 | United Kingdom | Bachelor’s degree (B.A., B.S., B.Eng., etc.) | 2 | Employed full-time | 40070.0 |
11 | Spain | Some college/university study without earning ... | 7 | Employed full-time | 14268.0 |
12 | Netherlands | Secondary school (e.g. American high school, G... | 20 | Employed full-time | 38916.0 |
Como se puede observar las filas que contenian informacion faltante fueron eliminadas, ahora podemos trabajar con un conjunto de datos con toda la informacion disponible de los salarios de los puntos de datos.
la siguiente instruccion nos dará un vistazo a la información del dataframe
df.info()
<class 'pandas.core.frame.DataFrame'> Int64Index: 34756 entries, 7 to 64154 Data columns (total 5 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 Country 34756 non-null object 1 EdLevel 34188 non-null object 2 YearsCodePro 34621 non-null object 3 Employment 34717 non-null object 4 Salary 34756 non-null float64 dtypes: float64(1), object(4) memory usage: 1.6+ MB
Arriba, podemos observar que tenemos 34756 entradas de datos, tambien podemos ver las columnas el tipo de dato que existe en la columna, correspondiendo el tipo object a una cadena y entero.
Ahora lo siguiente es eliminar todas las filas de una de estas columnas que no es un numero, asi que podemos hacer esto con el siguiente codigo:
df = df.dropna()
df.isnull().sum()
Country 0 EdLevel 0 YearsCodePro 0 Employment 0 Salary 0 dtype: int64
Entonces deberiamos obtener un cero para cada columna como el resultado de arriba, por lo que ahora solo usamos puntos de datos que tienen datos. Sin embargo deshacerse de todos estos datos es un enfoque muy radical de limpieza de datos, ya que existen otros enfoques que se podrían utilizar, como por ejemplo se puede completar los datos faltantes con la media de la columna, pero en este caso todavia tenemos suficientes puntos de datos disponibles , por lo que deberia estar bien simplemente eliminarlos.
df = df[df["Employment"] == "Employed full-time"]
df = df.drop("Employment", axis=1)
df.info()
<class 'pandas.core.frame.DataFrame'> Int64Index: 30019 entries, 7 to 64154 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 Country 30019 non-null object 1 EdLevel 30019 non-null object 2 YearsCodePro 30019 non-null object 3 Salary 30019 non-null float64 dtypes: float64(1), object(3) memory usage: 1.1+ MB
Lo siguiente que queremos hacer es limpiar los datos relacionados a "Country" esto lo hacemos con un recuento de puntos:
df['Country'].value_counts()
United States 7569 India 2425 United Kingdom 2287 Germany 1903 Canada 1178 ... Andorra 1 Guinea 1 Bahamas 1 Niger 1 Yemen 1 Name: Country, Length: 154, dtype: int64
El resultado de encima nos muestra cuantos puntos de datos tenemos por cada país, por lo que tenemos la mayor cantidad de los Estados Unidos y tambien vemos mucha informacion para muchos paises que solo tienen un punto de datos, por lo que no necesitaremos datos tan pequeños, asi que nos vamos a deshacer de todos los paises con puntos de datos mas pequeños, esto es debido a que tener datos muy pequeños podrian confundir al modelo, porque realmente no podria aprender de un único punto de datos.
def shorten_categories(categories, cutoff):
categorical_map = {}
for i in range(len(categories)):
if categories.values[i] >= cutoff:
categorical_map[categories.index[i]] = categories.index[i]
else:
categorical_map[categories.index[i]] = 'Other'
return categorical_map
Ahora llamaremos a la funcion creada:
country_map = shorten_categories(df.Country.value_counts(), 400)
df['Country'] = df['Country'].map(country_map)
df.Country.value_counts()
Other 8549 United States 7569 India 2425 United Kingdom 2287 Germany 1903 Canada 1178 Brazil 991 France 972 Spain 670 Australia 659 Netherlands 654 Poland 566 Italy 560 Russian Federation 522 Sweden 514 Name: Country, dtype: int64
Luego lo siguiente que haremos es inspeccionar el rango de salario, para esto graficaremos "Salary" contra "Country" en un trazo.
fig, ax = plt.subplots(1,1, figsize=(12, 7))
df.boxplot('Salary', 'Country', ax=ax)
plt.suptitle('Salary (US$) v Country')
plt.title('')
plt.ylabel('Salary')
plt.xticks(rotation=90)
plt.show()
Este tipo de gráficos nos permite identificar valores atípicos y comparar distribuciones. Además de conocer de una forma cómoda y rápida como el 50% de los valores centrales se distribuyen. No es de extrañar que en un conjunto de datos se muestren máximos muy altos o mínimos muy bajos por lo que se considera que existen los valores raros.
En nuestro caso la interpretacion que nos daría el grafico de caja para nuestro conjunto de datos, son las cajas o areas pequeñas correponderian al valor medio y todos los puntos son en realidad valores atípicos, por lo que hay muchos valores atípicos en este dataframe, asi que la mayoria de los puntos de datos están en el área mediana, por lo que nos tocaría mantener solo los puntos de datos donde tenemos la mayor cantidad de información, lo haremos con el siguiente comando:
df = df[df["Salary"] <= 250000]
df = df[df["Salary"] >= 10000]
df = df[df["Country"] != 'Other']
fig, ax = plt.subplots(1,1, figsize=(12, 7))
df.boxplot('Salary', 'Country', ax=ax)
plt.suptitle('Salary (US$) v Country')
plt.title('')
plt.ylabel('Salary')
plt.xticks(rotation=90)
plt.show()
Con el trazado nuevo de encima, podemos detectar mejores cajas y ver donde esta el rango medio, tambien podemos seguir detectando valores atípicos pero no tantos.
Lo siguiente que haremos es limpiar la columna "YearsCodePro", antes haremos una inspeccion para ver el estado de los datos.
df["YearsCodePro"].unique()
array(['13', '4', '2', '7', '20', '1', '3', '10', '12', '29', '6', '28', '8', '23', '15', '25', '9', '11', 'Less than 1 year', '5', '21', '16', '18', '14', '32', '19', '22', '38', '30', '26', '27', '17', '24', '34', '35', '33', '36', '40', '39', 'More than 50 years', '31', '37', '41', '45', '42', '44', '43', '50', '49'], dtype=object)
Vemos que tenemos valores de cadena, asi que lo siguiente a realizar es convertir estos valores de cadena a float, convirtiendo las cadenas "Less than 1 year" a 0.5 y "More than 50 years" a 50.
Para hacer esto creamos una función clean_experience
que obtiene x, y usamos una estructura condicional para el proposito describe lineas mas arriba.
def clean_experience(x):
if x == 'More than 50 years':
return 50
if x == 'Less than 1 year':
return 0.5
return float(x)
df['YearsCodePro'] = df['YearsCodePro'].apply(clean_experience)
df["EdLevel"].unique()
array(['Bachelor’s degree (B.A., B.S., B.Eng., etc.)', 'Master’s degree (M.A., M.S., M.Eng., MBA, etc.)', 'Some college/university study without earning a degree', 'Secondary school (e.g. American high school, German Realschule or Gymnasium, etc.)', 'Associate degree (A.A., A.S., etc.)', 'Professional degree (JD, MD, etc.)', 'Other doctoral degree (Ph.D., Ed.D., etc.)', 'I never completed any formal education', 'Primary/elementary school'], dtype=object)
Si echamos un vistazo a todos estos valores unicos de la columna "EdLevel" vemos que tenemos muchas respuestas diferentes, por lo que aquí tambien queremos combinar algunas categorias, mantenemos la categoria "Bachelor's degree", "Master's degree", "Professional degree" o "Other Doctoral degree" y para todas las demás categorias decimos que es menor que Bachelor's.
Para hacer esto nuevamente creamos una función que nos ayude a combinar categorias y seleccionar lo que queramos la funcion se denominará clean_education
def clean_education(x):
if 'Bachelor’s degree' in x:
return 'Bachelor’s degree'
if 'Master’s degree' in x:
return 'Master’s degree'
if 'Professional degree' in x or 'Other doctoral' in x:
return 'Post grad'
return 'Less than a Bachelors'
df['EdLevel'] = df['EdLevel'].apply(clean_education)
df["EdLevel"].unique()
array(['Bachelor’s degree', 'Master’s degree', 'Less than a Bachelors', 'Post grad'], dtype=object)
clean_experience
tenemos tipo de datos float. Y en la columna "EdLEvel" y "Country" tenemos datos de tipo cadena. Por lo que nuestro modelo no puede entender lo que es una cadena, asi que tenemos que transformar de tipo cadena en un valor unico, de modo que sea un numero que nuestro modelo pueda entender. Por ejemplo para la salida de la funcion clean_Education
en lugar de las cadenas 'Bachelo's degree', 'Master’s degree', 'Less than a Bachelors', 'Post grad', podriamos usar el numero 0,1,2,3 .
LabelEncoder
que importamos desde sklearn.preprocessing
from sklearn.preprocessing import LabelEncoder
le_education = LabelEncoder()
df['EdLevel'] = le_education.fit_transform(df['EdLevel'])
df["EdLevel"].unique()
#le.classes_
array([0, 2, 1, 3])
Entonces podemos observar en el resultado de encima que ya no se tienen valores de cadena, en su lugar tenemos datos de valores numericos como 0,2,1,3, porlo que el orden no es realmente importante aqui , lo importante es que el codificador de etiquetas le_education
sabe exactamente que numero pertenece a que cadena, pudiendo asi poder convertir esto denuevo.
Ahora ya que hicimos esto para la columna "EdLevel", entonces ahora hacemos lo mismo para la columna "Country" por lo que asignamos cada pais a un numero unico
le_country = LabelEncoder()
df['Country'] = le_country.fit_transform(df['Country'])
df["Country"].unique()
array([13, 12, 10, 7, 4, 2, 6, 1, 3, 5, 11, 8, 0, 9])
Nuevamente el resultado nos muestra numeros en lugar de cadenas.
- ## Modeling data
X = df.drop("Salary", axis=1)
y = df["Salary"]
from sklearn.linear_model import LinearRegression
linear_reg = LinearRegression()
linear_reg.fit(X, y.values)
LinearRegression()
Para predecir nuevos valores usamos la duncion predict
y_pred = linear_reg.predict(X)
from sklearn.metrics import mean_squared_error, mean_absolute_error
import numpy as np
error = np.sqrt(mean_squared_error(y, y_pred))
error
39274.75368318509
El resultado del error nos muestra un error de 39274 dolares, lo que significa que nuestro modelo en promedio esta desfasado en este numero y esto sigue siendo bastante alto si pensamos que los numeros oscilan entre 10000 a 25000 y cada vez que predecimos el salario en promedio estamos fuera de este valor. Asi que probamos un diferente modelo, el siguiente modelo a probar será un Regresor Arbol de Decisiones
from sklearn.tree import DecisionTreeRegressor
dec_tree_reg = DecisionTreeRegressor(random_state=0)
dec_tree_reg.fit(X, y.values)
DecisionTreeRegressor(random_state=0)
y_pred = dec_tree_reg.predict(X)
error = np.sqrt(mean_squared_error(y, y_pred))
print("${:,.02f}".format(error))
$29,414.94
Verificando el resultado, esta vez la rai cuadrada del error cuadratico medio es solo 29000, porlo que esto esta mucho mejor.
from sklearn.ensemble import RandomForestRegressor
random_forest_reg = RandomForestRegressor(random_state=0)
random_forest_reg.fit(X, y.values)
RandomForestRegressor(random_state=0)
y_pred = random_forest_reg.predict(X)
error = np.sqrt(mean_squared_error(y, y_pred))
print("${:,.02f}".format(error))
$29,487.31
Echamos un vistazo al resultado y esta vez y el error sigue siendo 29000, porlo que no es realmente mejor que el Arbol de Decisiones.
Entonces seguir probando mas modelos sería un poco engorroso, por lo que ahora consideraremos realizar, es que para cada uno de los modelos ya usados, probaremos a usar diferentes valores de parametros, por ejemplo para El Arbol de Decisiones podemos usar un parametro de profundidad maxima y luego podria funcionar de manera muy diferente para diferentes parametros.
Por lo que ahora para encontrar el mejor modelo con los mejores parametros existe un metodo llamado GridSearchCV
que significa Busqueda de Cuadricula con Validacion Cruzada.
La forma en que funciona el metodo es importando GRidSearchCV, y luego definimos el conjunto de diferentes parametros que queremos probar y lo almacenamos en max_depth
y luego creamos un diccionario de parametros.
from sklearn.model_selection import GridSearchCV
max_depth = [None, 2,4,6,8,10,12]
parameters = {"max_depth": max_depth}
regressor = DecisionTreeRegressor(random_state=0)
gs = GridSearchCV(regressor, parameters, scoring='neg_mean_squared_error')
gs.fit(X, y.values)
GridSearchCV(estimator=DecisionTreeRegressor(random_state=0), param_grid={'max_depth': [None, 2, 4, 6, 8, 10, 12]}, scoring='neg_mean_squared_error')
Al ejecutar el comando de arriba lo que sucede es basicamente recorrer todos los diferentes parametros que se colocaron en max_depth y probará todos los diferentes parametros y cada vez evalua el error y luego elije el mejor modelo para los datos.
regressor = gs.best_estimator_
regressor.fit(X, y.values)
y_pred = regressor.predict(X)
error = np.sqrt(mean_squared_error(y, y_pred))
print("${:,.02f}".format(error))
$30,428.51
Asi que este es el error final que obtenemos para nuestro regresor final, y ahora aplicamos modelo a los nuevos datos, pero antes echemos un vistazo de nuevo al dataframe
X
Country | EdLevel | YearsCodePro | |
---|---|---|---|
7 | 13 | 0 | 13.0 |
9 | 12 | 2 | 4.0 |
10 | 12 | 0 | 2.0 |
11 | 10 | 1 | 7.0 |
12 | 7 | 1 | 20.0 |
... | ... | ... | ... |
64113 | 13 | 1 | 15.0 |
64116 | 13 | 0 | 6.0 |
64122 | 13 | 1 | 4.0 |
64127 | 13 | 3 | 12.0 |
64129 | 13 | 2 | 4.0 |
18491 rows × 3 columns
# country, edlevel, yearscode
X = np.array([["United States", 'Master’s degree', 15 ]])
X
array([['United States', 'Master’s degree', '15']], dtype='<U15')
Lo que queremos hacer con esta prueba es aplicar el codificador de etiquetas para "Country" porlo que seria la columna cero, luego queremos aplicar el codificador de etiquetas para "EdLEvel"
X[:, 0] = le_country.transform(X[:,0])
X[:, 1] = le_education.transform(X[:,1])
X = X.astype(float)
X
array([[13., 2., 15.]])
y_pred = regressor.predict(X)
y_pred
C:\Users\Tera\anaconda3\envs\pjuno\lib\site-packages\sklearn\base.py:441: UserWarning: X does not have valid feature names, but DecisionTreeRegressor was fitted with feature names warnings.warn(
array([139427.26315789])
Por lo que con esa informacion ya podemos ver el salario que predijo el modelo, y ahora es lo que tenemos que hacer en nuestra App.
!!!A PARTIR DE ESTE PUNTO SI EL CODIGO ES CORRECTO NO VOLVER A GUARDAR CON PICKLE PORQUE CUALQUIER CAMBIO EN saved_steps.pkl AFECTARIA A LA APP WEB QUE ESTA COMPILADA EN VISUAL CODE !!!
- ## Guardando el Modelo
import pickle
Y luego le decimos que guarde el regressor
, el codificador de etiquetas le_country
y el codificador de etiquetas le_education
.
Para hacer esta tarea creamos un diccionario que guardamos en data
Luego con with open('saved_steps.pkl', 'wb') as file
abrimos un archivo de pickle y aqui con 'wb'
queremos abrir esto en el modo binario correcto esto es importante para pickle.
Y luego con pickle.dump(data, file)
le decimos a pickle volcar data
en file
.
Ahora al ejecutar la siguiente linea de comando, deberia guardar data
en nuestro directorio
data = {"model": regressor, "le_country": le_country, "le_education": le_education}
with open('saved_steps.pkl', 'wb') as file:
pickle.dump(data, file)
'rb'
read binary o leer binario y decimos data = pickle.load(file)
. with open('saved_steps.pkl', 'rb') as file:
data = pickle.load(file)
data
y luego ejecutaremos la prediccion con nuestro modelo cargado nuevamente y luego vemos que obtenemos exactamente el mismo numero que obtuvimos mas arriba del modelo original. Veamosloregressor_loaded = data["model"]
le_country = data["le_country"]
le_education = data["le_education"]
y_pred = regressor_loaded.predict(X)
y_pred
C:\Users\Tera\anaconda3\envs\pjuno\lib\site-packages\sklearn\base.py:441: UserWarning: X does not have valid feature names, but DecisionTreeRegressor was fitted with feature names warnings.warn(
array([139427.26315789])