Saltar al contenido

lección 5

Día 3 (tarde) -- Modelo analítico de contenido

Respondes la pregunta de Sofía: ¿qué contenido funciona y por qué? Analizas engagement por género, duración y tipo. Diseñas el content performance score.

⏱ 55 min

## Miércoles, 14:00 -- La pregunta de Sofía

Esta tarde se cruzan dos fuentes que no casan. Por un lado `catalogo.csv`, con `content_id, title, content_type, genre_primary, duration_minutes, season_count, episode_count, is_original` -- y ojo con la unidad, porque la duración está en MINUTOS y el tiempo visto en segundos, así que cualquier cociente entre los dos necesita un por 60. Por otro, los costes de licencia, que no están en el catálogo: viven en `costes_licencias.csv` (el mismo contenido que el `.xlsx` que mantiene Javier a mano, para que se pueda consultar con SQL). Ese fichero tiene 388 filas para 1.200 títulos, los importes en formato europeo, las fechas en `DD/MM/YYYY`, los títulos con erratas y ocho packs que agrupan varios títulos sin decir cuáles. Lo único que comparten las dos fuentes es el título, y casarlas es la mitad del trabajo de la tarde.

Después de comer, Sofía te pilla en la cocina: "He oído que ya tienes datos funcionando. ¿Puedes ayudarme con algo? Tengo que decidir en qué gastar 200K EUR en licencias de contenido para el Q2 y literalmente no tengo ni idea de qué funciona mejor en nuestra plataforma. ¿Comedia o drama? ¿Series cortas o películas largas? ¿Documentales de naturaleza o true crime? Cualquier dato que me des es mejor que lo que tengo ahora, que es mi intuición."

Esta es la pregunta de negocio más interesante de la semana. Para Marta, las métricas de producto cuentan una historia de tracción. Para Sofía, el análisis de contenido responde una pregunta operativa concreta: dónde invertir 200K EUR. Si le das la respuesta correcta, StreamVibe compra el contenido que maximiza engagement y retención. Si se equivoca, quema presupuesto en títulos que nadie ve.

## El modelo de performance de contenido

Necesitas un framework que evalúe cada pieza de contenido en múltiples dimensiones. No basta con "cuántas veces se vio" -- una película mala puede tener muchos plays (por curiosidad o marketing) pero poca retención. El verdadero indicador de que un contenido funciona es: ¿la gente lo termina? ¿vuelve al día siguiente? ¿lo recomienda (proxied por si otros usuarios con patrones similares también lo ven)?

1-- Lo primero es el coste por titulo, porque no viene en el catalogo.
2-- Sale de la hoja que mantiene Javier a mano, y hay que limpiarla: importes en
3-- formato europeo, sufijos en los titulos, mayusculas y espacios de sobra.
4CREATE OR REPLACE VIEW coste_titulo AS
5WITH limpio AS (
6 SELECT TRIM(REPLACE(titulo, ' (temp 1-2)', '')) AS titulo,
7 -- "1.565,32" -> 1565.32: primero fuera el punto de los miles, y
8 -- despues la coma decimal a punto. En ese orden, o se pierde todo.
9 TRY_CAST(REPLACE(REPLACE(coste_mensual_eur, '.', ''), ',', '.')
10 AS DOUBLE) AS coste_eur
11 FROM costes_licencias
12 -- Los packs fuera de aqui: una fila que dice "Pack series BBC, incluye 5
13 -- titulos" no tiene un coste por titulo hasta que alguien decida como
14 -- repartirlo, y eso es una decision, no un calculo. Se tratan aparte.
15 WHERE titulo NOT LIKE 'Pack %'
16),
17por_clave AS (
18 SELECT LOWER(TRIM(titulo)) AS clave, MAX(coste_eur) AS coste_eur
19 FROM limpio GROUP BY 1
20)
21SELECT c.content_id, p.coste_eur
22FROM content c JOIN por_clave p ON LOWER(c.title) = p.clave;
23
24-- Y ahora la puntuacion de rendimiento por titulo.
25WITH hoy AS (
26 -- "Hoy" es el ultimo dia con datos, nunca CURRENT_DATE: el extracto esta
27 -- congelado en octubre de 2024, y anclado al reloj del ordenador esta
28 -- consulta devuelve cero filas y parece que la escribiste mal.
29 SELECT MAX(started_at) AS fecha FROM playbacks
30),
31por_titulo AS (
32 SELECT
33 c.content_id,
34 c.title,
35 c.content_type,
36 c.genre_primary,
37 c.duration_minutes,
38 t.coste_eur,
39 COUNT(p.playback_id) AS reproducciones,
40 COUNT(DISTINCT p.user_id) AS espectadores,
41 SUM(p.watch_seconds) / 3600.0 AS horas,
42 -- El cociente de tiempos necesita la duracion EN SEGUNDOS. El catalogo
43 -- la da en minutos: sin el x60 el porcentaje sale 60 veces mas alto.
44 AVG(p.watch_seconds * 1.0
45 / NULLIF(c.duration_minutes * 60.0, 0)) * 100 AS pct_visto,
46 -- Denominador COUNT(p.playback_id), no COUNT(*): con COUNT(*) la fila
47 -- de un titulo que nadie ha visto cuenta 1 y sale un 0% de
48 -- finalizacion, que se lee como "nadie lo termina" cuando lo que pasa
49 -- es que nadie lo ha empezado.
50 SUM(CASE WHEN p.completed THEN 1 ELSE 0 END) * 100.0
51 / NULLIF(COUNT(p.playback_id), 0) AS pct_terminado
52 FROM content c
53 LEFT JOIN coste_titulo t ON t.content_id = c.content_id
54 -- LAS CONDICIONES SOBRE LA TABLA DE LA DERECHA VAN EN EL ON.
55 -- Si la fecha se pone en el WHERE, el cruce por la izquierda deja de serlo:
56 -- los titulos sin reproducciones tienen NULL ahi, el NULL no cumple la
57 -- condicion y la fila desaparece sin avisar.
58 LEFT JOIN playbacks p
59 ON c.content_id = p.content_id
60 AND p.started_at >= (SELECT fecha FROM hoy) - INTERVAL '90 days'
61 AND p.is_meaningful
62 GROUP BY 1, 2, 3, 4, 5, 6
63),
64puntuada AS (
65 SELECT
66 pt.*,
67 pt.espectadores * 1.0 / (SELECT COUNT(DISTINCT user_id) FROM playbacks
68 WHERE is_meaningful) AS alcance,
69 -- Sin coste no hay retorno que calcular. Se deja a NULO, no a cero:
70 -- un cero en un factor que pesa el 30% manda el titulo al fondo de la
71 -- tabla, y la tabla se usa para decidir que no renovar. Un hueco en la
72 -- hoja de Javier no puede convertirse en una decision de no comprar.
73 CASE WHEN pt.coste_eur > 0
74 THEN pt.horas / pt.coste_eur * 1000 END AS horas_por_1000eur
75 FROM por_titulo pt
76 WHERE pt.reproducciones >= 10 -- por debajo, el porcentaje es ruido
77)
78SELECT
79 content_id, title, content_type, genre_primary, duration_minutes,
80 espectadores,
81 ROUND(pct_visto, 1) AS pct_visto,
82 ROUND(pct_terminado, 1) AS pct_terminado,
83 ROUND(horas, 1) AS horas,
84 ROUND(horas_por_1000eur, 2) AS horas_por_1000eur,
85 -- Sin los tres factores no hay nota. Inventarle una con los dos que si
86 -- tenemos tampoco vale: le daria una posicion en el ranking que no se ha
87 -- ganado, y como nunca podria salir al final, quedaria protegido de la
88 -- lista de no renovar. Las dos cosas son decisiones disfrazadas de calculo.
89 CASE WHEN horas_por_1000eur IS NULL THEN NULL ELSE ROUND(
90 LEAST(alcance / 0.05, 1.0) * 30
91 + LEAST(pct_terminado / 80.0, 1.0) * 40
92 + LEAST(horas_por_1000eur / 6.0, 1.0) * 30
93 , 1) END AS puntuacion
94FROM puntuada
95ORDER BY puntuacion DESC NULLS LAST
96LIMIT 50;

La puntuación por título: alcance (30%), finalización (40%) y retorno por euro (30%).

## La otra mitad del encargo: lo que no se vuelve a comprar

Repartir 200.000 euros tiene dos caras, y la consulta de arriba sólo enseña una. Ordena lo que se ve lo suficiente para poder juzgarlo, y eso responde a "en qué invertir". La otra pregunta -- "qué dejo de pagar" -- son justo los títulos que el umbral de diez reproducciones acaba de echar de la tabla, y son 929 de 1.200. Esa lista no existe en ninguna parte y es la que Sofía va a agradecer más, porque el dinero que libera es dinero que puede gastar.

1-- Lo que no se vuelve a comprar: licencia pagada y casi nadie viendolo.
2WITH hoy AS (SELECT MAX(started_at) AS fecha FROM playbacks)
3SELECT
4 c.content_id, c.title, c.genre_primary, c.duration_minutes,
5 t.coste_eur,
6 -- Marcado a proposito: un titulo sin coste registrado NO es un titulo
7 -- gratis. Es un titulo del que no sabemos lo que pagamos.
8 t.coste_eur IS NULL AS coste_sin_registrar,
9 COUNT(p.playback_id) AS reproducciones,
10 COUNT(DISTINCT p.user_id) AS espectadores
11FROM content c
12LEFT JOIN coste_titulo t ON t.content_id = c.content_id
13LEFT JOIN playbacks p
14 ON c.content_id = p.content_id
15 AND p.started_at >= (SELECT fecha FROM hoy) - INTERVAL '90 days'
16 AND p.is_meaningful
17GROUP BY 1, 2, 3, 4, 5, 6
18HAVING COUNT(p.playback_id) < 10
19-- NULLS LAST a proposito: los titulos sin coste no deben encabezar una lista
20-- ordenada por coste. Van marcados y al final, no arriba.
21ORDER BY t.coste_eur DESC NULLS LAST;
22
23-- Lo que sale con los datos del caso:
24-- 929 titulos con menos de 10 reproducciones en la ventana.
25-- De esos, 636 sin coste registrado.
26-- Los 293 que si tienen coste suman 764.666 EUR al mes.

La lista que libera presupuesto. 929 títulos, y 764.666 EUR al mes en los que sí tienen coste.

Y aquí aparece algo que hay que mirar antes de seguir. Los 293 títulos poco vistos suman 764.666 euros al mes, y el catálogo entero con coste registrado suma 1.016.918. StreamVibe ingresa unos 87.100 euros al mes. Las licencias cuestan doce veces los ingresos. Eso no es un hallazgo de negocio: es una comprobación de orden de magnitud que falla, y lo que se hace con ella no es construir un análisis encima, es levantarse e ir a preguntar. ¿La columna es mensual o anual? ¿El importe es por título o por acuerdo? ¿Incluye lo que ya no está activo? Hasta que Javier no lo aclare, la cifra absoluta no se puede poner en ninguna diapositiva.

Lo que sí se sostiene mientras tanto es el ORDEN. Si toda la columna está mal por el mismo factor -- porque son euros al año y no al mes, por ejemplo -- los cocientes entre géneros y entre formatos no cambian: un factor constante se va arriba y abajo de la división. Así que el análisis de esta tarde se puede hacer y la recomendación se puede defender; lo que no se puede decir es "cuesta tanto". Saber qué parte de tu resultado sobrevive a un error de escala y qué parte no es una de esas cosas que separan a quien lleva tiempo en esto.

## Análisis por género -- ¿comedia o drama?

1-- La version que parece bien y esta mal. Merece la pena escribirla primero,
2-- porque es la que sale sola y el error no da ningun aviso.
3SELECT
4 c.genre_primary,
5 ROUND(SUM(p.watch_seconds) / 3600.0
6 / SUM(t.coste_eur) * 1000, 3) AS horas_por_1000eur
7FROM content c
8JOIN playbacks p ON c.content_id = p.content_id
9JOIN coste_titulo t ON t.content_id = c.content_id
10GROUP BY 1
11ORDER BY horas_por_1000eur DESC;
12
13-- documentary 0,371 <-- primera
14-- animation 0,370
15-- drama 0,351
16-- comedy 0,341
17-- thriller 0,320
18-- horror 0,294
19-- romance 0,281
20-- sci-fi 0,271 <-- ultima
21--
22-- Ocho generos entre 0,271 y 0,371. Sospechosamente parecidos.
23
24-- EL PROBLEMA: despues del cruce hay una fila por REPRODUCCION, y el coste del
25-- titulo va repetido en todas. Asi que SUM(t.coste_eur) no es lo que costo el
26-- catalogo del genero: es el precio de cada titulo multiplicado por sus veces
27-- vistas, y sumado. Un titulo visto cinco mil veces aporta cinco mil veces su
28-- precio.
29--
30-- El efecto no es que el resultado quede raro: es que la metrica se vuelve CIEGA
31-- A LA POPULARIDAD. Numerador y denominador crecen los dos con las
32-- reproducciones, asi que se cancelan y lo que queda es, en la practica, las
33-- horas por reproduccion divididas por el coste medio por reproduccion.
34
35-- LA VERSION BUENA: una fila por titulo primero, el genero despues.
36WITH hoy AS (SELECT MAX(started_at) AS fecha FROM playbacks),
37por_titulo AS (
38 SELECT
39 c.genre_primary AS genero,
40 c.content_id,
41 -- MAX y no SUM: el coste del titulo es uno, aunque la fila se repita.
42 MAX(t.coste_eur) AS coste_eur,
43 COALESCE(SUM(p.watch_seconds), 0) / 3600.0 AS horas,
44 COUNT(p.playback_id) AS reproducciones,
45 COUNT(DISTINCT p.user_id) AS espectadores
46 FROM content c
47 JOIN coste_titulo t ON t.content_id = c.content_id
48 -- Cruce por la IZQUIERDA y con la fecha en el ON: el coste del catalogo que
49 -- nadie ha visto tiene que seguir en el denominador. Si se cae, un genero con
50 -- mucho catalogo muerto sale mejor cuanto mas muerto este.
51 LEFT JOIN playbacks p
52 ON c.content_id = p.content_id
53 AND p.started_at >= (SELECT fecha FROM hoy) - INTERVAL '90 days'
54 AND p.is_meaningful
55 GROUP BY 1, 2
56)
57SELECT
58 genero,
59 COUNT(*) AS titulos,
60 SUM(reproducciones) AS reproducciones,
61 ROUND(SUM(horas), 1) AS horas,
62 ROUND(SUM(horas) / NULLIF(SUM(coste_eur), 0) * 1000, 2) AS horas_por_1000eur
63FROM por_titulo
64GROUP BY genero
65HAVING SUM(reproducciones) >= 100
66ORDER BY horas_por_1000eur DESC;
67
68-- thriller 5,64 <-- primera
69-- horror 5,33
70-- drama 5,14
71-- comedy 5,07
72-- romance 4,81
73-- documentary 4,26
74-- sci-fi 3,64
75-- animation 1,78 <-- ultima

La misma pregunta con las dos fórmulas. La mala no sólo aplana el ranking: lo invierte.

Ojo con el cruce por la izquierda de la versión buena, que es igual de importante y se ve menos. El coste del catálogo que nadie ha visto tiene que seguir contando en el denominador, porque se paga igual. Si se usa un cruce normal, los títulos con cero reproducciones desaparecen y un género sale mejor cuanto más catálogo muerto tenga -- lo cual es exactamente al revés de lo que le interesa a Sofía. Es la misma trampa del `ON` contra el `WHERE`, con otra cara: aquí no se pierde una fila por descuido, se pierde el dinero que esa fila representa.

## Sumar una columna después de un cruce casi siempre está mal

El error del retorno por género no es un despiste de esta lección: es uno de los tres o cuatro errores que se repiten toda una carrera, y no da ningún síntoma. Merece la pena quedarse con la forma, porque la vas a reconocer en sitios donde no estás pensando en licencias de contenido.

La forma es ésta. Tienes una tabla de COSAS -- títulos, clientes, productos, pedidos -- y una tabla de SUCESOS -- reproducciones, visitas, líneas de pedido, pagos. Las cruzas. El resultado tiene una fila por suceso, y todas las columnas de la cosa vienen repetidas en cada una de sus filas. En ese momento, `SUM` sobre una columna de la cosa deja de significar lo que su nombre dice: suma el valor una vez por suceso.

  • El coste de un título por el número de veces que se vio. Es el de esta lección.
  • El precio de un producto por el número de líneas de pedido en que aparece, cuando lo que querías era el valor del inventario.
  • El límite de crédito de un cliente por su número de transacciones, cuando lo que querías era la exposición total del banco.
  • La superficie de una tienda por su número de ventas, cuando lo que querías era metros cuadrados por región.

La prueba cabe en una frase: di en voz alta lo que crees que estás sumando. Si lo que sale es una propiedad de la cosa -- "lo que costó el catálogo", "el valor del inventario", "los metros de la región" -- hay que agrupar primero por la cosa y sumar después. Si lo que sale es un total ponderado por los sucesos -- "los minutos de contenido servidos", "los euros facturados" -- entonces sumar tras el cruce es exactamente lo correcto. Las dos cosas se escriben casi igual y significan cosas distintas, y ninguna de las dos da error.

Y la señal de alarma que conviene aprender a ver: cuando una métrica que debería variar mucho sale casi igual en todos los grupos, sospecha de un cruce. Ocho géneros entre 0,271 y 0,371 no es un hallazgo de que todos los géneros rinden parecido: es el síntoma de que el numerador y el denominador están creciendo los dos con el mismo factor y se cancelan. Un rango sospechosamente estrecho es una de las pocas pistas gratis que dan estos errores.

## ¿Qué género hace que la gente vuelva?

Ésta es la pregunta buena, porque las horas por euro miden eficiencia y la vuelta mide si el contenido engancha. La forma obvia de medirla -- "de los que vieron este género, cuántos vieron algo en los siete días siguientes" -- tiene un problema que no se ve hasta que la pones al lado de su línea de base: eso no mide el género, mide lo activo que ya era el usuario. Alguien que entra todos los días sale como retenido en los cinco géneros que miró esa semana y los sube todos.

1-- Dos columnas con la MISMA forma, que es lo que hace la comparacion honesta:
2-- el mismo conjunto de usuarios y la misma clase de ventana de 7 dias.
3-- la primera cuenta las ventanas que arrancan tras ver ESTE genero
4-- la segunda, las que arrancan tras CUALQUIER reproduccion de esos mismos
5-- usuarios -- o sea, la linea de base de lo activos que ya son
6WITH actividad AS (
7 SELECT DISTINCT user_id, event_date FROM playbacks WHERE is_meaningful
8),
9vistas AS (
10 SELECT p.user_id, c.genre_primary AS genero, p.event_date
11 FROM playbacks p JOIN content c USING (content_id)
12 WHERE p.is_meaningful
13),
14tras_genero AS (
15 SELECT v.genero,
16 COUNT(DISTINCT v.user_id) AS usuarios,
17 COUNT(DISTINCT CASE WHEN EXISTS (
18 SELECT 1 FROM actividad a
19 WHERE a.user_id = v.user_id
20 AND a.event_date BETWEEN v.event_date + 1 AND v.event_date + 7
21 ) THEN v.user_id END) AS vuelven
22 FROM vistas v GROUP BY 1
23),
24linea_base AS (
25 -- Los MISMOS usuarios, pero la ventana arranca tras cualquier cosa que vieran.
26 SELECT v.genero,
27 COUNT(DISTINCT p.user_id) AS usuarios,
28 COUNT(DISTINCT CASE WHEN EXISTS (
29 SELECT 1 FROM actividad a
30 WHERE a.user_id = p.user_id
31 AND a.event_date BETWEEN p.event_date + 1 AND p.event_date + 7
32 ) THEN p.user_id END) AS vuelven
33 FROM (SELECT DISTINCT genero, user_id FROM vistas) v
34 JOIN playbacks p ON p.user_id = v.user_id AND p.is_meaningful
35 GROUP BY 1
36)
37SELECT g.genero, g.usuarios,
38 ROUND(g.vuelven * 100.0 / g.usuarios, 1) AS tras_este_genero,
39 ROUND(b.vuelven * 100.0 / b.usuarios, 1) AS tras_cualquier_cosa,
40 ROUND(g.vuelven * 100.0 / g.usuarios
41 - b.vuelven * 100.0 / b.usuarios, 1) AS diferencia
42FROM tras_genero g JOIN linea_base b USING (genero)
43ORDER BY tras_este_genero DESC;
44
45-- genero usuarios tras_este_genero tras_cualquier_cosa diferencia
46-- drama 1397 74,3 80,5 -6,2
47-- documentary 1262 70,1 80,3 -10,1
48-- thriller 1146 68,1 79,8 -11,8
49-- comedy 1095 67,9 79,5 -11,6
50-- sci-fi 922 66,4 80,7 -14,3
51-- romance 538 61,0 82,0 -21,0
52-- horror 562 61,0 79,9 -18,9
53-- animation 341 60,7 82,1 -21,4

La misma métrica y su línea de base. La columna de la diferencia es la única que dice algo.

Un detalle de la versión original que conviene ver, porque es el tipo de línea que se cuela en una revisión. La consulta antigua llevaba `AND p2.content_id != p.content_id`, o sea que para contar como "vuelta" tenía que ser a un título DISTINTO. Eso excluye justo el caso que sostiene la conclusión de la lección: seguir viendo la misma serie. En estos datos el `content_id` es el del título, no el del episodio, así que ver el episodio siguiente es el mismo identificador -- y la métrica que iba a demostrar que las series funcionan estaba descartando la prueba. Quitarlo es lo correcto, pero hay que decir lo que pasa al quitarlo: entonces la métrica favorece mecánicamente a lo serializado, porque en una serie el episodio siguiente es una vuelta casi garantizada. Las dos versiones tienen sesgo, en direcciones contrarias. La forma honesta de presentarlo es con las dos columnas separadas: vuelve a lo mismo y vuelve a otra cosa.

## Análisis por duración -- ¿series cortas o películas largas?

1-- Rendimiento por franja de duracion. Mismo patron que el genero: primero el
2-- titulo, despues la franja.
3--
4-- Y OJO CON LOS CORTES. El catalogo da la duracion en MINUTOS. Si se copian los
5-- cortes en segundos y solo se cambia el nombre de la columna, TODOS los titulos
6-- caen en el primer tramo -- una pelicula de 140 minutos es menor que 1.200 -- la
7-- tabla sale con una fila y el analisis desaparece sin dar ningun error.
8WITH hoy AS (SELECT MAX(started_at) AS fecha FROM playbacks),
9por_titulo AS (
10 SELECT
11 CASE
12 WHEN c.duration_minutes < 20 THEN '< 20 min (corto)'
13 WHEN c.duration_minutes < 45 THEN '20-45 min (episodio)'
14 WHEN c.duration_minutes < 90 THEN '45-90 min (medio)'
15 WHEN c.duration_minutes < 120 THEN '90-120 min (pelicula)'
16 ELSE '> 120 min (largo)'
17 END AS franja,
18 c.content_id,
19 MAX(t.coste_eur) AS coste_eur,
20 COALESCE(SUM(p.watch_seconds), 0) / 3600.0 AS horas,
21 COUNT(p.playback_id) AS reproducciones,
22 COUNT(DISTINCT p.user_id) AS espectadores,
23 -- Porcentaje visto medio de ESTE titulo. El x60 convierte la duracion a
24 -- segundos; sin el, el ratio sale 60 veces mas alto.
25 AVG(p.watch_seconds * 1.0
26 / NULLIF(c.duration_minutes * 60.0, 0)) * 100 AS pct_visto
27 FROM content c
28 JOIN coste_titulo t ON t.content_id = c.content_id
29 LEFT JOIN playbacks p
30 ON c.content_id = p.content_id
31 AND p.started_at >= (SELECT fecha FROM hoy) - INTERVAL '90 days'
32 AND p.is_meaningful
33 GROUP BY 1, 2
34)
35SELECT
36 franja,
37 COUNT(*) AS titulos,
38 SUM(reproducciones) AS reproducciones,
39 -- Empuja hacia LO CORTO: la duracion esta en el denominador.
40 ROUND(AVG(pct_visto), 1) AS pct_visto,
41 -- Empuja hacia LO LARGO: las horas vistas crecen con el metraje.
42 ROUND(SUM(horas) / NULLIF(SUM(coste_eur), 0) * 1000, 2) AS horas_por_1000eur,
43 -- NO depende del metraje. Es el arbitro cuando las dos de arriba se pelean.
44 ROUND(SUM(espectadores) / NULLIF(SUM(coste_eur), 0) * 1000, 2)
45 AS espectadores_por_1000eur,
46 -- Y esta es la que de verdad distingue un formato de otro.
47 ROUND(SUM(reproducciones) * 1.0 / NULLIF(SUM(espectadores), 0), 2)
48 AS sesiones_por_espectador
49FROM por_titulo
50GROUP BY franja
51HAVING SUM(reproducciones) >= 100
52ORDER BY espectadores_por_1000eur DESC;
53
54-- franja titulos repro pct_visto h/1000EUR esp/1000EUR ses/esp
55-- 20-45 min (episodio) 59 1167 90,6 4,20 4,75 1,58
56-- > 120 min (largo) 57 719 69,2 6,99 4,65 1,02
57-- 90-120 min (pelicula) 78 1052 77,3 5,83 4,39 1,01
58-- < 20 min (corto) 27 298 93,2 0,77 4,14 1,01
59-- 45-90 min (medio) 159 1907 83,2 4,31 3,89 1,22

Tres métricas y tres sesgos. La cuarta columna es la que contesta la pregunta.

Ninguna de estas tres columnas es la buena, y ése es el punto. El porcentaje visto premia lo corto porque la duración está en el denominador; las horas por euro premian lo largo porque está en el numerador. Enséñalas juntas, di en qué dirección tira cada una, y decide con la que no depende del metraje. Cuando alguien te dé UN solo número para comparar formatos, la pregunta es siempre la misma: hacia dónde tira.

Tres personas que dedican los mismos veinticinco minutos, y tres notas distintas. La diferencia no está en ellas: está en el denominador.

## La presentación a Sofía -- datos que toman decisiones

A las 17:00 te sientas con Sofía 15 minutos y le muestras los resultados. No le enseñas las queries SQL -- le enseñas 3 gráficos simples y una recomendación clara:

La clave de contar datos a alguien que no es técnico, que aprendiste en AdPulse: no enseñes el proceso, enseña el resultado. Sofía no necesita saber que has cruzado tres tablas ni que has tenido que agrupar por título antes de dividir. Necesita saber en qué gastar y qué dejar de pagar. Pero hay una regla que va antes que ésa, y es la que hace que te vuelvan a llamar: cada cifra que digas en voz alta tiene que salir de una consulta que puedas volver a ejecutar delante de ella, con su unidad al lado.

  1. 01.Formato. El formato episodio (20-45 min) es el único al que la gente vuelve: 1,58 sesiones por espectador frente a 1,01 en películas y en cortos. En eficiencia por euro no gana -- ahí van por delante las películas largas -- y su 90,6% de porcentaje visto no cuenta como argumento, porque esa métrica premia lo corto por construcción. Recomendación: el grueso del presupuesto al formato episodio, y el motivo es la vuelta, no la finalización.
  2. 02.Género. Con estos datos, ninguno destaca en retención: los ocho quedan por debajo de su propia línea de base, así que la diferencia que parecía haber era la actividad del usuario, no el género. En horas por cada mil euros sí hay orden -- thriller 5,64, drama 5,14, documentales 4,26, animación 1,78 -- y es un orden defendible, pero mide eficiencia, no enganche. Recomendación: usar el género para decidir qué NO renovar, no para decidir qué comprar.
  3. 03.Lo que libera presupuesto. 929 títulos de 1.200 tienen menos de diez reproducciones en la ventana. De los 293 que además tienen coste registrado, el ahorro está en la tabla, ordenado de más caro a más barato. Ésa es la lista con la que Sofía puede financiar parte del trimestre sin pedir dinero nuevo.
  4. 04.Lo que no sabemos. De 823 títulos no tenemos coste, así que no tienen nota de rendimiento: aparecen aparte y marcados, no al fondo del ranking. Y la escala de los importes no cuadra -- las licencias suman doce veces los ingresos mensuales -- así que hasta hablar con Javier no hay ninguna cifra absoluta en euros en esta presentación. Los órdenes sí, los importes no.

Sofía se queda con dos cosas y ninguna es la que esperaba. La primera, que el formato importa más que el género, y al revés de como ella lo estaba mirando. La segunda, la lista de los 929: "llevo año y medio renovando esto sin mirar. ¿Me estás diciendo que puedo pagar medio trimestre cancelando cosas que nadie ve?". Y luego la pregunta que vas a oír muchas veces en tu carrera: "¿esto es seguro?". La respuesta honesta es la que le das: el orden sí, los euros no hasta que Javier aclare si su columna es mensual o anual.

Fíjate en lo que NO le has dicho, porque es tan importante como lo que le has dicho. No le has dicho "true crime retiene tres veces más que naturaleza", que es lo que la intuición esperaba y lo que habría salido de la primera versión de estas consultas. No lo has dicho porque al poner la línea de base al lado resultó que no era verdad. Una recomendación de 200.000 euros sostenida en una métrica que medía otra cosa no es una recomendación: es una apuesta con el dinero de otro. Y el día que se caiga, se caerá delante de Marta.

Lo que acabas de hacer con Sofía es lo que separa a un ingeniero de datos senior de uno intermedio, y son dos cosas, no una. La primera: no le has entregado métricas, le has entregado una decisión. "El grueso al formato episodio y aquí están los 929 títulos que puedes cancelar" es infinitamente más útil que un panel con quince gráficos. El senior entrega decisiones, no datos. La segunda, y es la que más cuesta: le has dicho qué parte de tu respuesta no se sostiene todavía. Un intermedio entrega las cuatro conclusiones; un senior entrega tres y una pregunta para Javier.

## El modelo dimensional de contenido

Para que el análisis de contenido sea sostenible (no solo un one-off de está tarde), diseñas un modelo dimensional simple en la zona gold del lake que Metabase pueda consultar directamente:

1-- Tabla gold: rendimiento por titulo. Se recalcula cada noche, despues de que
2-- la capa silver este actualizada, y Sofia la consulta cuando quiera sin
3-- escribir un solo cruce.
4CREATE OR REPLACE TABLE gold_content_performance AS
5WITH hoy AS (SELECT MAX(started_at) AS fecha FROM playbacks)
6SELECT
7 c.content_id,
8 c.title,
9 c.content_type,
10 c.genre_primary,
11 c.duration_minutes,
12 c.release_year,
13 c.is_original,
14 t.coste_eur,
15 t.coste_eur IS NULL AS coste_sin_registrar,
16 COUNT(p.playback_id) AS reproducciones_90d,
17 COUNT(DISTINCT p.user_id) AS espectadores_90d,
18 ROUND(SUM(p.watch_seconds) / 3600.0, 2) AS horas_90d,
19 ROUND(AVG(p.watch_seconds) / 60.0, 1) AS minutos_por_sesion,
20 -- Denominador COUNT(p.playback_id) y no COUNT(*): un titulo sin
21 -- reproducciones tiene que salir a NULO, no a 0%.
22 ROUND(SUM(CASE WHEN p.completed THEN 1 ELSE 0 END) * 100.0
23 / NULLIF(COUNT(p.playback_id), 0), 1) AS pct_terminado,
24 ROUND(AVG(p.watch_seconds * 1.0
25 / NULLIF(c.duration_minutes * 60.0, 0)) * 100, 1) AS pct_visto,
26 -- Sin coste, el retorno se queda a nulo. Un cero seria una afirmacion.
27 CASE WHEN t.coste_eur > 0
28 THEN ROUND(SUM(p.watch_seconds) / 3600.0 / t.coste_eur * 1000, 2)
29 END AS horas_por_1000eur,
30 -- Cuando se calculo esta foto. Va dentro de la tabla, porque una tabla
31 -- materializada sin fecha de calculo es una tabla en la que nadie sabe si
32 -- puede confiar.
33 CURRENT_TIMESTAMP AS calculada_el
34FROM content c
35LEFT JOIN coste_titulo t ON t.content_id = c.content_id
36LEFT JOIN playbacks p
37 ON c.content_id = p.content_id
38 AND p.started_at >= (SELECT fecha FROM hoy) - INTERVAL '90 days'
39 AND p.is_meaningful
40GROUP BY 1, 2, 3, 4, 5, 6, 7, 8, 9;

La tabla que Sofía consulta sola. Incluye el catálogo entero, también lo que nadie ve.

## Resumen del Día 3 (tarde)

  • Sacaste el coste de licencia de donde estaba de verdad -- una hoja hecha a mano, no el catálogo -- y lo casaste por título: importes en formato europeo, sufijos, mayúsculas y ocho packs sin repartir
  • Cazaste el error que invertía el ranking de géneros: sumar el coste después del cruce lo multiplica por el número de reproducciones y la métrica se vuelve ciega a la popularidad
  • Comparaste formatos con tres métricas y el sesgo de cada una escrito al lado, en vez de con una sola que premiaba lo corto por construcción
  • Pusiste una línea de base a la retención por género, y con ella resultó que la diferencia que parecía haber era la actividad del usuario
  • Construiste la lista de lo que no se vuelve a comprar: 929 títulos, la mitad del encargo de Sofía, que antes no existía en ninguna parte
  • Dejaste sin nota lo que no se puede puntuar, en vez de ponerle un cero que lo habría enterrado por falta de un dato
  • Y dijiste en voz alta lo que no se sostiene: la escala de los importes no cuadra con los ingresos, así que el orden se presenta y los euros no

Regístrate para guardar tu progreso.