Práctica 2 — Pipeline de Preparación de Datos

Plataforma de Streaming SonidEC | Minería de Datos

Autores/as

[JORGE MARCELO REYES VINUEZA]

[RONALD IVAN MOLINA PICO]

[ANGEL VALENTIN MEJIA PALACIOS]

[ANTHONY JOSUE MEJIA DELGADO]

[PABLO ANDRES GONZEMBACH LUNA]

Fecha de publicación

18 de octubre de 2026


1 Introducción

En 2026, las plataformas de streaming de música y podcasts generan enormes volúmenes de datos de comportamiento de usuario. En esta práctica trabajamos con el dataset de sesiones de SonidEC, una plataforma de streaming ecuatoriana, con el objetivo de preparar los datos y obtener insights sobre los hábitos de consumo de sus usuarios.

El pipeline implementado cubre cinco etapas: selección, limpieza, enriquecimiento, análisis exploratorio (EDA) y transformación de variables.


2 1. Selección y Carga

En esta etapa, se realiza la selección del conjunto de datos. Como este es proporcionado por el docente, únicamente lo cargamos y revisamos.

Ver código
streaming_sonidec <- read.csv(
  "../Datos/streaming_sonidec.csv",
  stringsAsFactors = FALSE,
  fileEncoding     = "latin1",
  na.strings       = c("", "NA", "N/A")
)

glimpse(streaming_sonidec)
Rows: 2,080
Columns: 10
$ usuario_id           <int> 2987, 4070, 2451, 2505, 8446, 5977, 9901, 1882, 1…
$ fecha_sesion         <chr> "2023-10-20", "2024-07-29", "2024-03-16", "2026-0…
$ genero_musical       <chr> "Pop", "Indie", "Pop", "Reggaeton", "Salsa", "Roc…
$ dispositivo          <chr> "Móvil", "Móvil", "Móvil", "Tablet", "Computad…
$ duracion_min         <dbl> 25.7, 39.6, 19.5, 29.3, 51.9, 30.1, 38.8, 43.2, 5…
$ canciones_escuchadas <int> 11, 10, 4, 7, 18, 11, 11, 12, 16, 20, 12, 1, 10, …
$ skip_rate            <dbl> 0.603, 0.691, 0.913, 0.882, 0.720, 0.832, 0.992, …
$ plan                 <chr> "Gratuito", "Gratuito", "Gratuito", "Premium", "G…
$ provincia            <chr> "Guayas", "Azuay", "Manabí", "Guayas", "Guayas",…
$ rating_sesion        <int> 5, 3, NA, 2, 4, 5, 1, 5, 1, 2, 5, 4, 2, 3, 5, 2, …
Ver código
summary(streaming_sonidec)
   usuario_id      fecha_sesion    genero_musical    dispositivo  
 Min.   :1003   Length   :2080   Length   :2080   Length   :2080  
 1st Qu.:3194   N.unique :1113   N.unique :   6   N.unique :   4  
 Median :5391   N.blank  :   0   N.blank  :   0   N.blank  :   0  
 Mean   :5471   Min.nchar:  10   Min.nchar:   3   Min.nchar:   6  
 3rd Qu.:7770   Max.nchar:  10   Max.nchar:  12   Max.nchar:  10  
 Max.   :9999                                                     
                                                                  
  duracion_min    canciones_escuchadas   skip_rate             plan     
 Min.   :  1.00   Min.   : 1.00        Min.   :0.0010   Length   :2080  
 1st Qu.: 25.90   1st Qu.: 8.00        1st Qu.:0.2550   N.unique :   3  
 Median : 38.50   Median :13.00        Median :0.5060   N.blank  :   0  
 Mean   : 38.67   Mean   :12.81        Mean   :0.5074   Min.nchar:   7  
 3rd Qu.: 51.15   3rd Qu.:17.00        3rd Qu.:0.7670   Max.nchar:   8  
 Max.   :107.30   Max.   :37.00        Max.   :1.0000                   
 NAs    :125                           NAs    :106                      
     provincia    rating_sesion  
 Length   :2080   Min.   :1.000  
 N.unique :  15   1st Qu.:3.000  
 N.blank  :   0   Median :4.000  
 Min.nchar:   4   Mean   :3.576  
 Max.nchar:   9   3rd Qu.:5.000  
                  Max.   :5.000  
                  NAs    :163    
Ver código
cat("Filas:", nrow(streaming_sonidec), "\n")
Filas: 2080 
Ver código
cat("Columnas:", ncol(streaming_sonidec), "\n")
Columnas: 10 

El dataset tiene 2080 registros y 10 variables.


3 2. Limpieza

La limpieza de datos es un paso crucial para garantizar la calidad del análisis. Abordaremos: duplicados, valores faltantes, fechas fuera de rango y estandarización de variables categóricas.

3.1 2.1 Eliminación de observaciones duplicadas

Ver código
# Verificamos si hay duplicados
sesiones_repetidas <- streaming_sonidec |>
  group_by_all() |>
  filter(n() > 1) |>
  ungroup()

glimpse(sesiones_repetidas)
Rows: 160
Columns: 10
$ usuario_id           <int> 2248, 5054, 3906, 4452, 2268, 4978, 7386, 4523, 8…
$ fecha_sesion         <chr> "2025-05-04", "2024-11-30", "2024-09-05", "2024-1…
$ genero_musical       <chr> "Reggaeton", "Rock", "Electrónica", "Reggaeton",…
$ dispositivo          <chr> "Computador", "Móvil", "Tablet", "Computador", "…
$ duracion_min         <dbl> 23.3, 46.6, 24.9, 34.5, NA, 30.3, 9.6, 59.7, NA, …
$ canciones_escuchadas <int> 7, 13, 11, 14, 5, 10, 4, 22, 17, 1, 3, 11, 5, 3, …
$ skip_rate            <dbl> 0.402, 0.825, 0.733, 0.217, 0.841, 0.168, 0.678, …
$ plan                 <chr> "Premium", "Premium", "Familiar", "Familiar", "Pr…
$ provincia            <chr> "Azuay", "loja", "Guayas", "Pichincha", "Pichinch…
$ rating_sesion        <int> 1, 2, 4, 5, 3, 3, 4, 1, 3, 4, 4, 4, 3, 4, 1, 4, 3…
Ver código
# Eliminamos duplicados
streaming_sonidec_limpio <- streaming_sonidec |> distinct()

cat("Filas originales:", nrow(streaming_sonidec), "\n")
Filas originales: 2080 
Ver código
cat("Filas con repetidos:", nrow(sesiones_repetidas), "\n")
Filas con repetidos: 160 
Ver código
cat("Filas después de eliminar duplicados:", nrow(streaming_sonidec_limpio), "\n")
Filas después de eliminar duplicados: 2000 

Se encontraron 160 registros duplicados que fueron eliminados.

3.2 2.2 Valores faltantes

Ver código
# Conversión de tipos
streaming_sonidec_limpio <- streaming_sonidec_limpio |>
  mutate(
    duracion_min         = as.numeric(duracion_min),
    canciones_escuchadas = as.integer(canciones_escuchadas),
    skip_rate            = as.numeric(skip_rate),
    rating_sesion        = as.integer(rating_sesion),
    fecha_sesion         = as.Date(fecha_sesion),
    usuario_id           = as.integer(usuario_id),
    provincia = iconv(provincia, from = "latin1", to = "UTF-8")
  )

# Verificamos los NA en cada variable
valores_faltantes <- streaming_sonidec_limpio |> summarise_all(~sum(is.na(.)))
glimpse(valores_faltantes)
Rows: 1
Columns: 10
$ usuario_id           <int> 0
$ fecha_sesion         <int> 0
$ genero_musical       <int> 0
$ dispositivo          <int> 0
$ duracion_min         <int> 120
$ canciones_escuchadas <int> 0
$ skip_rate            <int> 100
$ plan                 <int> 0
$ provincia            <int> 0
$ rating_sesion        <int> 160
Ver código
# Función moda
moda <- function(x) {
  ux <- unique(x[!is.na(x)])
  ux[which.max(tabulate(match(x, ux)))]
}

# Imputación:
# - duracion_min  → mediana (robusta ante distribución sesgada)
# - skip_rate     → media   (variable acotada [0,1])
# - rating_sesion → moda    (variable discreta ordinal)
streaming_sonidec_limpio <- streaming_sonidec_limpio |>
  mutate(
    duracion_min  = ifelse(is.na(duracion_min),
                           median(duracion_min, na.rm = TRUE), duracion_min),
    skip_rate     = ifelse(is.na(skip_rate),
                           mean(skip_rate, na.rm = TRUE), skip_rate),
    rating_sesion = ifelse(is.na(rating_sesion),
                           moda(rating_sesion), rating_sesion)
  )

# Verificamos que no queden NAs
valores_faltantes_post <- streaming_sonidec_limpio |> summarise_all(~sum(is.na(.)))
glimpse(valores_faltantes_post)
Rows: 1
Columns: 10
$ usuario_id           <int> 0
$ fecha_sesion         <int> 0
$ genero_musical       <int> 0
$ dispositivo          <int> 0
$ duracion_min         <int> 0
$ canciones_escuchadas <int> 0
$ skip_rate            <int> 0
$ plan                 <int> 0
$ provincia            <int> 0
$ rating_sesion        <int> 0

3.3 2.3 Fechas fuera del rango 2023–2026

Ver código
fechas_fuera <- streaming_sonidec_limpio |>
  filter(fecha_sesion < as.Date("2023-01-01") | fecha_sesion > as.Date("2026-12-31"))

cat("Registros con fecha fuera del rango 2023–2026:", nrow(fechas_fuera), "\n")
Registros con fecha fuera del rango 2023–2026: 40 
Ver código
streaming_sonidec_limpio <- streaming_sonidec_limpio |>
  filter(fecha_sesion >= as.Date("2023-01-01") & fecha_sesion <= as.Date("2026-12-31"))

cat("Filas tras limpiar fechas:", nrow(streaming_sonidec_limpio), "\n")
Filas tras limpiar fechas: 1960 

3.4 2.4 Estandarizar la variable provincia

Ver código
# Verificamos las formas de escritura
streaming_sonidec_limpio |> count(provincia)
             provincia   n
1                AZUAY  16
2                Azuay 215
3               GUAYAS  22
4               Guayas 489
5                 LOJA   8
6                 Loja 143
7  MANABÃ\u0083Â\u008d  17
8       ManabÃ\u0083­ 336
9            PICHINCHA  25
10           Pichincha 485
11               azuay  26
12              guayas  71
13                loja  24
14      manabÃ\u0083­  29
15           pichincha  54
Ver código
# Convertimos a formato título con str_to_title
streaming_sonidec_limpio <- streaming_sonidec_limpio |>
  mutate(provincia = str_to_title(str_trim(provincia)))

# Verificamos
streaming_sonidec_limpio |> count(provincia)
            provincia   n
1               Azuay 257
2              Guayas 582
3                Loja 175
4 Manabã\u0083Â\u008d  17
5      Manabã\u0083­ 365
6           Pichincha 564

4 3. Enriquecimiento

Creamos nuevas variables derivadas que amplían la capacidad analítica del dataset.

Ver código
streaming_sonidec_enriquecido <- streaming_sonidec_limpio |>
  mutate(
    # Zona geográfica a partir de provincia
    zona = case_when(
      provincia %in% c("Pichincha", "Azuay", "Loja")    ~ "Sierra",
      provincia %in% c("Guayas", "Manabi", "Manabí")    ~ "Costa",
      TRUE ~ "Otra"
    ),

    # Segmento de usuario según intensidad de uso
    segmento_usuario = case_when(
      duracion_min < 20                       ~ "Oyente casual",
      duracion_min >= 20 & duracion_min <= 60 ~ "Oyente regular",
      duracion_min > 60                       ~ "Oyente intensivo"
    ),
    segmento_usuario = factor(segmento_usuario,
                              levels = c("Oyente casual",
                                         "Oyente regular",
                                         "Oyente intensivo")),

    # Variables temporales
    anio          = year(fecha_sesion),
    mes           = month(fecha_sesion, label = FALSE),
    dia_semana    = wday(fecha_sesion, label = TRUE, abbr = FALSE),
    es_fin_semana = wday(fecha_sesion) %in% c(1L, 7L),
    tipo_dia      = ifelse(es_fin_semana, "Fin de semana", "Día laboral")
  )

datos <- streaming_sonidec_enriquecido

cat("Variables nuevas creadas: zona, segmento_usuario, anio, mes, dia_semana, es_fin_semana\n\n")
Variables nuevas creadas: zona, segmento_usuario, anio, mes, dia_semana, es_fin_semana
Ver código
datos |> count(zona)
    zona   n
1  Costa 582
2   Otra 382
3 Sierra 996
Ver código
datos |> count(segmento_usuario)
  segmento_usuario    n
1    Oyente casual  287
2   Oyente regular 1449
3 Oyente intensivo  224

5 4. EDA — Análisis Exploratorio de Datos

5.1 4.1 Género Musical (Cualitativa)

Frecuencia de sesiones y total de minutos escuchados por género musical.

Ver código
datos |> count(genero_musical, sort = TRUE)
  genero_musical   n
1      Reggaeton 566
2            Pop 485
3           Rock 288
4          Salsa 240
5   Electrónica 211
6          Indie 170
Ver código
g1a <- datos |>
  count(genero_musical, name = "sesiones") |>
  mutate(genero_musical = fct_reorder(genero_musical, sesiones)) |>
  ggplot(aes(x = genero_musical, y = sesiones, fill = genero_musical)) +
  geom_col(width = 0.68, show.legend = FALSE) +
  geom_text(aes(label = comma(sesiones)), hjust = -0.1, size = 3.6, fontface = "bold") +
  coord_flip() +
  scale_fill_manual(values = PALETA_6) +
  scale_y_continuous(expand = expansion(mult = c(0, 0.18))) +
  labs(title = "Sesiones por género musical", x = NULL, y = "N.º de sesiones") +
  theme_minimal(base_size = 13) +
  theme(panel.grid.major.y = element_blank(),
        plot.title = element_text(face = "bold", color = COL_PRIM))

g1b <- datos |>
  group_by(genero_musical) |>
  summarise(total_min = sum(duracion_min), .groups = "drop") |>
  mutate(genero_musical = fct_reorder(genero_musical, total_min)) |>
  ggplot(aes(x = genero_musical, y = total_min / 1000, fill = genero_musical)) +
  geom_col(width = 0.68, show.legend = FALSE) +
  geom_text(aes(label = paste0(round(total_min/1000, 1), "k")),
            hjust = -0.1, size = 3.6, fontface = "bold") +
  coord_flip() +
  scale_fill_manual(values = PALETA_6) +
  scale_y_continuous(expand = expansion(mult = c(0, 0.18))) +
  labs(title = "Minutos totales por género", x = NULL, y = "Miles de minutos") +
  theme_minimal(base_size = 13) +
  theme(panel.grid.major.y = element_blank(),
        plot.title = element_text(face = "bold", color = COL_PRIM))

(g1a | g1b)

Conclusión: El Reggaeton lidera ampliamente en número de sesiones y total de minutos escuchados, reflejando su predominancia en el mercado ecuatoriano. Le siguen Pop y Salsa. Indie y Electrónica, aunque con menor volumen, representan nichos con alta fidelidad. SonidEC debe priorizar su catálogo de Reggaeton y Pop para maximizar el engagement general.


5.2 4.2 Dispositivo (Cualitativa)

Distribución de uso por tipo de dispositivo.

Ver código
datos |> count(dispositivo, sort = TRUE)
  dispositivo    n
1      Móvil 1101
2  Computador  476
3      Tablet  239
4     SmartTV  144
Ver código
datos |>
  count(dispositivo, name = "n") |>
  mutate(pct = n / sum(n),
         label = paste0(dispositivo, "\n", percent(pct, accuracy = 0.1))) |>
  ggplot(aes(x = 2, y = n, fill = dispositivo)) +
  geom_col(width = 1, color = "white", linewidth = 0.9) +
  geom_text(aes(label = label), position = position_stack(vjust = 0.5),
            color = "white", fontface = "bold", size = 4.2) +
  coord_polar(theta = "y") +
  xlim(0.5, 2.5) +
  scale_fill_manual(values = c("#6C3FC5","#F7931E","#2EC4B6","#E63946")) +
  labs(title = "Distribución de sesiones por dispositivo",
       subtitle = "SonidEC | 2023–2026") +
  theme_void(base_size = 13) +
  theme(plot.title    = element_text(face = "bold", size = 14,
                                     color = COL_PRIM, hjust = 0.5),
        plot.subtitle = element_text(hjust = 0.5, color = "grey50"),
        legend.position = "none")

Conclusión: El Móvil concentra alrededor del 50 % de las sesiones, confirmando que la app móvil es el canal principal de consumo. SonidEC debe priorizar la optimización de la experiencia móvil y explorar estrategias para crecer en SmartTV, ligado al plan Familiar.


5.3 4.3 Duración de Sesión (Cuantitativa)

Histograma y boxplot con detección de outliers mediante criterio IQR.

Ver código
Q1_d  <- quantile(datos$duracion_min, 0.25)
Q3_d  <- quantile(datos$duracion_min, 0.75)
lim_sup_d <- Q3_d + 1.5 * (Q3_d - Q1_d)
n_out_d   <- sum(datos$duracion_min > lim_sup_d)

cat("Límite superior IQR:", round(lim_sup_d, 1), "min | Outliers:", n_out_d, "\n")
Límite superior IQR: 84.9 min | Outliers: 15 
Ver código
g3a <- ggplot(datos, aes(x = duracion_min)) +
  geom_histogram(bins = 45, fill = COL_PRIM, color = "white", alpha = 0.85) +
  geom_vline(xintercept = median(datos$duracion_min),
             color = COL_ACENT, linetype = "dashed", linewidth = 1.1) +
  geom_vline(xintercept = lim_sup_d,
             color = "#E63946", linetype = "dotted", linewidth = 1) +
  annotate("text", x = median(datos$duracion_min) + 1.5, y = Inf,
           label = paste("Mediana:", round(median(datos$duracion_min), 1), "min"),
           vjust = 2.2, hjust = 0, color = COL_ACENT, size = 3.8, fontface = "bold") +
  labs(title = "Distribución de duración de sesión",
       x = "Duración (min)", y = "Frecuencia") +
  theme_minimal(base_size = 13) +
  theme(plot.title = element_text(face = "bold", color = COL_PRIM))

g3b <- ggplot(datos, aes(y = duracion_min, x = "")) +
  geom_boxplot(fill = COL_PRIM, alpha = 0.7,
               outlier.color = "#E63946", outlier.size = 1.5, width = 0.45) +
  stat_summary(fun = mean, geom = "point", shape = 18, size = 5, color = COL_ACENT) +
  labs(title = "Boxplot", x = NULL, y = "Minutos") +
  theme_minimal(base_size = 13) +
  theme(plot.title = element_text(face = "bold", color = COL_PRIM),
        axis.text.x = element_blank(), axis.ticks.x = element_blank())

(g3a | g3b) + plot_layout(widths = c(3, 1))

Conclusión: La distribución de duracion_min es asimétrica positiva (sesgada a la derecha), con la mayoría de sesiones entre 15 y 60 minutos. Se detectan 15 outliers por encima del límite IQR (84.9 min), correspondientes probablemente a sesiones en reproducción automática sin interacción activa. La mediana es el estadístico más representativo para esta variable.


5.4 4.4 Skip Rate (Cuantitativa)

Histograma con curva de densidad. Un skip rate alto indica insatisfacción con las recomendaciones.

Ver código
cat("Media:", round(mean(datos$skip_rate), 3),
    "| Mediana:", round(median(datos$skip_rate), 3),
    "| Asimetría:", round(skewness(datos$skip_rate), 3), "\n")
Media: 0.507 | Mediana: 0.507 | Asimetría: -0.028 
Ver código
ggplot(datos, aes(x = skip_rate)) +
  geom_histogram(aes(y = after_stat(density)), bins = 38,
                 fill = COL_ACENT, color = "white", alpha = 0.82) +
  geom_density(color = COL_PRIM, linewidth = 1.3) +
  geom_vline(xintercept = 0.3, color = "#2EC4B6", linetype = "dashed", linewidth = 1) +
  geom_vline(xintercept = 0.6, color = "#E63946", linetype = "dashed", linewidth = 1) +
  annotate("text", x = 0.14, y = Inf, label = "Bajo\n(< 0.3)",
           vjust = 2, color = "#2EC4B6", fontface = "bold", size = 4) +
  annotate("text", x = 0.45, y = Inf, label = "Medio\n(0.3–0.6)",
           vjust = 2, color = "grey40", fontface = "bold", size = 4) +
  annotate("text", x = 0.80, y = Inf, label = "Alto\n(> 0.6)",
           vjust = 2, color = "#E63946", fontface = "bold", size = 4) +
  labs(title = "Distribución del Skip Rate",
       subtitle = "Proporción de canciones omitidas por sesión (0 = ninguna, 1 = todas)",
       x = "Skip Rate", y = "Densidad") +
  theme_minimal(base_size = 13) +
  theme(plot.title = element_text(face = "bold", color = COL_PRIM))

Conclusión: Un skip rate alto (> 0.6) significa que el usuario omitió más del 60 % de las canciones sugeridas, señal de que las recomendaciones no están alineadas con sus preferencias. La distribución muestra una concentración en valores medios-altos, lo que indica oportunidad de mejora en el motor de personalización de SonidEC.


5.5 4.5 Plan (Cualitativa)

Comparación de la duración de sesión según el tipo de plan contratado.

Ver código
datos |>
  group_by(plan) |>
  summarise(mediana = median(duracion_min), media = mean(duracion_min),
            n = n(), .groups = "drop")
# A tibble: 3 × 4
  plan     mediana media     n
  <chr>      <dbl> <dbl> <int>
1 Familiar    38.5  38.9   298
2 Gratuito    38.5  38.9   971
3 Premium     38.5  38.7   691
Ver código
datos |>
  mutate(plan = fct_reorder(plan, duracion_min, median)) |>
  ggplot(aes(x = plan, y = duracion_min, fill = plan)) +
  geom_violin(alpha = 0.55, trim = TRUE, color = NA) +
  geom_boxplot(width = 0.2, outlier.size = 1.2, alpha = 0.9, color = "grey25") +
  stat_summary(fun = mean, geom = "point", shape = 18, size = 5, color = COL_ACENT) +
  scale_fill_manual(values = c("#9B72D0","#F4A261","#2EC4B6")) +
  labs(title = "Duración de sesión según tipo de plan",
       subtitle = "Diamante naranja = media  |  Línea central = mediana",
       x = "Plan", y = "Duración (min)") +
  theme_minimal(base_size = 13) +
  theme(legend.position = "none",
        plot.title = element_text(face = "bold", color = COL_PRIM))

Conclusión: Los usuarios Premium y Familiar registran sesiones más largas que los Gratuitos. La ausencia de publicidad y el acceso ilimitado reducen la fricción en la experiencia de escucha. Esta diferencia justifica campañas de conversión hacia planes pagos como palanca principal de crecimiento del engagement.


5.6 4.6 Segmento de Usuario (Cualitativa derivada)

Distribución de los segmentos de oyente creados en la etapa de enriquecimiento.

Ver código
datos |> count(segmento_usuario)
  segmento_usuario    n
1    Oyente casual  287
2   Oyente regular 1449
3 Oyente intensivo  224
Ver código
datos |>
  count(segmento_usuario) |>
  mutate(pct = n / sum(n)) |>
  ggplot(aes(x = segmento_usuario, y = n, fill = segmento_usuario)) +
  geom_col(width = 0.58, show.legend = FALSE) +
  geom_text(aes(label = paste0(comma(n), "\n(", percent(pct, accuracy = 0.1), ")")),
            vjust = -0.35, fontface = "bold", size = 4) +
  scale_fill_manual(values = c("#9B72D0","#F4A261","#E63946")) +
  scale_y_continuous(expand = expansion(mult = c(0, 0.18))) +
  labs(title = "Distribución de segmentos de usuario",
       subtitle = "Clasificación según duración de sesión",
       x = NULL, y = "Número de sesiones") +
  theme_minimal(base_size = 13) +
  theme(panel.grid.major.x = element_blank(),
        plot.title = element_text(face = "bold", color = COL_PRIM))

Conclusión: El segmento “Oyente regular” (20–60 min) domina, representando el perfil típico de usuario que integra la música en su rutina diaria. Los “Oyentes intensivos” (> 60 min) son el segmento de mayor valor para monetización Premium. Los “Oyentes casuales” (< 20 min) requieren estrategias de retención con playlists cortas y personalizadas.


5.7 4.7 Fin de Semana (Variable binaria)

¿Las sesiones son más largas los fines de semana?

Ver código
datos |>
  group_by(tipo_dia) |>
  summarise(mediana = median(duracion_min), media = mean(duracion_min),
            n = n(), .groups = "drop")
# A tibble: 2 × 4
  tipo_dia      mediana media     n
  <chr>           <dbl> <dbl> <int>
1 Día laboral      38.5  38.8  1393
2 Fin de semana    38.5  39.0   567
Ver código
ggplot(datos, aes(x = duracion_min, y = tipo_dia, fill = tipo_dia)) +
  geom_density_ridges(alpha = 0.72, scale = 1.3, rel_min_height = 0.01,
                      color = "white", linewidth = 0.5) +
  scale_fill_manual(values = c("Día laboral" = COL_PRIM, "Fin de semana" = COL_ACENT)) +
  labs(title = "¿Son más largas las sesiones en fin de semana?",
       subtitle = "Distribución de duración por tipo de día",
       x = "Duración (min)", y = NULL, fill = NULL) +
  theme_minimal(base_size = 13) +
  theme(legend.position = "top",
        plot.title = element_text(face = "bold", color = COL_PRIM))

Conclusión: Las sesiones de fin de semana tienden a ser ligeramente más largas que las de días laborales por el mayor tiempo libre disponible. Sin embargo, la diferencia es moderada, indicando que SonidEC es una plataforma de uso cotidiano integrado a la rutina diaria, no solo de ocio.


6 Preguntas de Negocio

6.1 PB1 — ¿Qué género concentra más minutos escuchados?

Ver código
minutos_por_genero <- datos |>
  group_by(genero_musical) |>
  summarise(total_min = sum(duracion_min), .groups = "drop") |>
  arrange(desc(total_min))

minutos_por_genero
# A tibble: 6 × 2
  genero_musical total_min
  <chr>              <dbl>
1 Reggaeton         21977.
2 Pop               19174.
3 Rock              11219.
4 Salsa              9236.
5 Electrónica       7832.
6 Indie              6672.
Ver código
cat("Respuesta:", minutos_por_genero$genero_musical[1], "con",
    round(minutos_por_genero$total_min[1]), "minutos totales\n")
Respuesta: Reggaeton con 21977 minutos totales

6.2 PB2 — ¿Los usuarios Premium tienen menor skip_rate que los Gratuitos?

Ver código
skip_por_plan <- datos |>
  filter(plan %in% c("Premium", "Gratuito")) |>
  group_by(plan) |>
  summarise(skip_medio = mean(skip_rate), n = n(), .groups = "drop")

skip_por_plan
# A tibble: 2 × 3
  plan     skip_medio     n
  <chr>         <dbl> <int>
1 Gratuito      0.508   971
2 Premium       0.496   691
Ver código
datos |>
  filter(plan %in% c("Premium","Gratuito")) |>
  ggplot(aes(x = plan, y = skip_rate, fill = plan)) +
  geom_violin(alpha = 0.55, color = NA, trim = TRUE) +
  geom_boxplot(width = 0.2, outlier.size = 1, alpha = 0.85, color = "grey20") +
  stat_summary(fun = mean, geom = "point", shape = 18, size = 5, color = COL_ACENT) +
  scale_fill_manual(values = c("Gratuito" = "#9B72D0", "Premium" = "#2EC4B6")) +
  labs(title = "Skip Rate: Premium vs Gratuito", x = NULL, y = "Skip Rate") +
  theme_minimal(base_size = 13) +
  theme(legend.position = "none",
        plot.title = element_text(face = "bold", color = COL_PRIM))

6.3 PB3 — ¿Qué provincia tiene más sesiones y cuál mayor duración promedio?

Ver código
sesiones_prov <- datos |> count(provincia, name = "sesiones") |> arrange(desc(sesiones))
duracion_prov <- datos |>
  group_by(provincia) |>
  summarise(dur_prom = mean(duracion_min), .groups = "drop") |>
  arrange(desc(dur_prom))

sesiones_prov
            provincia sesiones
1              Guayas      582
2           Pichincha      564
3      Manabã\u0083­      365
4               Azuay      257
5                Loja      175
6 Manabã\u0083Â\u008d       17
Ver código
duracion_prov
# A tibble: 6 × 2
  provincia             dur_prom
  <chr>                    <dbl>
1 "Manabã\u0083­"           39.8
2 "Azuay"                   39.6
3 "Pichincha"               38.9
4 "Loja"                    38.7
5 "Guayas"                  38.1
6 "Manabã\u0083Â\u008d"     28.4
Ver código
sesiones_prov |>
  left_join(duracion_prov, by = "provincia") |>
  pivot_longer(c(sesiones, dur_prom), names_to = "metrica", values_to = "valor") |>
  mutate(metrica = recode(metrica, sesiones = "Sesiones (n)",
                          dur_prom = "Duración prom. (min)")) |>
  ggplot(aes(x = fct_reorder(provincia, valor), y = valor, fill = provincia)) +
  geom_col(show.legend = FALSE, width = 0.65) +
  coord_flip() +
  facet_wrap(~metrica, scales = "free_x") +
  scale_fill_manual(values = PALETA_6) +
  labs(title = "Sesiones y duración promedio por provincia", x = NULL, y = NULL) +
  theme_minimal(base_size = 12) +
  theme(plot.title = element_text(face = "bold", color = COL_PRIM))

6.4 PB4 — ¿Qué mes tiene el mayor número de sesiones?

Ver código
sesiones_mes <- datos |>
  count(mes, name = "sesiones") |>
  mutate(mes_nombre = month(mes, label = TRUE, abbr = FALSE)) |>
  arrange(desc(sesiones))

sesiones_mes
   mes sesiones mes_nombre
1    1      194      enero
2   11      188  noviembre
3    7      180      julio
4    5      173       mayo
5   12      170  diciembre
6    3      168      marzo
7    9      165 septiembre
8   10      155    octubre
9    6      147      junio
10   2      142    febrero
11   4      139      abril
12   8      139     agosto
Ver código
datos |>
  count(mes) |>
  mutate(mes_lab = factor(month.abb[mes], levels = month.abb)) |>
  ggplot(aes(x = mes_lab, y = n, group = 1)) +
  geom_area(fill = COL_PRIM, alpha = 0.15) +
  geom_line(color = COL_PRIM, linewidth = 1.4) +
  geom_point(aes(color = n), size = 4.5) +
  geom_text(aes(label = n), vjust = -1, size = 3.4, fontface = "bold") +
  scale_color_gradient(low = "#C4B5F5", high = COL_PRIM) +
  scale_y_continuous(expand = expansion(mult = c(0.05, 0.2))) +
  labs(title = "Número de sesiones por mes del año", x = "Mes", y = "Sesiones") +
  theme_minimal(base_size = 13) +
  theme(legend.position = "none",
        plot.title = element_text(face = "bold", color = COL_PRIM))

6.5 PB5 — ¿Existe relación entre canciones escuchadas y duración?

Ver código
cor_pears <- cor(datos$canciones_escuchadas, datos$duracion_min)
cat("Correlación Pearson: r =", round(cor_pears, 4), "\n")
Correlación Pearson: r = 0.9329 
Ver código
datos |>
  slice_sample(n = min(900, nrow(datos))) |>
  ggplot(aes(x = duracion_min, y = canciones_escuchadas)) +
  geom_point(alpha = 0.3, color = COL_PRIM, size = 1.8) +
  geom_smooth(method = "lm", color = COL_ACENT, linewidth = 1.4, se = TRUE) +
  annotate("text", x = Inf, y = Inf,
           label = paste0("r = ", round(cor_pears, 3)),
           hjust = 1.2, vjust = 2, size = 5.5, fontface = "bold", color = COL_ACENT) +
  labs(title = "Relación entre canciones escuchadas y duración de sesión",
       subtitle = "Muestra aleatoria | línea = regresión lineal",
       x = "Duración de sesión (min)", y = "Canciones escuchadas") +
  theme_minimal(base_size = 13) +
  theme(plot.title = element_text(face = "bold", color = COL_PRIM))

Conclusión: Se confirma una correlación fuerte positiva (r = 0.933) entre la duración de la sesión y el número de canciones escuchadas. A mayor tiempo de sesión, más canciones se consumen. Esto valida la coherencia del dataset y confirma que canciones_escuchadas es una proxy razonable del nivel de engagement activo del usuario.


7 5. Transformación

Preparamos las variables para algoritmos de Machine Learning.

Ver código
streaming_sonidec_transformado <- streaming_sonidec_enriquecido |>
  mutate(
    # Discretizar skip_rate en tres categorías
    cat_skip = case_when(
      skip_rate < 0.3  ~ "Bajo",
      skip_rate <= 0.6 ~ "Medio",
      TRUE             ~ "Alto"
    ),
    cat_skip = factor(cat_skip, levels = c("Bajo", "Medio", "Alto")),

    # Codificación numérica de plan y dispositivo
    plan_cod        = as.numeric(factor(plan)),
    dispositivo_cod = as.numeric(factor(dispositivo)),

    # Normalización z-score
    duracion_norm  = as.numeric(scale(duracion_min)),
    canciones_norm = as.numeric(scale(canciones_escuchadas)),

    # Eficiencia de sesión: canciones por minuto
    eficiencia_sesion = canciones_escuchadas / duracion_min
  )

# Distribución de cat_skip
streaming_sonidec_transformado |> count(cat_skip)
  cat_skip   n
1     Bajo 555
2    Medio 632
3     Alto 773
Ver código
# Codificaciones
streaming_sonidec_transformado |> distinct(plan, plan_cod) |> arrange(plan_cod)
      plan plan_cod
1 Familiar        1
2 Gratuito        2
3  Premium        3
Ver código
streaming_sonidec_transformado |> distinct(dispositivo, dispositivo_cod) |> arrange(dispositivo_cod)
  dispositivo dispositivo_cod
1  Computador               1
2      Móvil               2
3     SmartTV               3
4      Tablet               4
Ver código
# Verificación de normalización
cat("duracion_norm  — media:", round(mean(streaming_sonidec_transformado$duracion_norm), 5),
    "| sd:", round(sd(streaming_sonidec_transformado$duracion_norm), 5), "\n")
duracion_norm  — media: 0 | sd: 1 
Ver código
cat("canciones_norm — media:", round(mean(streaming_sonidec_transformado$canciones_norm), 5),
    "| sd:", round(sd(streaming_sonidec_transformado$canciones_norm), 5), "\n")
canciones_norm — media: 0 | sd: 1 
Ver código
# Vista previa
glimpse(streaming_sonidec_transformado |>
  select(plan, plan_cod, dispositivo, dispositivo_cod,
         duracion_min, duracion_norm, skip_rate, cat_skip, eficiencia_sesion))
Rows: 1,960
Columns: 9
$ plan              <chr> "Gratuito", "Gratuito", "Gratuito", "Premium", "Grat…
$ plan_cod          <dbl> 2, 2, 2, 3, 2, 2, 3, 2, 3, 1, 1, 3, 2, 3, 2, 1, 3, 1…
$ dispositivo       <chr> "Móvil", "Móvil", "Móvil", "Tablet", "Computador"…
$ dispositivo_cod   <dbl> 2, 2, 2, 4, 1, 2, 1, 1, 2, 3, 2, 4, 2, 2, 2, 2, 2, 1…
$ duracion_min      <dbl> 25.7, 39.6, 19.5, 29.3, 51.9, 30.1, 38.8, 43.2, 50.3…
$ duracion_norm     <dbl> -0.736261342, 0.043077615, -1.083880158, -0.53441815…
$ skip_rate         <dbl> 0.603, 0.691, 0.913, 0.882, 0.720, 0.832, 0.992, 0.6…
$ cat_skip          <fct> Alto, Alto, Alto, Alto, Alto, Alto, Alto, Alto, Medi…
$ eficiencia_sesion <dbl> 0.4280156, 0.2525253, 0.2051282, 0.2389078, 0.346820…

8 Resumen Ejecutivo

Ver código
cat("Dataset original          :", nrow(streaming_sonidec), "filas ×",
    ncol(streaming_sonidec), "columnas\n")
Dataset original          : 2080 filas × 10 columnas
Ver código
cat("Duplicados eliminados      :", nrow(sesiones_repetidas), "\n")
Duplicados eliminados      : 160 
Ver código
cat("Fechas fuera de rango      :", nrow(fechas_fuera), "\n")
Fechas fuera de rango      : 40 
Ver código
cat("NAs imputados - duracion   :", sum(is.na(streaming_sonidec$duracion_min)), "\n")
NAs imputados - duracion   : 125 
Ver código
cat("NAs imputados - skip_rate  :", sum(is.na(streaming_sonidec$skip_rate)), "\n")
NAs imputados - skip_rate  : 106 
Ver código
cat("NAs imputados - rating     :", sum(is.na(streaming_sonidec$rating_sesion)), "\n")
NAs imputados - rating     : 163 
Ver código
cat("Variables nuevas creadas   : 8\n")
Variables nuevas creadas   : 8
Ver código
cat("Dataset final              :", nrow(streaming_sonidec_transformado), "filas ×",
    ncol(streaming_sonidec_transformado), "columnas\n")
Dataset final              : 1960 filas × 23 columnas