El dataset fue conseguido del UCI en el link https://archive.ics.uci.edu/dataset/585/stock+keeping+units, esta abierto para descarga de cualquiera
df <- read_excel("stock+keeping+units/sku_data.xlsx") %>%
janitor::clean_names() # Limpia nombres: sin espacios, minúsculas y con "_"
unit_price - unit price in euro
expire_date - shelf-life
total_outbound - number of pallets sold from 2017-02-06 to 2018-02-13
outbound_number - how many times a product was ordered from 2017-02-06 to 2018-02-13
pal_grossweight - how much a fully-loaded pallet weights (kg)
pal_height - height of a fully-loaded pallet (cm)
units_per_pal - units per pallet
head(df)
## # A tibble: 6 × 8
## id unitprice expire_date outbound_number total_outbound pal_grossweight
## <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
## 1 1 0.058 547 9 2441 106.
## 2 2 0.954 547 0 0 208.
## 3 3 2.38 547 12 23 166.
## 4 4 5.1 547 0 0 221.
## 5 5 0 547 0 0 0
## 6 6 1.11 547 1 1 208.
## # ℹ 2 more variables: pal_height <dbl>, units_per_pal <dbl>
str(df)
## tibble [2,279 × 8] (S3: tbl_df/tbl/data.frame)
## $ id : num [1:2279] 1 2 3 4 5 6 7 8 9 10 ...
## $ unitprice : num [1:2279] 0.058 0.954 2.385 5.1 0 ...
## $ expire_date : num [1:2279] 547 547 547 547 547 547 547 547 547 365 ...
## $ outbound_number: num [1:2279] 9 0 12 0 0 1 2 0 0 0 ...
## $ total_outbound : num [1:2279] 2441 0 23 0 0 ...
## $ pal_grossweight: num [1:2279] 106 208 166 221 0 ...
## $ pal_height : num [1:2279] 1.56 1 1.02 1.05 0 1 1.02 0 0 1.44 ...
## $ units_per_pal : num [1:2279] 1920 384 108 72 0 384 108 0 0 36 ...
glimpse(df)
## Rows: 2,279
## Columns: 8
## $ id <dbl> 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,…
## $ unitprice <dbl> 0.0580, 0.9540, 2.3850, 5.1000, 0.0000, 1.1100, 2.5150…
## $ expire_date <dbl> 547, 547, 547, 547, 547, 547, 547, 547, 547, 365, 547,…
## $ outbound_number <dbl> 9, 0, 12, 0, 0, 1, 2, 0, 0, 0, 0, 6, 8, 0, 0, 1, 0, 3,…
## $ total_outbound <dbl> 2441, 0, 23, 0, 0, 1, 4, 0, 0, 0, 0, 14, 12, 0, 0, 2, …
## $ pal_grossweight <dbl> 105.60, 207.68, 165.78, 221.04, 0.00, 207.68, 165.78, …
## $ pal_height <dbl> 1.56, 1.00, 1.02, 1.05, 0.00, 1.00, 1.02, 0.00, 0.00, …
## $ units_per_pal <dbl> 1920, 384, 108, 72, 0, 384, 108, 0, 0, 36, 72, 384, 10…
dim(df) # Filas y columnas
## [1] 2279 8
summary(df)
## id unitprice expire_date outbound_number
## Min. : 1.0 Min. : 0.000 Min. : 0.0 Min. : 0
## 1st Qu.: 570.5 1st Qu.: 0.000 1st Qu.:365.0 1st Qu.: 0
## Median :1140.0 Median : 1.294 Median :547.0 Median : 1
## Mean :1140.0 Mean : 4.269 Mean :410.4 Mean : 236
## 3rd Qu.:1709.5 3rd Qu.: 4.545 3rd Qu.:547.0 3rd Qu.: 45
## Max. :2279.0 Max. :518.592 Max. :734.0 Max. :6325
## total_outbound pal_grossweight pal_height units_per_pal
## Min. : 0.0 Min. : 0.0 Min. :0.0000 Min. : 0.0
## 1st Qu.: 0.0 1st Qu.: 60.0 1st Qu.:0.0000 1st Qu.: 32.0
## Median : 3.0 Median :167.7 Median :0.8400 Median : 108.0
## Mean : 731.7 Mean :192.9 Mean :0.6728 Mean : 755.6
## 3rd Qu.: 419.5 3rd Qu.:277.6 3rd Qu.:1.0200 3rd Qu.: 384.0
## Max. :26411.0 Max. :907.2 Max. :2.1600 Max. :200000.0
skim(df) # Más detallado
Name | df |
Number of rows | 2279 |
Number of columns | 8 |
_______________________ | |
Column type frequency: | |
numeric | 8 |
________________________ | |
Group variables | None |
Variable type: numeric
skim_variable | n_missing | complete_rate | mean | sd | p0 | p25 | p50 | p75 | p100 | hist |
---|---|---|---|---|---|---|---|---|---|---|
id | 0 | 1 | 1140.00 | 658.03 | 1 | 570.5 | 1140.00 | 1709.50 | 2279.00 | ▇▇▇▇▇ |
unitprice | 0 | 1 | 4.27 | 14.45 | 0 | 0.0 | 1.29 | 4.54 | 518.59 | ▇▁▁▁▁ |
expire_date | 0 | 1 | 410.37 | 240.88 | 0 | 365.0 | 547.00 | 547.00 | 734.00 | ▃▁▂▇▁ |
outbound_number | 0 | 1 | 235.98 | 700.23 | 0 | 0.0 | 1.00 | 45.00 | 6325.00 | ▇▁▁▁▁ |
total_outbound | 0 | 1 | 731.70 | 2146.03 | 0 | 0.0 | 3.00 | 419.50 | 26411.00 | ▇▁▁▁▁ |
pal_grossweight | 0 | 1 | 192.94 | 164.62 | 0 | 60.0 | 167.68 | 277.56 | 907.20 | ▇▆▁▁▁ |
pal_height | 0 | 1 | 0.67 | 0.55 | 0 | 0.0 | 0.84 | 1.02 | 2.16 | ▇▂▇▂▁ |
units_per_pal | 0 | 1 | 755.56 | 6278.44 | 0 | 32.0 | 108.00 | 384.00 | 200000.00 | ▇▁▁▁▁ |
# Duplicados
sum(duplicated(df))
## [1] 0
colSums(is.na(df))
## id unitprice expire_date outbound_number total_outbound
## 0 0 0 0 0
## pal_grossweight pal_height units_per_pal
## 0 0 0
plot_missing(df) # Gráfico de valores perdidos (de DataExplorer)
Inicialmente parece que el dataset no tiene faltantes
colSums(df == 0, na.rm = TRUE)
## id unitprice expire_date outbound_number total_outbound
## 0 710 529 979 979
## pal_grossweight pal_height units_per_pal
## 371 787 295
colMeans(df == 0, na.rm = TRUE) # proporción
## id unitprice expire_date outbound_number total_outbound
## 0.0000000 0.3115401 0.2321194 0.4295744 0.4295744
## pal_grossweight pal_height units_per_pal
## 0.1627907 0.3453269 0.1294427
Pero si observamos la proporcion de 0’s que tiene el dataset, estas son inusualmente altas para todas las columnas importantes. Ademas por el contexto de las variables, ninguna de estas tienen sentido que esten igualadas a 0, por lo que lo mas seguro es que el sistema que haya coleccionado los datos haya sido codificado para introducir datos faltantes como “0”, por lo que tendremos que tratar los 0’s como faltantes.
table(df$expire_date)
##
## 0 365 456 547 730 734
## 529 305 8 1250 186 1
Otra cosa a notar es la variable expire_date, la cual a pesar de ser tratada como numerica en el dataset, tiene una cantidad limitada de variables unicas, lo cual hace que se comporte mas como una variable categorica ordinal. Esto complicara un poquito la imputacion de datos faltantes, por lo cual veremos mas adelante como lidiaremos con esto
# Transformamos 0's a NA's
df <- df %>% mutate(across(everything(), ~ na_if(., 0)))
summary(df)
## id unitprice expire_date outbound_number
## Min. : 1.0 Min. : 0.0033 Min. :365.0 Min. : 1.0
## 1st Qu.: 570.5 1st Qu.: 1.0600 1st Qu.:547.0 1st Qu.: 3.0
## Median :1140.0 Median : 2.8000 Median :547.0 Median : 17.0
## Mean :1140.0 Mean : 6.2014 Mean :534.4 Mean : 413.7
## 3rd Qu.:1709.5 3rd Qu.: 6.5460 3rd Qu.:547.0 3rd Qu.: 374.0
## Max. :2279.0 Max. :518.5920 Max. :734.0 Max. :6325.0
## NA's :710 NA's :529 NA's :979
## total_outbound pal_grossweight pal_height units_per_pal
## Min. : 1 Min. : 0.8 Min. :0.040 Min. : 1.0
## 1st Qu.: 9 1st Qu.:128.0 1st Qu.:0.900 1st Qu.: 42.0
## Median : 269 Median :207.7 Median :1.020 Median : 108.0
## Mean : 1283 Mean :230.5 Mean :1.028 Mean : 867.9
## 3rd Qu.: 1372 3rd Qu.:293.8 3rd Qu.:1.050 3rd Qu.: 384.0
## Max. :26411 Max. :907.2 Max. :2.160 Max. :200000.0
## NA's :979 NA's :371 NA's :787 NA's :295
skim(df)
Name | df |
Number of rows | 2279 |
Number of columns | 8 |
_______________________ | |
Column type frequency: | |
numeric | 8 |
________________________ | |
Group variables | None |
Variable type: numeric
skim_variable | n_missing | complete_rate | mean | sd | p0 | p25 | p50 | p75 | p100 | hist |
---|---|---|---|---|---|---|---|---|---|---|
id | 0 | 1.00 | 1140.00 | 658.03 | 1.00 | 570.50 | 1140.00 | 1709.50 | 2279.00 | ▇▇▇▇▇ |
unitprice | 710 | 0.69 | 6.20 | 17.07 | 0.00 | 1.06 | 2.80 | 6.55 | 518.59 | ▇▁▁▁▁ |
expire_date | 529 | 0.77 | 534.42 | 96.11 | 365.00 | 547.00 | 547.00 | 547.00 | 734.00 | ▂▁▇▁▁ |
outbound_number | 979 | 0.57 | 413.68 | 886.73 | 1.00 | 3.00 | 17.00 | 374.00 | 6325.00 | ▇▁▁▁▁ |
total_outbound | 979 | 0.57 | 1282.73 | 2714.59 | 1.00 | 9.00 | 269.00 | 1372.00 | 26411.00 | ▇▁▁▁▁ |
pal_grossweight | 371 | 0.84 | 230.46 | 154.01 | 0.80 | 128.00 | 207.68 | 293.76 | 907.20 | ▇▇▁▁▁ |
pal_height | 787 | 0.65 | 1.03 | 0.32 | 0.04 | 0.90 | 1.02 | 1.05 | 2.16 | ▁▂▇▂▁ |
units_per_pal | 295 | 0.87 | 867.91 | 6722.00 | 1.00 | 42.00 | 108.00 | 384.00 | 200000.00 | ▇▁▁▁▁ |
plot_missing(df)
Ahora si podemos notar que este dataset tiene una cantidad considerable de datos faltantes que tendremos que afrontar usando tecnicas avanzadas de prediccion
# Histogramas
df %>% select(where(is.numeric), -id) %>%
gather(variable, valor) %>%
ggplot(aes(valor)) +
geom_histogram(bins = 30, fill = "skyblue") +
facet_wrap(~variable, scales = "free")
# Boxplot para ver outliers
df %>%
select(where(is.numeric), -id) %>%
pivot_longer(cols = everything(), names_to = "variable", values_to = "valor") %>%
ggplot(aes(x = "", y = valor)) +
geom_boxplot(fill = "red2") +
facet_wrap(~variable, scales = "free_y") +
theme(axis.text.x = element_blank(),
axis.title.x = element_blank())
# Hacemos pruebas de normalidad
df %>%
select(where(is.numeric)) %>%
summarise(across(everything(),
~ shapiro.test(.x)$p.value))
## # A tibble: 1 × 8
## id unitprice expire_date outbound_number total_outbound pal_grossweight
## <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
## 1 5.59e-26 7.49e-63 7.20e-48 3.34e-50 3.97e-51 2.51e-34
## # ℹ 2 more variables: pal_height <dbl>, units_per_pal <dbl>
# Ninguna variable es normal
Vamos a inicialmente tratar los atipicos, para luego tratarlos con los faltantes que ya tenemos.
Por la mayor parte pareceria que varias columnas tienen varios datos faltantes que deberiamos de eliminar, pero como estos son datos reales no es tan necesario eliminarlos a todos, de todas formas hay columnas con unos datos un poquito exagerados que si creeremos necesarios corregir. Entonces esto es lo que haremos
# Funcion para hacer capping usando percentiles
cap_percentiles <- function(x, lower_p = 0.01, upper_p = 0.99) {
lower <- quantile(x, lower_p, na.rm = TRUE)
upper <- quantile(x, upper_p, na.rm = TRUE)
x <- ifelse(x < lower, lower, x)
x <- ifelse(x > upper, upper, x)
return(x)
}
# Función para aplicar Grubbs tipo 10 iterativamente
remove_grubbs_max <- function(x, alpha = 0.01) {
repeat {
test <- grubbs.test(x, type = 10)
if (test$p.value < alpha) {
max_val <- max(x, na.rm = TRUE)
x[which.max(x)] <- NA # Reemplaza solo uno por NA
} else {
break
}
}
return(x)
}
df <- df %>%
mutate(across(c(pal_height, pal_grossweight), cap_percentiles))
df <- df %>%
mutate(across(c(units_per_pal, unitprice), remove_grubbs_max))
# Hacemos los boxplots de nuevo para ver cambios
df %>%
select(where(is.numeric), -id) %>%
pivot_longer(cols = everything(), names_to = "variable", values_to = "valor") %>%
ggplot(aes(x = "", y = valor)) +
geom_boxplot(fill = "red2") +
facet_wrap(~variable, scales = "free_y") +
theme(axis.text.x = element_blank(),
axis.title.x = element_blank())
plot_missing(df)
Ahora para los datos faltantes, las variables de units_per_pal y pal_gross_weight no tienen porcentajes muy altos por lo que imputaremos por mediana. Mientras tanto, el resto de variables si tienen porcentajes que superan el 30% por lo que aplicaremos mice con metodo pmm para imputarlos, pero por lo descrito anteriormente del expire_date preferiremos imputar esta columna especificamente con knn, para que el modelo no se invente valores no existentes. El knn sera aplicado despues del mice para tener mejores resultados
# Imputamos por mediana por units_per_pal y pal_gross_weight
df <- df %>%
mutate(across(c(units_per_pal, pal_grossweight), ~ ifelse(is.na(.), median(., na.rm = TRUE), .)))
cols_mice <- c("unitprice", "pal_height", "total_outbound", "outbound_number")
col_knn <- "expire_date"
# Guardamos columnas originales para hacer comparaciones mas adelante
df_original <- df %>% select(all_of(c(cols_mice, col_knn)))
# Guardar versión sin NA's para graficar
df_no_na <- lapply(df_original, function(col) col[!is.na(col)])
# MICE
mice_model <- mice(df[cols_mice], method = "pmm", m = 1, seed = 123)
df[cols_mice] <- complete(mice_model)
# KNN
df[col_knn] <- kNN(df, variable = "expire_date", k = 5, imp_var = FALSE)[, col_knn]
plot_missing(df)
Ahora validamos normalidad de nuevo
# df_original es antes de imputacion, df es despues de imputacion
for (col in c(cols_mice, col_knn)) {
cat("\nColumna:", col, "\n")
print(shapiro.test(na.omit(df_original[[col]])))
print(shapiro.test(df[[col]]))
}
##
## Columna: unitprice
##
## Shapiro-Wilk normality test
##
## data: na.omit(df_original[[col]])
## W = 0.77867, p-value < 2.2e-16
##
##
## Shapiro-Wilk normality test
##
## data: df[[col]]
## W = 0.77932, p-value < 2.2e-16
##
##
## Columna: pal_height
##
## Shapiro-Wilk normality test
##
## data: na.omit(df_original[[col]])
## W = 0.90865, p-value < 2.2e-16
##
##
## Shapiro-Wilk normality test
##
## data: df[[col]]
## W = 0.90957, p-value < 2.2e-16
##
##
## Columna: total_outbound
##
## Shapiro-Wilk normality test
##
## data: na.omit(df_original[[col]])
## W = 0.50049, p-value < 2.2e-16
##
##
## Shapiro-Wilk normality test
##
## data: df[[col]]
## W = 0.495, p-value < 2.2e-16
##
##
## Columna: outbound_number
##
## Shapiro-Wilk normality test
##
## data: na.omit(df_original[[col]])
## W = 0.52808, p-value < 2.2e-16
##
##
## Shapiro-Wilk normality test
##
## data: df[[col]]
## W = 0.52164, p-value < 2.2e-16
##
##
## Columna: expire_date
##
## Shapiro-Wilk normality test
##
## data: na.omit(df_original[[col]])
## W = 0.70859, p-value < 2.2e-16
##
##
## Shapiro-Wilk normality test
##
## data: df[[col]]
## W = 0.66696, p-value < 2.2e-16
Todas las columnas siguen siendo no normales, por lo que haremos pruebas no parametricas para validar la imputacion. Tambien generaremos graficas para comparar
# Graficar distribuciones
for (col in c(cols_mice, col_knn)) {
temp <- data.frame(
Valor = c(na.omit(df_original[[col]]), df[[col]]),
Estado = rep(c("Antes", "Después"), c(length(na.omit(df_original[[col]])), nrow(df)))
)
p <- ggplot(temp, aes(x = Valor, fill = Estado)) +
geom_density(alpha = 0.5) +
labs(title = paste("Distribución de", col, "antes y después de imputar")) +
theme_minimal()
print(p)
}
# Prueba no parametrica de wilcoxon test
for (col in c(cols_mice, col_knn)){
before <- df_no_na[[col]]
after <- df[[col]]
test_np <- wilcox.test(before, after)
cat("\nColumna:", col, "\n")
cat("Wilcoxon p:", test_np$p.value, "\n")
}
##
## Columna: unitprice
## Wilcoxon p: 0.6549202
##
## Columna: pal_height
## Wilcoxon p: 0.289961
##
## Columna: total_outbound
## Wilcoxon p: 0.6311904
##
## Columna: outbound_number
## Wilcoxon p: 0.6863162
##
## Columna: expire_date
## Wilcoxon p: 0.1875679
De aqui podria parecer que hay algunos p valores dudosos, pero son los suficientes como para afirmar que nuestra imputacion conserva las distribuciones.
# Convertimos expire_date a caracteres para analizarlo asi
df <- df %>%
mutate(expire_date = as.character(expire_date))
# Excluimos la columna de los ids de nuestras graficas
cols_to_analyze <- df %>% select(-id)
# Iterar sobre columnas numéricas
numeric_cols <- names(cols_to_analyze)[sapply(cols_to_analyze, is.numeric)]
for (col in numeric_cols) {
cat("\n===== Variable:", col, "=====\n")
# Calcular estadísticos
sk <- skewness(df[[col]], na.rm = TRUE)
kt <- kurtosis(df[[col]], na.rm = TRUE)
cat("Skewness:", sk, "\n")
cat("Kurtosis:", kt, "\n")
# Interpretación:
# Skewness: mide la asimetría de la distribución
# 0 ≈ simétrica, >0 sesgo a la derecha, <0 sesgo a la izquierda
# Kurtosis: mide la "apuntamiento" de la distribución
# 3 ≈ mesocúrtica (normal), >3 leptocúrtica (picos altos), <3 platicúrtica (aplanada)
# Histograma + densidad
p <- ggplot(df, aes(x = .data[[col]])) +
geom_histogram(aes(y = ..density..), bins = 30, fill = "skyblue", alpha = 0.6) +
geom_density(color = "red", size = 1) +
labs(title = paste("Distribución de", col),
x = col, y = "Densidad") +
theme_minimal()
print(p)
}
##
## ===== Variable: unitprice =====
## Skewness: 1.506552
## Kurtosis: 1.351257
##
## ===== Variable: outbound_number =====
## Skewness: 3.273482
## Kurtosis: 12.06157
##
## ===== Variable: total_outbound =====
## Skewness: 4.272047
## Kurtosis: 22.9543
##
## ===== Variable: pal_grossweight =====
## Skewness: 1.449593
## Kurtosis: 2.846267
##
## ===== Variable: pal_height =====
## Skewness: 0.460275
## Kurtosis: 0.6478598
##
## ===== Variable: units_per_pal =====
## Skewness: 2.383801
## Kurtosis: 5.135571
cat_col <- "expire_date"
ggplot(df, aes(x = .data[[cat_col]])) +
geom_bar(fill = "lightgreen", alpha = 0.7) +
labs(title = paste("Frecuencias de", cat_col),
x = cat_col, y = "Conteo") +
theme_minimal()
# Grafico de torta
df %>%
select(where(~is.factor(.) || is.character(.))) %>%
pivot_longer(cols = everything(), names_to = "variable", values_to = "valor") %>%
group_by(variable, valor) %>%
summarise(n = n(), .groups = "drop") %>%
group_by(variable) %>%
mutate(
porcentaje = n / sum(n) * 100,
etiqueta = paste0(round(porcentaje, 1), "%")
) %>%
ggplot(aes(x = "", y = porcentaje, fill = valor)) +
geom_col(width = 1, color = "white") +
coord_polar(theta = "y") +
geom_text(aes(label = etiqueta),
position = position_stack(vjust = 0.5), size = 3) +
facet_wrap(~variable) +
theme_void()
# Gráfico de correlaciones
ggcorr(
df %>% select(all_of(numeric_cols)),
method = c("complete.obs", "spearman"), # primero 'use', luego 'method'
label = TRUE, label_round = 2
) +
ggtitle("Correlación Spearman entre variables numéricas")