7  Análisis preliminar

Autor/a

Iraitz Montalbán

Partimos de nuestros datos ya cargados, y deberemos ver la estructura base de nuestro dataframe. La función describe nos ofrece una primera revisión de los datos indicando nombre de las columnas, estadísticas de la distribución de datos, valores faltantes y typo de dato.

using CSV, DataFrames

df = CSV.read(joinpath(data_path,"titanic.csv"), DataFrame)
describe(df)
12×7 DataFrame
Row variable mean min median max nmissing eltype
Symbol Union… Any Union… Any Int64 Type
1 PassengerId 446.0 1 446.0 891 0 Int64
2 Survived 0.383838 0 0.0 1 0 Int64
3 Pclass 2.30864 1 3.0 3 0 Int64
4 Name Abbing, Mr. Anthony van Melkebeke, Mr. Philemon 0 String
5 Sex female male 0 String7
6 Age 29.6991 0.42 28.0 80.0 177 Union{Missing, Float64}
7 SibSp 0.523008 0 0.0 8 0 Int64
8 Parch 0.381594 0 0.0 6 0 Int64
9 Ticket 110152 WE/P 5735 0 String31
10 Fare 32.2042 0.0 14.4542 512.329 0 Float64
11 Cabin A10 T 687 Union{Missing, String15}
12 Embarked C S 2 Union{Missing, String1}

A esto podemos unirle conocer el tamaño de nuestro conjunto mediante el método size.

size(df)
(891, 12)

Deberemos entender la naturaleza de los datos que nos muestran y conocer, entre otras cuestiones:

Esto nos obliga a explorar si son variables categóricas codificadas con números, si existen campos con una granularidad alta o con valor constante. Podemos pedir estadísticas concretas al método describe.

describe(df, :nunique)
12×2 DataFrame
Row variable nunique
Symbol Union…
1 PassengerId
2 Survived
3 Pclass
4 Name 891
5 Sex 2
6 Age
7 SibSp
8 Parch
9 Ticket 681
10 Fare
11 Cabin 147
12 Embarked 3

7.1 Selección de campos

Podemos por tanto descartar aquellos casos donde el grano de detalle es prácticamente individual.

campos_a_descartar = [:Ticket, :Name]
2-element Vector{Symbol}:
 :Ticket
 :Name

Vemos que únicamente a contemplado los casos donde nos encontramos con tipos String. Pero sabemos, por ejemplo, que la columna Survived es una condición booleana de si sobrevivieron o no. Emplearemos la propia función de conversión de cada tipo, añadiéndole un . al final que denote por fila. En Julia, el punto (.) al final de una función o nombre de tipo (como string.) indica broadcasting, lo que aplicar una función elemento por elemento a lo largo de un array o colección, sin necesidad de escribir un bucle explícito.

Al igual que antes, empleamos el indicador ! ya que vamos a modificar la columna en su sitio. En Julia:

  • df[:, :Columna] devuelve una copia de la columna. Si modificas el resultado, el DataFrame original no cambia.
  • df[!, :Columna] devuelve una referencia a la columna original. Si modificas el resultado, el DataFrame original sí cambia.
df[!,:Survived] = Bool.(df[!,:Survived])
df[!,:PassengerId] = string.(df[!,:PassengerId])
df[!,:Pclass] = string.(df[!,:Pclass]);
Nota

El carácter final ; s simplemente para que no imprima el resultado por pantalla cuando se emplean Notebooks o scripts the Quarto .qmd.

Gracias a la librería Query.jl podemos emplear una sintaxis más compacta en nuestras consultas, añadiendo etapas de transformación como en los pipelines habituales en R o Python.

using Pkg;
Pkg.add("Query")
using DataFrames, Query

campos = [:Survived, :PassengerId, :Pclass]

filas = nrow(df)

for col in campos
    únicos = df |> 
        @select({Symbol(col) = _.col}) |> collect |> x -> unique(df[!, col])
    println("Columna $col tiene $(length(únicos)) valores únicos en $(filas) filas.")
    println()
end
Columna Survived tiene 2 valores únicos en 891 filas.

Columna PassengerId tiene 891 valores únicos en 891 filas.

Columna Pclass tiene 3 valores únicos en 891 filas.

Tenemos varios puntos en el código anterior:

  • @select es una macro para seleccionar columnas de un DataFrame. {Symbol(col) = _.col} crea un diccionario o mapeo donde la clave es el símbolo de la variable col y el valor es la columna correspondiente en el DataFrame. Symbol(col)en sí convierte el nombre de la columna (probablemente un string) en un símbolo, que es un tipo de dato común en Julia para referirse a nombres de columnas.
  • |> es el operador “pipe” en Julia, que pasa el resultado de la expresión anterior como argumento a la función siguiente.
  • collect toma un iterable (como el resultado de (select?)) y lo convierte en una colección concreta, como un array.
  • x -> unique(df[!, col]) es una función anónima (sin nombre designado como en el caso de las lambda) que toma x (aunque aquí no se usa directamente) y devuelve los valores únicos de la columna col del DataFrame df.

Hemos dado un salto en este bloque así que tomaros tiempo para entender cada parte. De aquí concluimos que la columna PassengerId no será muy relevante en el análisis estadístico ya que muestra un grano muy fino de valores que únicamente permiten identificar a cada individuo de nuestro conjunto. Pasará algo muy similar con valores de texto que indiquen nombre o ticket. Este dato ya lo obtuvimos anteriormente con lo que podemos determinar que las columnas de interés en adelante serán

append!(campos_a_descartar, [:PassengerId])
select!(df, Not(campos_a_descartar));

Recordad que ! modifica el dato objeto

first(df, 5)
5×9 DataFrame
Row Survived Pclass Sex Age SibSp Parch Fare Cabin Embarked
Bool String String7 Float64? Int64 Int64 Float64 String15? String1?
1 false 3 male 22.0 1 0 7.25 missing S
2 true 1 female 38.0 1 0 71.2833 C85 C
3 true 3 female 26.0 0 0 7.925 missing S
4 true 1 female 35.0 1 0 53.1 C123 S
5 false 3 male 35.0 0 0 8.05 missing S

Tenéis más información en DataFrames.jl > Querying frameworks

7.2 Valores numéricos

Una de las mejores formes de conocer las distribuciones de datos es precisamente visualizando su histograma o gráfico de densidad. Deberéis instalar los paquetes de visualización pertinentes.

Pkg.add("StatsPlots")
using StatsPlots

histogram(df.Age, normalize=true)

Vemos que si lo comparamos con una distribución normal, las gráficas difieren bastante. Esto suele requerir de evaluar la normalidad de la

Pkg.add("Distributions")
using Distributions
#| output: collapse

plot(Normal(3, 5), lw=3)

El primer reto al que nos enfrentaremos será el de poder mostrar el diagrama de densidad, ya que no permite la existencia de valores faltantes. Y como podemos ver existen varios para la columna age. Deberemos aprender a filtrar en base a una condición.

7.2.1 Valores faltantes

En el caso de Julia missing es un tipo de datos especial que no indica el valor faltante en una celda.

typeof(missing)
Missing

Existen funciones específicas para eliminar o simplemente filtrar (filter) en base a la condición de dato faltante (ismissing). En este caso podremos ver los primeros tres elementos en nuestro dataframe donde se cumple la condición de dato faltante en el campo edad (:Age).

first(
    filter(
        :Age => ismissing, df
    ),
    3 # mostramos los primeros tres elementos
)
3×9 DataFrame
Row Survived Pclass Sex Age SibSp Parch Fare Cabin Embarked
Bool String String7 Float64? Int64 Int64 Float64 String15? String1?
1 false 3 male missing 0 0 8.4583 missing Q
2 true 2 male missing 0 0 13.0 missing S
3 true 3 female missing 0 0 7.225 missing C

Y así podemos ver la densidad indicando nuestro dataframe modificado para que no cumpla esta condición. StatsPlots nos da la opción de explorar otras macros (ya vimos la macro de @time) que en este caso será el turno de @df. Esta macro, empleada con la sintaxis

@df <tabla> <función de visualización>

nos permite definir una estructura tabular que luego será empleada en la función de visualización a la que solo deberemos indicarle en qué columnas centrarse.

@df filter(:Age => !ismissing, df) density(:Age)

Como vemos, la función de densidad nos presenta un perímetro continuo sobre el histograma anterior. Podemos evaluar la normalidad de nuestra columna empleando tests estadísticos. La librería HypothesisTests.jl nos dará buenas referencias de cuales podemos usar.

Por simplificar de momento, viendo que la media y la mediana muestra valores parejos para el intervalo de datos, asumiremos dicha normalidad y veremos como imputar la media como valor estimado a los datos faltantes de nuestro dataframe.

using Statistics

mean(skipmissing(df.Age))
29.69911764705882

Este valor lo reemplazaremos indicando que queremos transformar (alterando el objeto df)

transform!(df, :Age => ByRow(x -> coalesce(x, mean(skipmissing(df.Age)))); renamecols=false)
describe(df)
9×7 DataFrame
Row variable mean min median max nmissing eltype
Symbol Union… Any Union… Any Int64 Type
1 Survived 0.383838 false 0.0 true 0 Bool
2 Pclass 1 3 0 String
3 Sex female male 0 String7
4 Age 29.6991 0.42 29.6991 80.0 0 Float64
5 SibSp 0.523008 0 0.0 8 0 Int64
6 Parch 0.381594 0 0.0 6 0 Int64
7 Fare 32.2042 0.0 14.4542 512.329 0 Float64
8 Cabin A10 T 687 Union{Missing, String15}
9 Embarked C S 2 Union{Missing, String1}

Tras esto, con las dos columnas restantes podemos optar por eliminar estos datos. dropmissing eliminará las filas que contengan valores faltantes en sus celdas.

df_sinfilas = dropmissing(df)
size(df_sinfilas)
(202, 9)

O mediante la función select podemos indicarle que no queremos las columnas con datos faltantes. Para esta macro deberemos usar otro paquete muy habitual para este tipo de labores.

Pkg.add("DataFramesMeta")
using DataFramesMeta

select!(df, Not(:Cabin, :Embarked))
describe(df)
WARNING: using DataFramesMeta.@select in module Notebook conflicts with an existing identifier.
7×7 DataFrame
Row variable mean min median max nmissing eltype
Symbol Union… Any Union… Any Int64 DataType
1 Survived 0.383838 false 0.0 true 0 Bool
2 Pclass 1 3 0 String
3 Sex female male 0 String7
4 Age 29.6991 0.42 29.6991 80.0 0 Float64
5 SibSp 0.523008 0 0.0 8 0 Int64
6 Parch 0.381594 0 0.0 6 0 Int64
7 Fare 32.2042 0.0 14.4542 512.329 0 Float64

7.3 Variables categóricas

Las variables categóricas son un tipo especial de variable que se utiliza para representar datos que pueden tomar un número limitado de valores distintos, llamados categorías o niveles.

En Julia, las variables categóricas se manejan principalmente usando el paquete CategoricalArrays.jl. Este paquete proporciona el tipo CategoricalArray, que permite almacenar datos categóricos de manera eficiente y realizar operaciones como ordenamiento, agrupamiento y comparación entre categorías.

En nuestro caso, la columna Pclass muestra categorías de prioridad de los pasajeros que encajarían dentro de la descripción de niveles.

Pkg.add("CategoricalArrays")
using CategoricalArrays

# Creamos una copia del dataframe
df_cat = copy(df)

# Primero, reemplazamos los valores numéricos por los nombres de nivel deseados
df_cat[!, :Pclass] = replace(df_cat[!, :Pclass], "1" => "Primera", "2" => "Segunda", "3" => "Tercera")

# Luego, convertimos la columna a categórica con los niveles y orden deseado
levels = ["Tercera", "Segunda", "Primera"]
df_cat[!, :Pclass] = categorical(df_cat[!, :Pclass]; levels, ordered=true)

# Y comprobamos el resultado
describe(df_cat)
7×7 DataFrame
Row variable mean min median max nmissing eltype
Symbol Union… Any Union… Any Int64 DataType
1 Survived 0.383838 false 0.0 true 0 Bool
2 Pclass Tercera Primera 0 CategoricalValue{String, UInt32}
3 Sex female male 0 String7
4 Age 29.6991 0.42 29.6991 80.0 0 Float64
5 SibSp 0.523008 0 0.0 8 0 Int64
6 Parch 0.381594 0 0.0 6 0 Int64
7 Fare 32.2042 0.0 14.4542 512.329 0 Float64

Gracias a ser categóricas ordenadas, podemos realizar comparativas como si de números se tratara pero empleando una sintaxis que nos aporta cierta claridad semántica. Si ordenamos de forma ascendente vemos que los viajeros en Tercera están en una categoría inferior a los de Primera.

first(sort(df_cat, :Pclass), 3)
3×7 DataFrame
Row Survived Pclass Sex Age SibSp Parch Fare
Bool Cat… String7 Float64 Int64 Int64 Float64
1 false Tercera male 22.0 1 0 7.25
2 true Tercera female 26.0 0 0 7.925
3 false Tercera male 35.0 0 0 8.05
last(sort(df_cat, :Pclass), 3)
3×7 DataFrame
Row Survived Pclass Sex Age SibSp Parch Fare
Bool Cat… String7 Float64 Int64 Int64 Float64
1 true Primera female 56.0 0 1 83.1583
2 true Primera female 19.0 0 0 30.0
3 true Primera male 26.0 0 0 30.0

Cosa que no podríamos hacer con el uso de cadenas de texto.

"Primera" < "Tercera"
true

Ya tenemos un conjunto de datos algo más limpio. Ahora toca estudiarlo en profundidad. Si queremos guardarlo para más adelante podemos volcar el DataFrame a fichero aunque si lo volcamos a un CSV o fichero de texto podemos perder la información categórica. De hecho, muchos formatos y entornos no serán compatibles con esta tipología de dato concreta.

using Parquet

df[!,:Sex] = String.(df[!,:Sex]);

write_parquet(joinpath(data_path,"titanic.parquet"), df)
4