lección 6
Día 4 -- Dashboard para inversores y recomendaciones
Construyes el dashboard que Marta mostrará mañana a Meridian Capital. Implementas un sistema de recomendaciones con collaborative filtering simple.
⏱ 40 min
## Jueves, 8:30 AM -- Un día para el dashboard
El dashboard y el motor de recomendaciones se construyen sobre las métricas ya calculadas a partir de `datos/reproducciones_eventos.parquet` (`user_id, content_id, timestamp, properties`) y `datos/usuarios.csv`. Para el bloque de crecimiento del dashboard puedes apoyarte además en `datos/gasto_marketing_semanal.csv` (`week_start, channel, spend_eur, impressions, clicks, signups_attributed`) si quieres relacionar la adquisición de usuarios con la inversión de marketing. Guarda todo en `datos/`; recuerda que el esquema PostgreSQL del contexto es ilustrativo y que debes usar las columnas reales de los ficheros.
Mañana es el día. A las 11:00 Marta se sienta frente a Meridian Capital con tu dashboard proyectado en la pantalla de la sala de reuniones. Hoy tu trabajó es transformar los datos calculados ayer en una narrativa visual que un inversor pueda entender en 5 minutos sin que nadie le explique nada. Los buenos dashboards no necesitan manual de usuario.
En AdPulse aprendiste algo fundamental sobre presentar datos a personas no técnicas: "Menos es más. Un dashboard con 3 métricas bien contadas vale más que uno con 20 gráficos que nadie mira." Carmen, la directora de datos, siempre decía: "Si tienes que explicar el gráfico, el gráfico está mal diseñado."
## Diseño del dashboard -- la narrativa visual
El dashboard para inversores tiene 3 secciones que cuentan una historia secuencial. Un inversor lo lee de arriba a abajo y al terminar piensa "está startup tiene tracción real":
- 01.HEADER: KPIs hero -- los 4 números que SÍ se pueden calcular con 21 días de datos, en grande: usuarios activos, reproducciones por usuario activo, tasa de finalización y retención D7
- 02.CRECIMIENTO: Altas por semana desde abril (eso sí hay: las altas llevan seis meses) + la cohorte de cada semana. El MAU mensual y el crecimiento mes a mes NO van, y más abajo se explica por qué
- 03.ENGAGEMENT: Distribución de segmentos (power/regular/casual/at-risk) + completion rate por género. Demuestra que el contenido engancha.
- 04.RETENCIÓN: Tabla de cohortes coloreada (heatmap). Las últimas cohortes retienen mejor que las primeras -- señal de mejora de producto.
1-- TODO EL PANEL CUELGA DE ESTA VISTA, y la vista se ensena en pantalla.2--3-- Es lo primero que se escribe. Ningun numero del panel puede depender del reloj4-- del ordenador: el dia que el pipeline deje de correr, un panel anclado a5-- CURRENT_DATE no enseña un error -- enseña un CERO, y un cero en una sala de6-- reuniones se lee como "el producto se ha parado".7CREATE OR REPLACE VIEW v_frescura AS8SELECT MAX(event_date) AS datos_hasta,9 COUNT(*) AS sesiones,10 COUNT(DISTINCT user_id) AS usuarios,11 DATE_DIFF('day', MIN(event_date), MAX(event_date)) + 1 AS dias,12 CURRENT_TIMESTAMP AS calculado_el13FROM silver_playbacks14WHERE is_meaningful;1516-- La linea que va DEBAJO de los cuatro numeros grandes, siempre visible:17SELECT 'Datos hasta ' || CAST(datos_hasta AS VARCHAR)18 || ' · ' || CAST(sesiones AS VARCHAR) || ' sesiones'19 || ' · ' || CAST(usuarios AS VARCHAR) || ' usuarios'20 || ' · calculado el ' || CAST(calculado_el AS VARCHAR) AS frescura21FROM v_frescura;22-- Datos hasta 2024-10-13 · 16371 sesiones · 1500 usuarios · calculado el ...2324-- CUATRO NUMEROS, CUATRO CONSULTAS.25--26-- No van en un solo SELECT. Si van juntos, un denominador a cero se lleva los27-- otros tres por delante: en PostgreSQL con un error y el panel entero en28-- blanco, y en DuckDB sin error ninguno -- cero entre cero da 'nan' y cualquier29-- cosa entre cero da 'inf', que en una tarjeta se lee como un fallo de la30-- pantalla. Cuatro consultas separadas fallan de una en una.3132-- 1. Usuarios activos en la ventana con datos.33SELECT COUNT(DISTINCT user_id) AS usuarios_activos34FROM silver_playbacks35WHERE is_meaningful36 AND event_date > (SELECT datos_hasta FROM v_frescura) - INTERVAL '30 days';37-- 15003839-- 2. Usuarios del ultimo dia.40SELECT COUNT(DISTINCT user_id) AS dau41FROM silver_playbacks42WHERE is_meaningful43 AND event_date = (SELECT datos_hasta FROM v_frescura);44-- 4054546-- 3. Retencion D1, con su base al lado. Un porcentaje sin denominador en un47-- panel de inversores es una invitacion a que te pregunten "sobre cuantos".48WITH dia_cero AS (49 SELECT user_id, MIN(event_date) AS cohorte50 FROM silver_playbacks WHERE is_meaningful GROUP BY user_id51),52actividad AS (53 SELECT DISTINCT c.cohorte, p.user_id,54 DATE_DIFF('day', c.cohorte, p.event_date) AS dn55 FROM silver_playbacks p JOIN dia_cero c USING (user_id)56 WHERE p.is_meaningful57)58SELECT ROUND(COUNT(DISTINCT CASE WHEN dn = 1 THEN user_id END) * 100.059 / NULLIF(COUNT(DISTINCT user_id), 0), 1) AS retencion_d1_pct,60 COUNT(DISTINCT user_id) AS sobre_usuarios61FROM actividad62-- Solo cohortes que ya han cumplido su plazo (leccion de ayer).63WHERE cohorte <= (SELECT datos_hasta FROM v_frescura) - 1;64-- 73,0 % sobre 1432 usuarios6566-- 4. Retencion D7 (+-1 dia), igual.67WITH dia_cero AS (68 SELECT user_id, MIN(event_date) AS cohorte69 FROM silver_playbacks WHERE is_meaningful GROUP BY user_id70),71actividad AS (72 SELECT DISTINCT c.cohorte, p.user_id,73 DATE_DIFF('day', c.cohorte, p.event_date) AS dn74 FROM silver_playbacks p JOIN dia_cero c USING (user_id)75 WHERE p.is_meaningful76)77SELECT ROUND(COUNT(DISTINCT CASE WHEN dn BETWEEN 6 AND 8 THEN user_id END) * 100.078 / NULLIF(COUNT(DISTINCT user_id), 0), 1) AS retencion_d7_pct,79 COUNT(DISTINCT user_id) AS sobre_usuarios80FROM actividad81WHERE cohorte <= (SELECT datos_hasta FROM v_frescura) - 8;82-- 29,4 % sobre 913 usuarios8384-- LO QUE NO VA EN EL PANEL, y por que.85--86-- El stickiness (DAU/MAU) y el crecimiento mes a mes NO entran. No es que salgan87-- feos: es que con 21 dias de historico la ventana de 30 dias del MAU no se88-- llena nunca, asi que el MAU sale bajo por construccion y el stickiness sale89-- inflado. Y no hay dos meses naturales completos que comparar.90--91-- En su sitio va una tarjeta que dice "Disponible a partir del 12 de noviembre",92-- con la fecha calculada. Un hueco explicado da confianza; un numero inventado93-- la quita entera en cuanto alguien pregunta de donde sale.
La vista de frescura primero, y después cuatro números en cuatro consultas separadas.
## Un panel tiene que decir de cuándo son sus datos
Merece la pena pararse en esto, porque es la diferencia entre un panel que aguanta seis meses y uno que engaña a alguien el primer martes que el pipeline falle en silencio. Un panel no es una consulta: es una consulta que alguien va a mirar sin preguntar nada. Y lo que más daño hace no es que se rompa, es que se rompa sin parecerlo.
- Si el pipeline deja de correr, un panel anclado al reloj enseña ceros, y un cero se lee como "el producto se ha parado", no como "faltan datos". Anclado al último día con datos, los números se quedan quietos y la fecha lo delata.
- Cada tarjeta lleva la fecha de los datos y la hora del cálculo. Son dos cosas distintas: los datos pueden ser de hace tres semanas y el cálculo de hace diez minutos, y esa diferencia es exactamente lo que hay que ver.
- Cada porcentaje lleva su denominador. Sin él, nadie puede distinguir un 100% sobre dos usuarios de un 100% sobre dos mil.
- Y lo que no se puede calcular todavía ocupa su hueco con una fecha, no se queda fuera en silencio. Si desaparece del panel, la primera pregunta será por qué falta; si está con su fecha, no hay pregunta.
## Sistema de recomendaciones -- collaborative filtering
Mientras Marta prepara la presentación, decides ir un paso más allá. Los inversores siempre preguntan lo mismo: "¿cómo vais a escalar el engagement?". Y la respuesta es personalización. No hace falta un equipo de aprendizaje automático para una primera versión: el filtrado colaborativo -- "los que vieron esto también vieron aquello" -- se escribe en una tarde.
La intuición es elegante: si dos personas han visto cosas parecidas, lo que una vio y la otra no es una buena recomendación. No hace falta entender POR QUÉ les gusta algo, sólo encontrar coincidencias. Pero antes de escribir una línea hay una pregunta que decide si el algoritmo va a funcionar, y casi nadie la hace: ¿cuántos datos tengo por unidad?
## Antes de elegir el algoritmo, cuenta
1-- La consulta de tres lineas que decide el diseno del recomendador.2SELECT ROUND(AVG(titulos), 2) AS titulos_por_usuario,3 MIN(titulos) AS minimo,4 MAX(titulos) AS maximo,5 COUNT(*) FILTER (WHERE titulos >= 3) AS con_3_o_mas,6 ROUND(100.0 * COUNT(*) FILTER (WHERE titulos >= 3) / COUNT(*), 1) AS pct7FROM (SELECT user_id, COUNT(DISTINCT content_id) AS titulos8 FROM silver_playbacks9 WHERE is_meaningful AND user_id IS NOT NULL10 GROUP BY user_id);11-- titulos_por_usuario 9,34 | minimo 2 | maximo 23 | con_3_o_mas 1493 (99,5 %)1213-- Bien: casi todo el mundo tiene tres titulos o mas, asi que el minimo de tres14-- no deja fuera a nadie por falta de historial. Pero eso es la COTA, no la15-- cobertura. Para que el parecido entre personas sirva, dos personas tienen que16-- haber visto LO MISMO, y eso es otra cosa:17WITH vistos AS (18 SELECT DISTINCT user_id, content_id FROM silver_playbacks19 WHERE is_meaningful AND user_id IS NOT NULL20),21pares AS (22 SELECT a.user_id AS u1, b.user_id AS u2, COUNT(*) AS comunes23 FROM vistos a JOIN vistos b24 ON a.content_id = b.content_id AND a.user_id <> b.user_id25 GROUP BY 1, 226)27SELECT COUNT(DISTINCT u1) AS con_algun_vecino,28 ROUND(100.0 * COUNT(DISTINCT u1)29 / (SELECT COUNT(DISTINCT user_id) FROM vistos), 1) AS pct,30 MAX(comunes) AS max_titulos_comunes31FROM pares WHERE comunes >= 3;32-- con_algun_vecino 1077 (71,8 %) | max_titulos_comunes 6
Dos consultas antes de escribir el algoritmo. La segunda es la que importa.
1# Sistema de recomendaciones v1: Collaborative Filtering básico2# "Usuarios que completaron X también completaron Y"34import pandas as pd5import numpy as np6from collections import defaultdict789def build_user_content_matrix(df_playbacks: pd.DataFrame) -> pd.DataFrame:10 """Construye la matriz usuario-contenido con scores de interacción."""11 # Solo considerar contenido que el usuario VIO significativamente12 significant = df_playbacks[df_playbacks['watch_seconds'] > 120].copy()1314 # Score de interacción: completion_ratio (0-1)15 # Si completó = 1.0, si vio la mitad = 0.5, etc.16 interaction = significant.groupby(['user_id', 'content_id']).agg(17 max_completion=('completion_ratio', 'max'),18 times_watched=('playback_id', 'count'),19 ).reset_index()2021 # Score final: max_completion * (1 + 0.1 * rewatches)22 interaction['score'] = interaction['max_completion'] * (23 1 + 0.1 * (interaction['times_watched'] - 1).clip(upper=5)24 )25 interaction['score'] = interaction['score'].clip(upper=1.5)2627 return interaction[['user_id', 'content_id', 'score']]282930def find_similar_users(31 user_id: int,32 interaction_matrix: pd.DataFrame,33 top_k: int = 2034) -> list[int]:35 """Encuentra los K usuarios más similares usando coseno."""36 # Contenido visto por el usuario target37 target_items = set(38 interaction_matrix[interaction_matrix['user_id'] == user_id]['content_id']39 )4041 if not target_items:42 return []4344 # Calcular similaridad con todos los demás usuarios45 # (overlap de contenido visto / tamaño del universo compartido)46 similarities = []47 other_users = interaction_matrix[48 interaction_matrix['user_id'] != user_id49 ]['user_id'].unique()5051 for other_id in other_users:52 other_items = set(53 interaction_matrix[interaction_matrix['user_id'] == other_id]['content_id']54 )55 overlap = len(target_items & other_items)56 if overlap >= 3: # Mínimo 3 títulos en común57 # Jaccard similarity58 union = len(target_items | other_items)59 similarity = overlap / union60 similarities.append((other_id, similarity))6162 # Top K más similares63 similarities.sort(key=lambda x: x[1], reverse=True)64 return [uid for uid, _ in similarities[:top_k]]656667def recommend_for_user(68 user_id: int,69 interaction_matrix: pd.DataFrame,70 content_df: pd.DataFrame,71 n_recommendations: int = 1072) -> pd.DataFrame:73 """Genera N recomendaciones para un usuario."""74 # Contenido ya visto75 seen = set(76 interaction_matrix[interaction_matrix['user_id'] == user_id]['content_id']77 )7879 # Encontrar usuarios similares80 similar_users = find_similar_users(user_id, interaction_matrix)8182 if not similar_users:83 # PLAN B: lo mas popular que no haya visto.84 #85 # Y ordenado por NUMERO DE ESPECTADORES, no por nota media. La version86 # ingenua calcula los espectadores y despues ordena por avg_score, que es87 # la media de la fraccion vista: los titulos que ha visto UNA sola88 # persona, y entera, salen con un 1,0 perfecto y encabezan la lista. O89 # sea que el plan B de "lo mas popular" recomendaba lo mas ignorado.90 popular = interaction_matrix.groupby('content_id').agg(91 espectadores=('user_id', 'nunique'),92 nota_media=('score', 'mean'),93 ).reset_index()94 popular = popular[~popular['content_id'].isin(seen)]95 # Un minimo de espectadores antes de ordenar por nota, para que la nota96 # signifique algo.97 popular = popular[popular['espectadores'] >= 10]98 popular = popular.sort_values(99 ['espectadores', 'nota_media'], ascending=False100 )101 resultado = popular.head(n_recommendations).merge(content_df, on='content_id')102 # Y va ETIQUETADO. Una recomendacion popular no es una recomendacion103 # personalizada, y la pantalla tiene que poder decirlo: "lo mas visto en104 # StreamVibe" en vez de "para ti".105 resultado['origen'] = 'popular'106 return resultado107108 # Contenido visto por usuarios similares pero NO por el target109 similar_content = interaction_matrix[110 interaction_matrix['user_id'].isin(similar_users)111 & ~interaction_matrix['content_id'].isin(seen)112 ]113114 # Rankear por score promedio ponderado115 recommendations = similar_content.groupby('content_id').agg(116 rec_score=('score', 'mean'),117 similar_viewers=('user_id', 'nunique'),118 ).reset_index()119120 # Ponderar por número de usuarios similares que lo vieron121 recommendations['final_score'] = (122 recommendations['rec_score'] * np.log1p(recommendations['similar_viewers'])123 )124125 recommendations = recommendations.sort_values('final_score', ascending=False)126 top_recs = recommendations.head(n_recommendations)127128 resultado = top_recs.merge(129 content_df[['content_id', 'title', 'genre_primary', 'content_type']],130 on='content_id',131 )132 resultado['origen'] = 'personalizada'133 return resultado
Filtrado colaborativo entre personas. Cubre el 71,8% de los usuarios, y el resto cae al plan B.
## El mismo problema, mirado desde el otro lado
El arreglo no es bajar el mínimo de tres títulos en común a dos. Eso sube la cobertura al 99% y empeora las recomendaciones, porque dos coincidencias sobre 1.200 títulos no dicen nada de nadie: el recomendador pasa a recomendar ruido con la misma cara de seguridad. Y una recomendación mala es peor que ninguna, porque le enseña al usuario a no mirar esa fila.
El cambio que sí funciona es de qué se compara con qué. El parecido entre PERSONAS necesita que dos personas coincidan, y eso es lo escaso. El parecido entre TÍTULOS sólo necesita que alguien haya visto dos títulos cualesquiera -- y de eso hay 44.777 pares distintos en estos datos. Se calcula una vez, se guarda, y responde a cualquiera que haya visto UN solo título.
1-- "Quien vio A tambien vio B", calculado una vez para todo el catalogo.2CREATE OR REPLACE TABLE gold_titulos_parecidos AS3WITH vistos AS (4 SELECT DISTINCT user_id, content_id5 FROM silver_playbacks6 WHERE is_meaningful AND user_id IS NOT NULL7),8espectadores AS (9 SELECT content_id, COUNT(*) AS n FROM vistos GROUP BY 110),11total AS (SELECT COUNT(DISTINCT user_id) AS n FROM vistos),12pares AS (13 SELECT a.content_id AS origen, b.content_id AS parecido, COUNT(*) AS covistos14 FROM vistos a JOIN vistos b15 ON a.user_id = b.user_id AND a.content_id <> b.content_id16 GROUP BY 1, 217)18SELECT p.origen, p.parecido, p.covistos,19 -- LIFT: las coincidencias observadas divididas por las que saldrian POR20 -- AZAR. Sin esto, el titulo mas visto de la plataforma aparece emparejado21 -- con todo, porque coincide con todo el mundo por pura frecuencia. El lift22 -- pregunta otra cosa: ¿coinciden MAS de lo que les tocaria?23 ROUND(p.covistos * (SELECT n FROM total)24 / (eo.n * 1.0 * ep.n), 2) AS lift25FROM pares p26JOIN espectadores eo ON eo.content_id = p.origen27JOIN espectadores ep ON ep.content_id = p.parecido28-- Un minimo de coincidencias: con una sola, el lift es ruido.29WHERE p.covistos >= 230ORDER BY p.origen, lift DESC;3132-- Y recomendar es ahora una consulta, no un bucle:33WITH mios AS (34 SELECT DISTINCT content_id FROM silver_playbacks35 WHERE user_id = 'usr_00042' AND is_meaningful36)37SELECT t.parecido AS content_id, c.title, c.genre_primary,38 SUM(t.lift) AS afinidad, COUNT(*) AS veces_sugerido39FROM gold_titulos_parecidos t40JOIN mios m ON m.content_id = t.origen41JOIN content c ON c.content_id = t.parecido42WHERE t.parecido NOT IN (SELECT content_id FROM mios)43GROUP BY 1, 2, 344ORDER BY afinidad DESC45LIMIT 10;
Parecido entre títulos: 26.494 pares con al menos dos coincidencias, y cobertura del 100%.
Lo que hay que llevarse de esta parte no es el algoritmo, es el orden de las decisiones. Primero se cuentan los datos por la unidad sobre la que el algoritmo razona -- aquí el par de usuarios, no el usuario ni el título. Después se elige. Cuando la unidad natural es escasa, casi siempre hay otra que no lo es, y cambiar de unidad suele ser más barato y más eficaz que afinar parámetros. Y si te sigues quedando corto, el plan B se etiqueta: "lo más visto" y "para ti" son promesas distintas, y sólo una de las dos la puedes cumplir.
## Un modelo de riesgo que no se ha comprobado no es un modelo
El tercer entregable del día es el que da más miedo, y no por difícil: porque es el único de los tres que falla de una forma que nadie puede detectar mirándolo. Un panel roto enseña ceros. Un recomendador roto devuelve una lista vacía, que parece "este usuario es nuevo". Un modelo de riesgo roto devuelve un número redondo, creíble y alarmante -- "el 43% de tu base está en riesgo" -- y no hay nada en la cifra que delate si está bien.
Lo primero, otra vez, es la fecha. Un modelo que empieza con "si lleva más de veintiún días sin entrar, cuarenta puntos" y se ancla al reloj del ordenador marca en riesgo al 100% de la base en cuanto los datos envejecen un mes. Anclado al último día con datos, sobre este extracto, ningún usuario pasa de veintiún días de inactividad -- así que ese umbral no discrimina y hay que bajarlo a la escala de los datos que hay.
1# Riesgo de baja, con los umbrales a la escala de los datos que hay.2#3# El umbral de inactividad NO se elige por intuicion. Con 21 dias de historico,4# "mas de 21 dias sin entrar" no lo cumple nadie y la senal no vale para nada.5# Se mira la distribucion primero: aqui hay 602 usuarios con mas de 7 dias sin6# aparecer y 189 con mas de 14, asi que los tramos van ahi.78UMBRALES_INACTIVIDAD = (7, 3) # dias: mucho / algo9UMBRALES_DIAS_ACTIVOS = (2, 5) # dias distintos con actividad101112def riesgo_de_baja(usuario: dict, ultimo_dia) -> dict:13 """Puntua el riesgo de 0 a 90. Devuelve tambien que no se ha podido medir."""14 puntos = 015 senales = []16 sin_medir = []1718 # 1. Inactividad. La referencia es el ultimo dia CON DATOS, no hoy.19 ultima = usuario.get('ultima_actividad')20 if ultima is None or pd.isna(ultima):21 # Un dato que falta NO suma puntos. La version ingenua ponia22 # days_ago = user_metrics.get('last_play_days_ago', 0), o sea que a quien23 # le faltaba el dato le asignaba CERO dias de inactividad -- el mejor24 # valor posible. Asi, un hueco protegia al usuario de la alarma.25 sin_medir.append('inactividad')26 else:27 dias = (ultimo_dia - ultima).days28 if dias > UMBRALES_INACTIVIDAD[0]:29 puntos += 4030 senales.append(('inactividad_alta', f'{dias} dias sin entrar'))31 elif dias > UMBRALES_INACTIVIDAD[1]:32 puntos += 2033 senales.append(('inactividad_media', f'{dias} dias sin entrar'))3435 # 2. Frecuencia.36 dias_activos = usuario.get('dias_activos')37 if dias_activos is None or pd.isna(dias_activos):38 sin_medir.append('frecuencia')39 elif dias_activos <= UMBRALES_DIAS_ACTIVOS[0]:40 puntos += 3041 senales.append(('frecuencia_baja', f'solo {dias_activos} dias activos'))42 elif dias_activos <= UMBRALES_DIAS_ACTIVOS[1]:43 puntos += 1544 senales.append(('frecuencia_media', f'{dias_activos} dias activos'))4546 # 3. Profundidad.47 pct_visto = usuario.get('pct_visto')48 if pct_visto is None or pd.isna(pct_visto):49 sin_medir.append('profundidad')50 elif pct_visto < 0.60:51 puntos += 2052 senales.append(('abandona_pronto', f've el {pct_visto:.0%} de lo que empieza'))53 elif pct_visto < 0.80:54 puntos += 1055 senales.append(('profundidad_media', f've el {pct_visto:.0%}'))5657 # Si no se ha podido medir NADA, el usuario no tiene riesgo: tiene un hueco.58 # Devolver 0 seria decir "esta bien", y no lo sabemos.59 nivel = None if len(sin_medir) == 3 else (60 'alto' if puntos >= 50 else 'medio' if puntos >= 25 else 'bajo'61 )6263 return {64 'puntos': puntos,65 'nivel': nivel,66 # El TIPO de senal va separado del texto. Si se guarda la frase entera67 # ("14 dias sin entrar"), contar las senales mas comunes devuelve mil68 # senales distintas con una aparicion cada una, porque el numero cambia69 # en cada usuario. Con el tipo aparte, agrupa.70 'senales': [t for t, _ in senales],71 'detalle': [d for _, d in senales],72 'sin_medir': sin_medir,73 }
Los umbrales salen de la distribución, y un dato que falta no suma puntos.
Con esto el modelo ya discrimina: 645 usuarios en riesgo alto (43,0%), 443 en medio (29,5%) y 412 en bajo (27,5%). Los de riesgo alto tienen 2,7 días activos de media contra 6,1 los de riesgo bajo, así que la señal apunta a donde debe. Y aquí es donde casi todo el mundo se para y lo pone en el panel.
No lo pongas todavía. Ese 43% es un número que nadie puede comprobar mirándolo, y mañana va delante de un inversor. Un modelo de riesgo sin validación retrospectiva no es un modelo: es una opinión con puntos. Y validarlo cuesta una consulta, porque no hace falta esperar a que pase el mes que viene: el pasado ya ocurrió y está en los datos.
1-- VALIDACION RETROSPECTIVA. Se corta el historico en una fecha, se puntua con2-- lo que se sabia ENTONCES, y se mira lo que paso DESPUES.3WITH hoy AS (SELECT MAX(event_date) AS f FROM silver_playbacks),4corte AS (SELECT (SELECT f FROM hoy) - 7 AS c),5-- 1. Lo que se sabia en el corte. Ni un dato posterior entra aqui: si se cuela,6-- el modelo "predice" con informacion del futuro y acierta siempre.7antes AS (8 SELECT user_id,9 MAX(event_date) AS ultima,10 COUNT(DISTINCT event_date) AS dias_activos,11 AVG(completion_ratio) AS pct_visto12 FROM silver_playbacks13 WHERE is_meaningful AND user_id IS NOT NULL14 AND event_date <= (SELECT c FROM corte)15 GROUP BY user_id16),17puntuada AS (18 SELECT a.*,19 (CASE WHEN DATE_DIFF('day', a.ultima, (SELECT c FROM corte)) > 7 THEN 4020 WHEN DATE_DIFF('day', a.ultima, (SELECT c FROM corte)) > 3 THEN 2021 ELSE 0 END22 + CASE WHEN a.dias_activos <= 2 THEN 3023 WHEN a.dias_activos <= 5 THEN 15 ELSE 0 END24 + CASE WHEN a.pct_visto < 0.60 THEN 2025 WHEN a.pct_visto < 0.80 THEN 10 ELSE 0 END) AS riesgo26 FROM antes a27),28-- 2. Lo que paso despues.29despues AS (30 SELECT DISTINCT user_id FROM silver_playbacks31 WHERE is_meaningful AND event_date > (SELECT c FROM corte)32 -- OJO: sin este IS NOT NULL, el NOT IN de abajo NO FUNCIONA. Un NOT IN33 -- contra un conjunto que contiene un nulo nunca es verdadero, asi que34 -- "se_fue" sale falso para TODO EL MUNDO y la validacion dice que no se35 -- fue nadie. No da error: da un cero.36 AND user_id IS NOT NULL37)38SELECT COUNT(*) AS evaluados,39 COUNT(*) FILTER (WHERE riesgo >= 50) AS marcados,40 COUNT(*) FILTER (WHERE se_fue) AS se_fueron,41 -- De los que marcamos, cuantos se fueron de verdad.42 ROUND(100.0 * COUNT(*) FILTER (WHERE riesgo >= 50 AND se_fue)43 / NULLIF(COUNT(*) FILTER (WHERE riesgo >= 50), 0), 1) AS acierto_pct,44 -- De los que se fueron, a cuantos habiamos marcado.45 ROUND(100.0 * COUNT(*) FILTER (WHERE riesgo >= 50 AND se_fue)46 / NULLIF(COUNT(*) FILTER (WHERE se_fue), 0), 1) AS cobertura_pct,47 -- Y LO QUE HAY QUE MIRAR ANTES QUE NADA: cuantos se fueron en total.48 ROUND(100.0 * COUNT(*) FILTER (WHERE se_fue) / COUNT(*), 1) AS tasa_base_pct49FROM (SELECT p.*, p.user_id NOT IN (SELECT user_id FROM despues) AS se_fue50 FROM puntuada p);5152-- evaluados 988 | marcados 287 | se_fueron 67053-- acierto 95,8 % | cobertura 41,0 % | tasa base 67,8 %
La validación que convierte una opinión con puntos en un modelo. Mira las tres cifras juntas.
Una última honestidad sobre lo que se ha medido. "Se fue" aquí significa "no volvió a aparecer en los últimos siete días del extracto", no "canceló la suscripción". Son cosas distintas y la primera es mucho más frecuente: la tasa base del 67,8% lo dice a gritos, y viene de que el extracto son tres semanas y mucha gente entra, prueba y no vuelve en ese plazo. El fichero de suscripciones tiene bajas de verdad -- 6.572 canceladas, y los motivos que la gente marcó al irse: 2.262 por falta de contenido, 1.627 por precio, 1.350 por no usarlo -- y validar contra eso es mejor. Lo que no vale es no decir contra qué se ha validado.
## Resumen del Día 4
- Montaste el panel con la fecha de los datos a la vista, y con los cuatro números en cuatro consultas separadas para que un denominador a cero no se lleve el panel entero
- Dejaste fuera el stickiness y la tendencia mensual, con una tarjeta que dice cuándo se podrán dar: con 21 días de histórico la ventana de 30 no se llena nunca
- Contaste los datos por unidad ANTES de elegir el algoritmo de recomendación, y resultó que la unidad escasa no era el usuario ni el título, era el par de usuarios
- Cambiaste de unidad en vez de bajar el umbral: comparando títulos la cobertura pasa del 71,8% al 100%, y el cálculo baja de segundos por usuario a una consulta con índice
- Arreglaste el plan B, que ordenaba por nota media y recomendaba los títulos vistos por una sola persona; y lo etiquetaste, porque "lo más visto" y "para ti" son promesas distintas
- Pusiste los umbrales del modelo de riesgo a la escala de los datos, y dejaste de regalar el mejor valor posible a quien le faltaba un dato
- Y validaste el modelo contra el pasado: 95,8% de acierto suena magnífico hasta que ves que la tasa base es del 67,8% y que se pierde a seis de cada diez que se van
- Marta revisó el panel a las 19:00 y preguntó por la línea de la fecha. Es la primera vez que alguien le enseña un panel que dice de cuándo son sus datos.
## comentarios
Reporta erratas, ayuda a otros o comparte tu opinión. Sé constructivo.
Inicia sesión para comentar y responder.
cargando comentarios...
Regístrate para guardar tu progreso.