| """Construcción de variables para entrenamiento e inferencia.""" |
| from __future__ import annotations |
|
|
| import math |
| import re |
| from datetime import datetime, timezone |
| from typing import Any, Dict, Iterable |
|
|
| import numpy as np |
| import pandas as pd |
|
|
| from .config import BENEFIT_TERMS, CTA_TERMS, PRICE_TERMS, PROMO_TERMS, TRUST_TERMS, URGENCY_TERMS |
|
|
|
|
| def safe_float(value: Any, default: float = 0.0) -> float: |
| """Convierte a float aceptando None, strings y NaN sin fallar.""" |
| try: |
| if value is None or value == "": |
| return default |
| f = float(value) |
| if math.isnan(f) or math.isinf(f): |
| return default |
| return f |
| except Exception: |
| return default |
|
|
|
|
| def safe_int(value: Any, default: int = 0) -> int: |
| return int(round(safe_float(value, default=default))) |
|
|
|
|
| def contains_any(text: str, terms: Iterable[str]) -> int: |
| lower = (text or "").lower() |
| return int(any(term in lower for term in terms)) |
|
|
|
|
| def uppercase_ratio(text: str) -> float: |
| letters = [c for c in text or "" if c.isalpha()] |
| if not letters: |
| return 0.0 |
| return round(sum(1 for c in letters if c.isupper()) / len(letters), 4) |
|
|
|
|
| def count_words(text: str) -> int: |
| return len(re.findall(r"\w+", text or "", flags=re.UNICODE)) |
|
|
|
|
|
|
|
|
| STOPWORDS_RELEVANCE = { |
| "de", "la", "el", "los", "las", "un", "una", "unos", "unas", "y", "o", |
| "en", "a", "por", "para", "con", "sin", "del", "al", "que", "como", |
| "más", "mas", "muy", "este", "esta", "estos", "estas", "eso", "esa", |
| "es", "son", "ser", "fue", "hay", "se", "su", "sus", "tu", "tus", |
| "mi", "mis", "lo", "le", "les", "video", "youtube", "canal", |
| "comentario", "comentarios", "contenido", "link", "http", "https", |
| "www", "com", "reel", "short", "shorts", "post", "publicacion", "publicación" |
| } |
|
|
| HUMOR_TERMS = { |
| "jaja", "jajaja", "jeje", "risa", "humor", "broma", "chiste", "meme", |
| "parodia", "sketch", "comedia", "gracioso", "graciosa", "sarcasmo", |
| "irónico", "ironico", "pov", "cuando", "fail", "fails", "absurdo", "random" |
| } |
| EDU_TERMS = { |
| "tutorial", "aprende", "aprender", "guía", "guia", "paso a paso", "cómo", |
| "como hacer", "explico", "explicar", "consejos", "tips", "clase", "curso" |
| } |
| COMMERCIAL_TERMS = { |
| "compra", "comprar", "precio", "descuento", "oferta", "promoción", "promocion", |
| "cupón", "cupon", "envío", "envio", "agenda", "reserva", "servicio", |
| "producto", "tienda", "cliente", "clientes", "venta", "ventas", "gratis", |
| "whatsapp", "cotiza", "cotizar", "link en bio" |
| } |
| MUSIC_TERMS = { |
| "música", "musica", "canción", "cancion", "letra", "lyrics", "cover", |
| "artista", "cantante", "banda", "álbum", "album", "single", "concierto", |
| "verso", "coro", "beat", "remix", "videoclip" |
| } |
|
|
|
|
| def relevance_tokens(text: str) -> set[str]: |
| """Tokens útiles para medir si el texto OCR conecta con el mensaje.""" |
| clean = (text or "").lower() |
| clean = re.sub(r"https?://\S+", " ", clean) |
| clean = re.sub(r"www\.\S+", " ", clean) |
| clean = re.sub(r"[^a-záéíóúüñ0-9\s]", " ", clean, flags=re.UNICODE) |
| clean = re.sub(r"\s+", " ", clean).strip() |
| return { |
| token for token in clean.split() |
| if len(token) >= 3 and token not in STOPWORDS_RELEVANCE |
| } |
|
|
|
|
| def _count_custom_terms(text: str, terms: Iterable[str]) -> int: |
| low = (text or "").lower() |
| return sum(1 for term in terms if str(term).lower() in low) |
|
|
|
|
| def infer_content_intent_light(title: str = "", description: str = "", transcript_text: str = "", ocr_text: str = "") -> str: |
| """Detecta intención básica para no aplicar lógica comercial a todo. |
| |
| Retorna una etiqueta simple usada por OCR/guion: humor, comercial, |
| educativo, informativo o general. |
| """ |
| text = " ".join([title or "", description or "", transcript_text or "", ocr_text or ""]).lower() |
| humor = _count_custom_terms(text, HUMOR_TERMS) |
| edu = _count_custom_terms(text, EDU_TERMS) |
| music = _count_custom_terms(text, MUSIC_TERMS) |
| commercial = _count_custom_terms(text, COMMERCIAL_TERMS) + contains_any(text, CTA_TERMS) + contains_any(text, PROMO_TERMS) + contains_any(text, PRICE_TERMS) |
| if music >= 1 and commercial < 3: |
| return "musical" |
| if humor >= 1 and commercial < 3: |
| return "humor/entretenimiento" |
| if commercial >= 3: |
| return "comercial/promocional" |
| if edu >= 2: |
| return "educativo/tutorial" |
| if any(x in text for x in ["noticia", "actualidad", "denuncia", "entrevista", "comunicado"]): |
| return "informativo/noticioso" |
| return "general/branding" |
|
|
|
|
| def _ocr_role_for_intent(ocr_text: str, intent: str, overlap: float, marketing_signal: float, humor_signal: float) -> str: |
| if not (ocr_text or "").strip(): |
| return "sin_texto_detectado" |
| if intent.startswith("musical"): |
| return "letra_musical_o_rotulo" if overlap >= 0.15 else "texto_musical_poco_conectado" |
| if intent.startswith("humor"): |
| if humor_signal > 0 or overlap >= 0.25: |
| return "apoya_contexto_o_remate" |
| return "texto_desconectado_del_chiste" |
| if intent.startswith("comercial"): |
| if marketing_signal >= 0.34: |
| return "refuerza_oferta_cta_o_beneficio" |
| if overlap >= 0.25: |
| return "acompaña_mensaje_comercial" |
| return "texto_comercial_poco_claro" |
| if intent.startswith("educativo"): |
| return "apoyo_explicativo" if overlap >= 0.20 else "texto_explicativo_poco_conectado" |
| return "contexto_visual" if overlap >= 0.20 else "texto_visible_sin_funcion_clara" |
|
|
|
|
| def compute_ocr_relevance_score( |
| ocr_text: str, |
| title: str = "", |
| description: str = "", |
| transcript_text: str = "", |
| ocr_metrics: Dict[str, Any] | None = None, |
| ) -> float: |
| """Score 0-1 de relevancia del texto en pantalla. |
| |
| La versión anterior trataba casi todo como pieza comercial. Esta versión es |
| sensible al tipo de contenido: humor/entretenimiento, educativo, comercial, |
| informativo o general. Por eso no exige CTA ni producto cuando el video no |
| está vendiendo nada. |
| """ |
| ocr_metrics = ocr_metrics or {} |
| ocr_text = ocr_text or "" |
| if not ocr_text.strip(): |
| return 0.0 |
|
|
| intent = str(ocr_metrics.get("ocr_llm_content_intent") or infer_content_intent_light(title, description, transcript_text, ocr_text)) |
| ocr_tokens = relevance_tokens(ocr_text) |
| context_tokens = relevance_tokens(" ".join([title or "", description or "", transcript_text or ""])) |
| overlap = (len(ocr_tokens & context_tokens) / max(len(ocr_tokens), 1)) if ocr_tokens and context_tokens else 0.0 |
|
|
| commercial_flags = [ |
| contains_any(ocr_text, CTA_TERMS), contains_any(ocr_text, BENEFIT_TERMS), |
| contains_any(ocr_text, URGENCY_TERMS), contains_any(ocr_text, TRUST_TERMS), |
| contains_any(ocr_text, PROMO_TERMS), contains_any(ocr_text, PRICE_TERMS), |
| _count_custom_terms(ocr_text, COMMERCIAL_TERMS) > 0, |
| ] |
| marketing_signal = sum(bool(x) for x in commercial_flags) / max(len(commercial_flags), 1) |
| humor_signal = min(_count_custom_terms(ocr_text, HUMOR_TERMS) / 2, 1.0) |
| edu_signal = min(_count_custom_terms(ocr_text, EDU_TERMS) / 2, 1.0) |
|
|
| coverage = min(safe_float(ocr_metrics.get("ocr_frame_coverage", 0)), 1.0) |
| wc = count_words(ocr_text) |
| if 2 <= wc <= 18: |
| density_fit = 1.0 |
| elif 19 <= wc <= 40: |
| density_fit = 0.65 |
| elif wc > 40: |
| density_fit = 0.35 |
| else: |
| density_fit = 0.45 |
|
|
| if intent.startswith("musical"): |
| |
| |
| score = 0.42 * overlap + 0.25 * coverage + 0.23 * density_fit + 0.10 * max(humor_signal, edu_signal) |
| elif intent.startswith("humor"): |
| |
| |
| score = 0.45 * overlap + 0.20 * humor_signal + 0.20 * coverage + 0.15 * density_fit |
| elif intent.startswith("educativo"): |
| score = 0.45 * overlap + 0.20 * edu_signal + 0.20 * coverage + 0.15 * density_fit |
| elif intent.startswith("comercial"): |
| score = 0.38 * overlap + 0.34 * marketing_signal + 0.18 * coverage + 0.10 * density_fit |
| else: |
| score = 0.50 * overlap + 0.20 * coverage + 0.20 * density_fit + 0.10 * max(marketing_signal, humor_signal, edu_signal) |
|
|
| return round(max(0.0, min(score, 1.0)), 4) |
|
|
|
|
| def describe_ocr_relevance( |
| ocr_text: str, |
| title: str = "", |
| description: str = "", |
| transcript_text: str = "", |
| ocr_metrics: Dict[str, Any] | None = None, |
| ) -> Dict[str, Any]: |
| """Devuelve explicación del OCR sensible al objetivo del video.""" |
| ocr_metrics = ocr_metrics or {} |
| intent = str(ocr_metrics.get("ocr_llm_content_intent") or infer_content_intent_light(title, description, transcript_text, ocr_text)) |
| ocr_tokens = relevance_tokens(ocr_text) |
| context_tokens = relevance_tokens(" ".join([title or "", description or "", transcript_text or ""])) |
| overlap = (len(ocr_tokens & context_tokens) / max(len(ocr_tokens), 1)) if ocr_tokens and context_tokens else 0.0 |
| commercial_flags = [ |
| contains_any(ocr_text, CTA_TERMS), contains_any(ocr_text, BENEFIT_TERMS), |
| contains_any(ocr_text, URGENCY_TERMS), contains_any(ocr_text, TRUST_TERMS), |
| contains_any(ocr_text, PROMO_TERMS), contains_any(ocr_text, PRICE_TERMS), |
| _count_custom_terms(ocr_text, COMMERCIAL_TERMS) > 0, |
| ] |
| marketing_signal = sum(bool(x) for x in commercial_flags) / max(len(commercial_flags), 1) |
| humor_signal = min(_count_custom_terms(ocr_text, HUMOR_TERMS) / 2, 1.0) |
| score = compute_ocr_relevance_score(ocr_text, title, description, transcript_text, ocr_metrics) |
| role = str(ocr_metrics.get("ocr_llm_role") or _ocr_role_for_intent(ocr_text, intent, overlap, marketing_signal, humor_signal)) |
| if not (ocr_text or "").strip(): |
| interpretation = "No se detectó texto en pantalla; la lectura visual depende del audio, imagen y ritmo." |
| elif intent.startswith("musical"): |
| interpretation = "El texto en pantalla se evalúa como letra, rótulo, título, artista o apoyo visual del videoclip; no como CTA comercial." |
| elif intent.startswith("humor"): |
| interpretation = "El texto en pantalla se evalúa como soporte del chiste, contexto o remate; no como CTA de venta." |
| elif intent.startswith("comercial"): |
| interpretation = "El texto en pantalla se evalúa por su capacidad de reforzar oferta, beneficio, prueba o acción." |
| elif intent.startswith("educativo"): |
| interpretation = "El texto en pantalla se evalúa como apoyo explicativo: debe aclarar conceptos, pasos o ideas clave." |
| else: |
| interpretation = "El texto en pantalla se evalúa por su conexión con el tema central y por si ayuda a entender el video." |
| return { |
| "content_intent": intent, |
| "ocr_role": role, |
| "ocr_context_overlap": round(overlap, 4), |
| "ocr_marketing_signal": round(marketing_signal, 4), |
| "ocr_humor_signal": round(humor_signal, 4), |
| "ocr_relevance_score": score, |
| "ocr_interpretation": interpretation, |
| } |
|
|
| def parse_date(value: Any) -> datetime | None: |
| if not value: |
| return None |
| try: |
| parsed = pd.to_datetime(value, utc=True, errors="coerce") |
| if pd.isna(parsed): |
| return None |
| return parsed.to_pydatetime() |
| except Exception: |
| return None |
|
|
|
|
| def days_since_publication(published_at: Any) -> float: |
| dt = parse_date(published_at) |
| if not dt: |
| return 1.0 |
| now = datetime.now(timezone.utc) |
| days = (now - dt).total_seconds() / 86400 |
| return max(days, 1.0) |
|
|
|
|
| def duration_fit_score(duration_seconds: float) -> float: |
| """Score heurístico de ajuste de duración para anuncios y contenido corto.""" |
| d = safe_float(duration_seconds) |
| if d <= 0: |
| return 0.5 |
| if d <= 7: |
| return 0.95 |
| if d <= 15: |
| return 0.90 |
| if d <= 30: |
| return 0.80 |
| if d <= 60: |
| return 0.70 |
| if d <= 180: |
| return 0.55 |
| return 0.40 |
|
|
|
|
| def compute_text_power_score(text: str, ocr_metrics: Dict[str, Any] | None = None) -> float: |
| """Score 0-1 de potencia comunicacional por señales básicas.""" |
| ocr_metrics = ocr_metrics or {} |
| score = 0.0 |
| score += 0.20 * contains_any(text, CTA_TERMS) |
| score += 0.18 * contains_any(text, BENEFIT_TERMS) |
| score += 0.15 * contains_any(text, URGENCY_TERMS) |
| score += 0.12 * contains_any(text, TRUST_TERMS) |
| score += 0.10 * contains_any(text, PROMO_TERMS) |
| score += 0.08 * contains_any(text, PRICE_TERMS) |
| wc = count_words(text) |
| if 8 <= wc <= 120: |
| score += 0.10 |
| elif wc > 120: |
| score += 0.04 |
| if safe_float(ocr_metrics.get("ocr_frame_coverage", 0)) > 0: |
| score += 0.07 |
| return round(min(score, 1.0), 4) |
|
|
|
|
| def compute_engagement_metrics(views: Any, likes: Any, comments: Any, published_at: Any = None) -> Dict[str, float]: |
| views_f = max(safe_float(views), 0.0) |
| likes_f = max(safe_float(likes), 0.0) |
| comments_f = max(safe_float(comments), 0.0) |
| denom = max(views_f, 1.0) |
| days = days_since_publication(published_at) |
| return { |
| "views": views_f, |
| "likes": likes_f, |
| "comments": comments_f, |
| "engagement_rate": round((likes_f + comments_f) / denom, 6), |
| "like_rate": round(likes_f / denom, 6), |
| "comment_rate": round(comments_f / denom, 6), |
| "views_per_day": round(views_f / days, 6), |
| "log_views": round(float(np.log1p(views_f)), 6), |
| "log_likes": round(float(np.log1p(likes_f)), 6), |
| "log_comments": round(float(np.log1p(comments_f)), 6), |
| } |
|
|
|
|
| def build_text_features(title: str, description: str, transcript_text: str, ocr_text: str) -> Dict[str, Any]: |
| title = title or "" |
| description = description or "" |
| transcript_text = transcript_text or "" |
| ocr_text = ocr_text or "" |
| text_total = " ".join([title, description, transcript_text, ocr_text]).strip() |
| return { |
| "title": title, |
| "description": description, |
| "transcript_text": transcript_text, |
| "ocr_text": ocr_text, |
| "text_total": text_total, |
| "title_len": len(title), |
| "description_len": len(description), |
| "transcript_len": len(transcript_text), |
| "ocr_text_len": len(ocr_text), |
| "text_total_len": len(text_total), |
| "title_word_count": count_words(title), |
| "description_word_count": count_words(description), |
| "transcript_word_count": count_words(transcript_text), |
| "ocr_word_count": count_words(ocr_text), |
| "text_total_word_count": count_words(text_total), |
| "exclamation_count": text_total.count("!"), |
| "question_count": text_total.count("?"), |
| "uppercase_ratio": uppercase_ratio(text_total), |
| "digit_count": sum(c.isdigit() for c in text_total), |
| "cta_flag": contains_any(text_total, CTA_TERMS), |
| "urgency_flag": contains_any(text_total, URGENCY_TERMS), |
| "trust_flag": contains_any(text_total, TRUST_TERMS), |
| "promo_flag": contains_any(text_total, PROMO_TERMS), |
| "benefit_flag": contains_any(text_total, BENEFIT_TERMS), |
| "price_flag": contains_any(text_total, PRICE_TERMS), |
| } |
|
|
|
|
| def build_feature_row( |
| title: str = "", |
| description: str = "", |
| transcript_text: str = "", |
| ocr_text: str = "", |
| category_id: Any = "", |
| duration_seconds: Any = 0, |
| views: Any = 0, |
| likes: Any = 0, |
| comments: Any = 0, |
| published_at: Any = None, |
| video_type: str = "auto", |
| extra: Dict[str, Any] | None = None, |
| ) -> Dict[str, Any]: |
| """Construye una fila de features para inferencia.""" |
| extra = extra or {} |
| text_features = build_text_features(title, description, transcript_text, ocr_text) |
| engagement = compute_engagement_metrics(views, likes, comments, published_at) |
| d = safe_float(duration_seconds) |
| ocr_relevance_info = describe_ocr_relevance( |
| ocr_text=ocr_text, |
| title=title, |
| description=description, |
| transcript_text=transcript_text, |
| ocr_metrics=extra, |
| ) |
| ocr_relevance = safe_float(ocr_relevance_info.get("ocr_relevance_score", 0)) |
| content_intent = ocr_relevance_info.get("content_intent") or infer_content_intent_light(title, description, transcript_text, ocr_text) |
| text_power = compute_text_power_score(text_features["text_total"], extra) |
| vt = video_type or "auto" |
| if vt == "auto": |
| vt = "sin narrador" if d and d <= 8 else "con narrador/desconocido" |
| return { |
| **text_features, |
| **engagement, |
| "category_id": str(category_id or "unknown"), |
| "duration_seconds": d, |
| "duration_fit_score": duration_fit_score(d), |
| "duration_bucket": "short" if d <= 15 else "medium" if d <= 60 else "long", |
| "video_type": vt, |
| "content_intent": content_intent, |
| "text_power_score": text_power, |
| "ocr_relevance_score": ocr_relevance, |
| **ocr_relevance_info, |
| **extra, |
| } |
|
|
|
|
| def metricas_block(features: Dict[str, Any]) -> Dict[str, Any]: |
| """Subdiccionario `metricas` para el contrato de salida final.""" |
| return { |
| "views": int(safe_float(features.get("views", 0))), |
| "likes": int(safe_float(features.get("likes", 0))), |
| "comments": int(safe_float(features.get("comments", 0))), |
| "engagement_rate": round(safe_float(features.get("engagement_rate", 0)), 6), |
| "like_rate": round(safe_float(features.get("like_rate", 0)), 6), |
| "comment_rate": round(safe_float(features.get("comment_rate", 0)), 6), |
| "views_per_day": round(safe_float(features.get("views_per_day", 0)), 4), |
| "duration_seconds": round(safe_float(features.get("duration_seconds", 0)), 2), |
| "duration_fit_score": round(safe_float(features.get("duration_fit_score", 0)), 4), |
| "text_power_score": round(safe_float(features.get("text_power_score", 0)), 4), |
| "ocr_relevance_score": round(safe_float(features.get("ocr_relevance_score", 0)), 4), |
| "title_word_count": int(safe_float(features.get("title_word_count", 0))), |
| "description_word_count": int(safe_float(features.get("description_word_count", 0))), |
| "transcript_word_count": int(safe_float(features.get("transcript_word_count", 0))), |
| "ocr_word_count": int(safe_float(features.get("ocr_word_count", 0))), |
| "ocr_frame_coverage": round(safe_float(features.get("ocr_frame_coverage", 0)), 4), |
| "visual_text_density": features.get("visual_text_density", "no_disponible"), |
| "video_type": features.get("video_type", "auto"), |
| "content_intent": features.get("content_intent", "general/branding"), |
| } |
|
|
|
|
| def transcripcion_block(features: Dict[str, Any], extra_status: Dict[str, Any] | None = None) -> Dict[str, Any]: |
| """Subdiccionario `analisis_transcripcion` para el contrato.""" |
| extra_status = extra_status or {} |
| text = features.get("transcript_text", "") or "" |
| return { |
| "disponible": bool(text.strip()), |
| "fuente": extra_status.get("source", "manual_o_no_ejecutado"), |
| "advertencia": extra_status.get("warning", ""), |
| "longitud_caracteres": len(text), |
| "cantidad_palabras": count_words(text), |
| "tiene_cta": bool(features.get("cta_flag")), |
| "tiene_beneficio": bool(features.get("benefit_flag")), |
| "tiene_urgencia": bool(features.get("urgency_flag")), |
| "tiene_confianza": bool(features.get("trust_flag")), |
| "tipo_contenido_estimado": features.get("content_intent", "general/branding"), |
| "preview": text[:900] + ("..." if len(text) > 900 else ""), |
| } |
|
|
|
|
| def ocr_block(features: Dict[str, Any], extra_status: Dict[str, Any] | None = None) -> Dict[str, Any]: |
| """Subdiccionario `analisis_ocr` para el contrato.""" |
| extra_status = extra_status or {} |
| text = features.get("ocr_text", "") or "" |
| return { |
| "disponible": bool(text.strip()), |
| "motor": extra_status.get("engine", "no_disponible"), |
| "advertencia": extra_status.get("warning", ""), |
| "frames_con_texto": int(safe_float(features.get("ocr_frames_with_text", 0))), |
| "frames_totales": int(safe_float(features.get("ocr_frame_count", 0))), |
| "cobertura": round(safe_float(features.get("ocr_frame_coverage", 0)), 4), |
| "cantidad_palabras": int(safe_float(features.get("ocr_word_count", 0))), |
| "densidad": features.get("visual_text_density", "sin_texto"), |
| "relevancia_texto_en_pantalla": round(safe_float(features.get("ocr_relevance_score", 0)), 4), |
| "relevancia_pct": round(safe_float(features.get("ocr_relevance_score", 0)) * 100, 1), |
| "tiene_cta": bool(features.get("ocr_cta_flag")), |
| "tiene_promocion": bool(features.get("ocr_promo_flag")), |
| "tiene_urgencia": bool(features.get("ocr_urgency_flag")), |
| "tiene_confianza": bool(features.get("ocr_trust_flag")), |
| "tiene_exceso_texto": features.get("visual_text_density") == "alta", |
| "tipo_contenido_estimado": features.get("ocr_llm_content_intent") or features.get("content_intent", "general/branding"), |
| "rol_texto_en_pantalla": features.get("ocr_llm_role") or features.get("ocr_role", "sin_funcion_clara"), |
| "ocr_text_raw": features.get("ocr_text_raw", ""), |
| "ocr_first_result": features.get("ocr_first_result", ""), |
| "ocr_llm_source": features.get("ocr_llm_source", ""), |
| "ocr_llm_warning": features.get("ocr_llm_warning", ""), |
| "ocr_llm_meaning": features.get("ocr_llm_meaning", ""), |
| "ocr_llm_confidence": features.get("ocr_llm_confidence", ""), |
| "ocr_llm_content_intent": features.get("ocr_llm_content_intent", ""), |
| "ocr_llm_role": features.get("ocr_llm_role", ""), |
| "overlap_contextual": round(safe_float(features.get("ocr_context_overlap", 0)), 4), |
| "senal_comercial": round(safe_float(features.get("ocr_marketing_signal", 0)), 4), |
| "senal_humor": round(safe_float(features.get("ocr_humor_signal", 0)), 4), |
| "interpretacion_ocr": features.get("ocr_interpretation", ""), |
| "preview": text[:900] + ("..." if len(text) > 900 else ""), |
| } |
|
|
|
|
| def normalize_training_dataframe(df: pd.DataFrame) -> pd.DataFrame: |
| """Normaliza columnas frecuentes de datasets públicos de YouTube. |
| |
| Implementación **vectorizada** para que escale a cientos de miles de filas: |
| se evita ``df.apply(axis=1)`` y se usan operaciones nativas de NumPy/Pandas. |
| """ |
| rename_candidates = { |
| "view_count": "views", |
| "views_count": "views", |
| "likes_count": "likes", |
| "like_count": "likes", |
| "comment_count": "comments", |
| "comments_count": "comments", |
| "publishedAt": "published_at", |
| "publish_time": "published_at", |
| "categoryId": "category_id", |
| "channelTitle": "channel_title", |
| "channel": "channel_title", |
| } |
| normalized = df.copy() |
| for src_col, dst_col in rename_candidates.items(): |
| if src_col in normalized.columns and dst_col not in normalized.columns: |
| normalized = normalized.rename(columns={src_col: dst_col}) |
| for col in ["title", "description", "tags"]: |
| if col not in normalized.columns: |
| normalized[col] = "" |
| normalized[col] = normalized[col].fillna("").astype(str) |
| for col in ["views", "likes", "comments"]: |
| if col not in normalized.columns: |
| normalized[col] = 0 |
| normalized[col] = pd.to_numeric(normalized[col], errors="coerce").fillna(0) |
| if "published_at" not in normalized.columns: |
| normalized["published_at"] = None |
| if "category_id" not in normalized.columns: |
| normalized["category_id"] = "unknown" |
| normalized["category_id"] = normalized["category_id"].fillna("unknown").astype(str) |
| if "duration_seconds" not in normalized.columns: |
| normalized["duration_seconds"] = 0 |
| normalized["duration_seconds"] = pd.to_numeric(normalized["duration_seconds"], errors="coerce").fillna(0) |
|
|
| |
| normalized["text_total"] = ( |
| normalized["title"] + " " |
| + normalized["description"] + " " |
| + normalized["tags"] |
| ) |
|
|
| |
| normalized["title_len"] = normalized["title"].str.len().fillna(0).astype(int) |
| normalized["description_len"] = normalized["description"].str.len().fillna(0).astype(int) |
| normalized["text_total_word_count"] = ( |
| normalized["text_total"].str.findall(r"\w+", flags=re.UNICODE).str.len().fillna(0).astype(int) |
| ) |
|
|
| |
| views = normalized["views"].astype(float).clip(lower=0) |
| likes = normalized["likes"].astype(float).clip(lower=0) |
| comments = normalized["comments"].astype(float).clip(lower=0) |
| denom = views.replace(0, np.nan).fillna(1.0) |
| normalized["engagement_rate"] = ((likes + comments) / denom).round(6) |
| normalized["like_rate"] = (likes / denom).round(6) |
| normalized["comment_rate"] = (comments / denom).round(6) |
| normalized["log_views"] = np.log1p(views).round(6) |
| normalized["log_likes"] = np.log1p(likes).round(6) |
| normalized["log_comments"] = np.log1p(comments).round(6) |
|
|
| |
| pub = pd.to_datetime(normalized["published_at"], errors="coerce", utc=True) |
| now = pd.Timestamp.now(tz="UTC") |
| days = (now - pub).dt.total_seconds() / 86400.0 |
| days = days.fillna(1.0).clip(lower=1.0) |
| normalized["views_per_day"] = (views / days).round(6) |
|
|
| |
| normalized["views"] = views |
| normalized["likes"] = likes |
| normalized["comments"] = comments |
|
|
| |
| text_lower = normalized["text_total"].str.lower() |
|
|
| def _flag(terms): |
| pattern = "|".join(re.escape(t) for t in terms) |
| return text_lower.str.contains(pattern, regex=True, na=False).astype(int) |
|
|
| normalized["cta_flag"] = _flag(CTA_TERMS) |
| normalized["urgency_flag"] = _flag(URGENCY_TERMS) |
| normalized["trust_flag"] = _flag(TRUST_TERMS) |
| normalized["promo_flag"] = _flag(PROMO_TERMS) |
| normalized["benefit_flag"] = _flag(BENEFIT_TERMS) |
| normalized["price_flag"] = _flag(PRICE_TERMS) |
|
|
| |
| score = ( |
| 0.20 * normalized["cta_flag"] |
| + 0.18 * normalized["benefit_flag"] |
| + 0.15 * normalized["urgency_flag"] |
| + 0.12 * normalized["trust_flag"] |
| + 0.10 * normalized["promo_flag"] |
| + 0.08 * normalized["price_flag"] |
| ) |
| wc = normalized["text_total_word_count"] |
| score = score + np.where((wc >= 8) & (wc <= 120), 0.10, np.where(wc > 120, 0.04, 0.0)) |
| normalized["text_power_score"] = score.clip(upper=1.0).round(4) |
|
|
| |
| d = normalized["duration_seconds"].astype(float) |
| fit = pd.Series(0.40, index=normalized.index) |
| fit = np.where(d <= 7, 0.95, fit) |
| fit = np.where((d > 7) & (d <= 15), 0.90, fit) |
| fit = np.where((d > 15) & (d <= 30), 0.80, fit) |
| fit = np.where((d > 30) & (d <= 60), 0.70, fit) |
| fit = np.where((d > 60) & (d <= 180), 0.55, fit) |
| fit = np.where(d <= 0, 0.50, fit) |
| normalized["duration_fit_score"] = fit |
| return normalized |
|
|
|
|
| def create_boost_candidate_target(df: pd.DataFrame, percentile: float = 0.70) -> pd.DataFrame: |
| """Crea etiqueta boost_candidate a partir de score compuesto.""" |
| work = df.copy() |
|
|
| def robust_minmax(series: pd.Series) -> pd.Series: |
| s = pd.to_numeric(series, errors="coerce").fillna(0) |
| lo, hi = s.quantile(0.02), s.quantile(0.98) |
| clipped = s.clip(lo, hi) |
| if hi - lo == 0: |
| return pd.Series(np.zeros(len(s)), index=s.index) |
| return (clipped - lo) / (hi - lo) |
|
|
| work["organic_performance_score"] = ( |
| 0.35 * robust_minmax(work["views_per_day"]) |
| + 0.25 * robust_minmax(work["engagement_rate"]) |
| + 0.15 * robust_minmax(work["like_rate"]) |
| + 0.10 * robust_minmax(work["comment_rate"]) |
| + 0.10 * robust_minmax(work["text_power_score"]) |
| + 0.05 * robust_minmax(work["duration_fit_score"]) |
| ) |
| threshold = work["organic_performance_score"].quantile(percentile) |
| work["boost_candidate"] = (work["organic_performance_score"] >= threshold).astype(int) |
| work["target_threshold"] = threshold |
| return work |
|
|