""" VISUALIZACIONES PARA ANÁLISIS DE SIMILARIDAD COSENO - INDICADORES ODS ======================================================================== Este script genera visualizaciones interactivas y estáticas para ponderar el valor de similaridad_cos como proxy de similaridad al consultar una iniciativa ciudadana con una base de indicadores ODS. Autor: Análisis ODS Fecha: Octubre 2025 """ import pandas as pd import numpy as np import matplotlib.pyplot as plt import seaborn as sns from matplotlib.gridspec import GridSpec import plotly.graph_objects as go import plotly.express as px from plotly.subplots import make_subplots import warnings warnings.filterwarnings('ignore') # Configuración estética plt.style.use('seaborn-v0_8-darkgrid') sns.set_palette("husl") # ============================================================================ # 1. CARGA Y PREPARACIÓN DE DATOS # ============================================================================ def cargar_datos(ruta_archivo): """ Carga los datos desde el archivo markdown y los convierte a DataFrame """ # Leer el archivo saltando la línea de separación df = pd.read_csv(ruta_archivo, sep='|', skiprows=[1]) # Limpiar columnas (eliminar espacios) df.columns = df.columns.str.strip() # Eliminar columnas vacías (primera y última por el formato markdown) df = df.drop(df.columns[[0, -1]], axis=1) # Limpiar espacios en valores de texto for col in df.select_dtypes(include=['object']).columns: df[col] = df[col].str.strip() return df # Diccionario de colores oficiales de los ODS (Fuente: Guías de la ONU) colores_ods = { "1": "#E5243B", # Red - ODS 1: Fin de la Pobreza "2": "#DDA63A", # Mustard - ODS 2: Hambre Cero "3": "#4C9F38", # Kelly Green - ODS 3: Salud y Bienestar "4": "#C5192D", # Dark Red - ODS 4: Educación de Calidad "5": "#FF3A21", # Red Orange - ODS 5: Igualdad de Género "6": "#26BDE2", # Bright Blue - ODS 6: Agua Limpia y Saneamiento "7": "#FCC30B", # Yellow - ODS 7: Energía Asequible y No Contaminante "8": "#A21942", # Burgundy Red - ODS 8: Trabajo Decente y Crecimiento Económico "9": "#FD6925", # Orange - ODS 9: Industria, Innovación e Infraestructura "10": "#DD1367", # Magenta - ODS 10: Reducción de las Desigualdades "11": "#FD9D24", # Golden Yellow - ODS 11: Ciudades y Comunidades Sostenibles "12": "#BF8B2E", # Dark Mustard - ODS 12: Producción y Consumo Responsables "13": "#3F7E44", # Dark Green - ODS 13: Acción por el Clima "14": "#0A97D9", # Blue - ODS 14: Vida Submarina "15": "#56C02B", # Lime Green - ODS 15: Vida de Ecosistemas Terrestres "16": "#00689D", # Royal Blue - ODS 16: Paz, Justicia e Instituciones Sólidas "17": "#19486A", # Navy Blue - ODS 17: Alianzas para Lograr los Objetivos } # ============================================================================ # 2. GRÁFICA 1: DISTRIBUCIÓN DE SIMILARIDAD POR ODS (Box Plot Interactivo) # ============================================================================ def viz_1_distribucion_por_ods(df, id_lvl, score, titulo): """ LÓGICA: Esta visualización muestra la distribución de valores de similaridad coseno agrupados por cada ODS. Permite identificar: - Qué ODS tienen mayor rango de similaridad - La mediana de similaridad por ODS - Outliers o valores atípicos - Consistencia interna de cada ODS INTERPRETACIÓN: - Cajas más altas → Mayor variabilidad en la similaridad dentro del ODS - Medianas altas → El ODS tiene indicadores más similares a la consulta - Outliers superiores → Indicadores específicos muy relevantes """ fig = go.Figure() for idx, ods in enumerate(sorted(df['ODS_ID'].unique())): datos_ods = df[df['ODS_ID'] == ods][score] fig.add_trace(go.Box( y=datos_ods, name=f'ODS {ods}', boxmean='sd', # Mostrar media y desviación estándar marker_color=px.colors.qualitative.Plotly[int(ods) % len(px.colors.qualitative.Plotly)] )) fig.update_layout( title={ 'text': f'Distribución de Similaridad Coseno por {titulo}
Análisis de dispersión y tendencia central por objetivo', 'x': 0.5, 'xanchor': 'center' }, # xaxis_title='Objetivo de Desarrollo Sostenible', xaxis_title=id_lvl, yaxis_title='Similaridad Coseno', height=600, showlegend=False, hovermode='x unified' ) return fig # ============================================================================ # 3. GRÁFICA 2: HEATMAP DE SIMILARIDAD (ODS vs Rango de Ranking) # ============================================================================ def viz_2_heatmap_ods_ranking(df, id_lvl, score, rank, titulo): """ LÓGICA: Matriz de calor que muestra la intensidad de similaridad en función de dos dimensiones: ODS (eje Y) y posición en el ranking (eje X agrupado). Se divide el ranking en deciles (10 grupos) para visualizar cómo se distribuye la similaridad a lo largo de la relevancia ordenada. INTERPRETACIÓN: - Colores cálidos (rojo/naranja) → Alta similaridad - Colores fríos (azul) → Baja similaridad - Patrón horizontal → Un ODS domina en ciertas posiciones - Patrón vertical → Ciertas posiciones tienen alta similaridad en varios ODS - Diagonal descendente → Comportamiento esperado (mayor rank → menor similaridad) """ # Crear deciles de ranking df['rank_decil'] = pd.qcut(df[rank], q=10, labels=[f'D{i+1}' for i in range(10)]) # Crear matriz pivote pivot_table = df.pivot_table( values=score, index=id_lvl, columns='rank_decil', aggfunc='mean' ) fig, ax = plt.subplots(figsize=(14, 8)) sns.heatmap( pivot_table, annot=True, fmt='.3f', cmap='RdYlGn', center=df[score].median(), cbar_kws={'label': 'Similaridad Coseno Promedio'}, linewidths=0.5, ax=ax ) ax.set_title( f'Heatmap: Similaridad Coseno por {id_lvl} y Decil de Ranking\n' 'Visualización de patrones de relevancia en función del orden', fontsize=14, pad=20 ) ax.set_xlabel('Decil de Ranking (D1=Top 10%, D10=Bottom 10%)', fontsize=12) ax.set_ylabel(id_lvl, fontsize=12) plt.tight_layout() return fig # ============================================================================ # 4. GRÁFICA 3: SCATTER PLOT 3D (ODS, Indicador, Similaridad) # ============================================================================ def viz_3_scatter_3d_interactivo(df, id_lvl, score, rank, titulo): """ LÓGICA: Visualización tridimensional que permite explorar la relación entre tres variables: - Eje X: ODS ID - Eje Y: Número de indicador dentro del ODS (extraído del indicador_id) - Eje Z: Similaridad coseno - Tamaño: Inversamente proporcional al ranking (más relevantes = más grandes) - Color: Por ODS INTERPRETACIÓN: - Puntos altos (eje Z) → Alta similaridad - Clusters verticales → Varios indicadores de un ODS son similares - Puntos grandes en altura → Indicadores relevantes y bien posicionados - Permite rotar e interactuar para descubrir patrones espaciales """ # Extraer número de indicador df['indicador_num'] = df[id_lvl].str.extract(r'\.(\d+)\.').astype(float) fig = go.Figure() for ods in sorted(df['ODS_ID'].unique()): datos_ods = df[df['ODS_ID'] == ods] fig.add_trace(go.Scatter3d( x=datos_ods['ODS_ID'], y=datos_ods['indicador_num'], z=datos_ods[score], mode='markers', name=f'ODS {ods}', marker=dict( size=10 - (datos_ods[rank] / len(df) * 8), # Tamaño inversamente proporcional al rank opacity=0.7, line=dict(width=0.5, color='white') ), text=datos_ods[id_lvl], hovertemplate='%{text}
' + 'ODS: %{x}
' + 'Similaridad: %{z:.4f}
' + '' )) fig.update_layout( title='Visualización 3D: ODS × Indicador × Similaridad
Exploración espacial de patrones de relevancia', scene=dict( xaxis_title='ODS ID', yaxis_title='Número de Indicador', zaxis_title='Similaridad Coseno', camera=dict(eye=dict(x=1.5, y=1.5, z=1.3)) ), height=700, showlegend=True ) return fig # ============================================================================ # 5. GRÁFICA 4: RADAR CHART - Similaridad Promedio por ODS # ============================================================================ def viz_4_radar_chart_ods(df, id_lvl, score, rank, titulo): """ LÓGICA: Gráfico de radar (spider chart) que muestra la similaridad promedio de cada ODS en forma circular. Útil para comparar rápidamente el perfil de relevancia de todos los ODS. INTERPRETACIÓN: - Áreas más grandes → Mayor similaridad promedio con la consulta - Forma del polígono → Perfil de cobertura de la iniciativa - Picos → ODS altamente relevantes - Valles → ODS menos relacionados - Simetría → Iniciativa balanceada entre ODS vs. especializada """ # Calcular promedios por ODS ods_stats = df.groupby(id_lvl).agg({ score: ['mean', 'max', 'count'] }).reset_index() ods_stats.columns = [id_lvl, 'sim_promedio', 'sim_max', 'count_indicadores'] ods_stats = ods_stats.sort_values(id_lvl) fig = go.Figure() # Similaridad promedio fig.add_trace(go.Scatterpolar( r=ods_stats['sim_promedio'], theta=['ODS ' + str(x) for x in ods_stats[id_lvl]], fill='toself', name='Similaridad Promedio', line_color='blue', fillcolor='rgba(0, 0, 255, 0.2)' )) # Similaridad máxima fig.add_trace(go.Scatterpolar( r=ods_stats['sim_max'], theta=['ODS ' + str(x) for x in ods_stats[id_lvl]], fill='toself', name='Similaridad Máxima', line_color='red', fillcolor='rgba(255, 0, 0, 0.1)' )) # Calcular rango automático con margen del 5% valor_minimo = min(ods_stats['sim_promedio'].min(), ods_stats['sim_max'].min()) valor_maximo = max(ods_stats['sim_promedio'].max(), ods_stats['sim_max'].max()) margen = (valor_maximo - valor_minimo) * 0.05 rango_automatico = [max(0, valor_minimo - margen), min(1, valor_maximo + margen)] fig.update_layout( polar=dict( radialaxis=dict( visible=True, range=rango_automatico ) ), title=f'Radar Chart: Perfil de Similaridad por {titulo}
Comparación de promedios y máximos', showlegend=True, height=600 ) return fig # ============================================================================ # 6. GRÁFICA 5: SUNBURST - Jerarquía ODS → Indicadores # ============================================================================ def viz_5_sunburst_jerarquia(df, id_lvl, score, rank, titulo): """ LÓGICA: Diagrama de sunburst (sol radiante) que muestra la jerarquía ODS → Indicadores con el tamaño proporcional a la similaridad. El círculo interior representa los ODS y los anillos exteriores los indicadores dentro de cada ODS. INTERPRETACIÓN: - Segmentos grandes → Indicadores o grupos de indicadores muy similares - Colores → Gradiente de similaridad (más oscuro = mayor similaridad) - Permite drill-down interactivo - Visualiza la contribución relativa de cada indicador al ODS """ # Preparar datos para sunburst df_sun = df.copy() df_sun['ods_label'] = 'ODS ' + df_sun['ODS_ID'].astype(str) df_sun['path'] = df_sun['ods_label'] + ' / ' + df_sun[id_lvl] # Limitar a top 100 para mejor visualización df_sun_top = df_sun.nsmallest(100, rank) fig = px.sunburst( df_sun_top, path=['ods_label', id_lvl], values=score, color=score, color_continuous_scale='Viridis', hover_data=[rank], title=f'Sunburst: Jerarquía {titulo} → Indicadores (Top 100)
Tamaño proporcional a similaridad' ) fig.update_layout( height=700, coloraxis_colorbar=dict(title="Similaridad") ) return fig # ============================================================================ # 7. GRÁFICA 6: CASCADA - Top Indicadores por ODS # ============================================================================ def viz_6_top_indicadores_por_ods(df, id_lvl, score, rank, titulo, top_n=3): """ LÓGICA: Para cada ODS, muestra los top N indicadores con mayor similaridad en un formato de barras horizontales agrupadas. Permite comparar: - Cuál es el mejor indicador de cada ODS - La brecha entre el mejor y los siguientes - Qué ODS tiene los indicadores más relevantes en general INTERPRETACIÓN: - Barras más largas → Mayor similaridad - Agrupación densa → Varios indicadores igualmente relevantes - Gaps grandes → Un indicador destaca sobre el resto en ese ODS """ # Obtener top N por ODS top_indicadores = df.groupby('ODS_ID').apply( lambda x: x.nsmallest(top_n, rank) ).reset_index(drop=True) fig = px.bar( top_indicadores, x=score, y=id_lvl, color=id_lvl, orientation='h', facet_row=id_lvl, height=300 * len(df[id_lvl].unique()) // 3, title=f'Top {top_n} Indicadores con Mayor Similaridad por ODS
Análisis de relevancia por objetivo', labels={score: 'Similaridad Coseno', id_lvl: 'Indicador'}, color_continuous_scale='Plasma' ) fig.update_yaxes(showticklabels=True, matches=None) fig.update_xaxes(matches='x') return fig # ============================================================================ # 8. GRÁFICA 7: STREAM GRAPH - Evolución de Similaridad # ============================================================================ def viz_7_streamgraph_similaridad(df, id_lvl, score, rank, titulo): """ LÓGICA: Gráfico de área apilada que muestra cómo contribuye cada ODS a la similaridad acumulada a lo largo del ranking. El eje X es el ranking (ordenado) y el eje Y muestra el área acumulada de similaridad por ODS. INTERPRETACIÓN: - Áreas más anchas → ODS con mayor presencia en ese rango de ranking - Cambios de color dominante → Transición de relevancia entre ODS - Posición en ranking bajo → Indicadores más relevantes - Permite ver qué ODS domina en qué rangos de relevancia """ # Crear bins de ranking df['rank_bin'] = pd.cut(df[rank], bins=20, labels=False) # Agrupar por rank_bin y ODS stream_data = df.groupby(['rank_bin', id_lvl])[score].sum().reset_index() # Pivotar para streamgraph stream_pivot = stream_data.pivot(index='rank_bin', columns=id_lvl, values=score).fillna(0) fig = go.Figure() for ods in stream_pivot.columns: fig.add_trace(go.Scatter( x=stream_pivot.index, y=stream_pivot[ods], mode='lines', name=f'ODS {ods}', stackgroup='one', groupnorm='percent', # Normalizar a porcentaje hovertemplate='ODS %{fullData.name}
Contribución: %{y:.1f}%' )) fig.update_layout( title='Stream Graph: Contribución de cada ODS por Rango de Ranking
Evolución de relevancia normalizada', xaxis_title='Rango de Ranking (agrupado)', yaxis_title='Contribución Porcentual', height=600, hovermode='x unified' ) return fig # ============================================================================ # 9. GRÁFICA 8: VIOLIN PLOT - Comparación Detallada de Distribuciones # ============================================================================ def viz_8_violin_plot_ods(df, id_lvl, score, rank, titulo): """ LÓGICA: Similar al box plot pero muestra la distribución completa de densidad de probabilidad de la similaridad para cada ODS. El ancho del "violín" representa la concentración de valores en ese rango. INTERPRETACIÓN: - Violines anchos → Muchos valores en ese rango de similaridad - Violines angostos → Pocos valores en ese rango - Forma bimodal → Dos grupos de indicadores con diferente similaridad - Forma unimodal → Indicadores homogéneos en similaridad - Permite ver distribuciones no normales que el box plot no captura """ fig = go.Figure() for ods in sorted(df[id_lvl].unique()): datos_ods = df[df[id_lvl] == ods][score] fig.add_trace(go.Violin( y=datos_ods, name=f'ODS {ods}', box_visible=True, meanline_visible=True, fillcolor=px.colors.qualitative.Plotly[int(ods) % len(px.colors.qualitative.Plotly)], opacity=0.6, x0=f'ODS {ods}' )) fig.update_layout( title='Violin Plot: Distribución de Densidad de Similaridad por ODS
Análisis detallado de concentración de valores', yaxis_title='Similaridad Coseno', xaxis_title='Objetivo de Desarrollo Sostenible', height=600, showlegend=False ) return fig # ============================================================================ # 10. GRÁFICA 9: DASHBOARD INTEGRADO - Métricas Clave # ============================================================================ def viz_9_dashboard_metricas(df, id_lvl, score, rank, titulo): """ LÓGICA: Dashboard con múltiples paneles que resume las métricas clave: - Panel 1: Top 10 indicadores con mayor similaridad - Panel 2: Estadísticas por ODS (media, std, max, min) - Panel 3: Distribución global de similaridad (histograma) - Panel 4: Correlación entre rank y similaridad INTERPRETACIÓN: - Vista holística de la calidad del matching - Permite validar que el ranking está bien correlacionado con similaridad - Identifica outliers o problemas en el cálculo - Facilita comunicación de resultados a stakeholders """ fig = make_subplots( rows=2, cols=2, subplot_titles=( 'Top 10 Indicadores por Similaridad', 'Estadísticas por ODS', 'Distribución Global de Similaridad', 'Correlación: Rank vs Similaridad' ), specs=[ [{"type": "bar"}, {"type": "table"}], [{"type": "histogram"}, {"type": "scatter"}] ] ) # Panel 1: Top 10 top_10 = df.nsmallest(10, rank) fig.add_trace( go.Bar( x=top_10[score], y=top_10['indicador_id'], orientation='h', marker_color='lightblue', text=top_10[score].round(4), textposition='auto' ), row=1, col=1 ) # Panel 2: Tabla de estadísticas stats_ods = df.groupby(id_lvl)[score].agg(['mean', 'std', 'min', 'max', 'count']).reset_index() stats_ods.columns = ['ODS', 'Media', 'Std', 'Min', 'Max', 'Count'] stats_ods = stats_ods.round(4) fig.add_trace( go.Table( header=dict(values=list(stats_ods.columns), fill_color='paleturquoise', align='left'), cells=dict(values=[stats_ods[col] for col in stats_ods.columns], fill_color='lavender', align='left') ), row=1, col=2 ) # Panel 3: Histograma fig.add_trace( go.Histogram( x=df[score], nbinsx=30, marker_color='indianred', name='Distribución' ), row=2, col=1 ) # Panel 4: Scatter rank vs similaridad fig.add_trace( go.Scatter( x=df[rank], y=df[score], mode='markers', marker=dict( size=5, color=df[id_lvl], colorscale='Viridis', showscale=True, colorbar=dict(title="ODS", x=1.15) ), text=df['indicador_id'] ), row=2, col=2 ) # Añadir línea de tendencia z = np.polyfit(df[rank], df[score], 1) p = np.poly1d(z) fig.add_trace( go.Scatter( x=df[rank], y=p(df[rank]), mode='lines', line=dict(color='red', dash='dash'), name='Tendencia' ), row=2, col=2 ) fig.update_xaxes(title_text="Similaridad", row=1, col=1) fig.update_xaxes(title_text="Similaridad", row=2, col=1) fig.update_xaxes(title_text="Rank", row=2, col=2) fig.update_yaxes(title_text="Indicador", row=1, col=1) fig.update_yaxes(title_text="Frecuencia", row=2, col=1) fig.update_yaxes(title_text="Similaridad", row=2, col=2) fig.update_layout( height=900, showlegend=False, title_text="Dashboard Integrado: Métricas Clave de Similaridad ODS", title_x=0.5 ) return fig # ============================================================================ # 11. GRÁFICA 10: MATRIZ DE TRANSICIÓN - Cambios de ODS por Ranking # ============================================================================ def viz_10_matriz_transicion(df, id_lvl, score, rank, titulo): """ LÓGICA: Muestra cómo cambia el ODS dominante a medida que avanzamos en el ranking. Divide el ranking en cuartiles y muestra qué ODS tiene más presencia en cada cuartil. INTERPRETACIÓN: - Permite ver si un ODS domina consistentemente - Identifica cambios de dominancia (ej: ODS 5 domina top rankings, luego ODS 17) - Útil para entender si la iniciativa es más afín a ciertos ODS - Ayuda a explicar por qué ciertos ODS aparecen más arriba """ # Crear cuartiles df['cuartil'] = pd.qcut(df[rank], q=4, labels=['Q1 (Top)', 'Q2', 'Q3', 'Q4 (Bottom)']) # Contar presencia de ODS por cuartil matriz = pd.crosstab(df[id_lvl], df['cuartil'], normalize='columns') * 100 fig, ax = plt.subplots(figsize=(12, 8)) sns.heatmap( matriz, annot=True, fmt='.1f', cmap='YlOrRd', cbar_kws={'label': '% de Presencia en Cuartil'}, linewidths=0.5, ax=ax ) ax.set_title( 'Matriz de Transición: Presencia de ODS por Cuartil de Ranking\n' 'Análisis de dominancia y evolución', fontsize=14, pad=20 ) ax.set_xlabel('Cuartil de Ranking', fontsize=12) ax.set_ylabel('ODS ID', fontsize=12) plt.tight_layout() return fig def viz_19_resumen_tags(df_ods, df_metas, df_indicadores): """ Visualización 19: Resumen en Tags (Métricas Clave) Muestra métricas agregadas en formato de tags/badges: - Cantidad de iniciativas - Promedios por iniciativa (ODS, Metas, Indicadores) - Elementos más frecuentes """ # Calcular métricas n_iniciativas = df_ods['INICIATIVA_ID'].nunique() if 'INICIATIVA_ID' in df_ods.columns else 1 # Promedios por iniciativa try: ods_por_iniciativa = df_ods.groupby('INICIATIVA_ID')['ODS_ID'].nunique().mean() metas_por_iniciativa = df_metas.groupby('INICIATIVA_ID')['META_ID'].nunique().mean() ind_por_iniciativa = df_indicadores.groupby('INICIATIVA_ID')['INDICADOR_ID'].nunique().mean() except: ods_por_iniciativa = df_ods.groupby('id_unico')['ODS_ID'].nunique().mean() metas_por_iniciativa = df_metas.groupby('id_unico')['META_ID'].nunique().mean() ind_por_iniciativa = df_indicadores.groupby('id_unico')['INDICADOR_ID'].nunique().mean() # Más frecuentes ods_mas_frecuente = df_ods['ODS_ID'].mode()[0] if len(df_ods) > 0 else 0 meta_mas_frecuente = df_metas['META_ID'].mode()[0] if len(df_metas) > 0 else 'N/A' ind_mas_frecuente = df_indicadores['INDICADOR_ID'].mode()[0] if len(df_indicadores) > 0 else 'N/A' # Frecuencias ods_freq = (df_ods['ODS_ID'] == ods_mas_frecuente).sum() meta_freq = (df_metas['META_ID'] == meta_mas_frecuente).sum() ind_freq = (df_indicadores['INDICADOR_ID'] == ind_mas_frecuente).sum() # Crear HTML con tags html1 = f"""

Métricas Generales

Iniciativas Analizadas
{n_iniciativas}
""" html2 = f"""

Promedio por Iniciativa

ODS
{ods_por_iniciativa:.1f}
Metas
{metas_por_iniciativa:.1f}
Indicadores
{ind_por_iniciativa:.1f}
""" html3 = f"""

Elementos Más Frecuentes

ODS Más Frecuente
ODS {ods_mas_frecuente}
{ods_freq} apariciones
Meta Más Frecuente
Meta {meta_mas_frecuente}
{meta_freq} apariciones
Indicador Más Frecuente
{str(ind_mas_frecuente)[:30]}
{ind_freq} apariciones
""" return (html1, html2, html3) import plotly.graph_objects as go # Para visualizaciones_relaciones.py def viz_20_pareto_ods(df, nivel, nivel_pareto, mass = False, iniciativa_id=None): """ Visualización 20: Diagrama de Pareto por Iniciativa Muestra la curva 80/20 para identificar ODS críticos """ if mass: df_init = df.copy() # Ordenar por similaridad df_sorted = df_init.sort_values(f'score', ascending=False).reset_index(drop=True) # Se aplica calculo exponencial para acentuar y diferenciar los pesos df_sorted[f'{nivel}_similaridad_cos_exp3'] = df_sorted[f'score'] ** 3 else: # Si no se especifica iniciativa, usar la primera if iniciativa_id == 'individual': iniciativa_id = 'consulta_individual' df_init = df.copy() elif iniciativa_id is None: iniciativa_id = df['INICIATIVA_ID'].iloc[0] # Filtrar iniciativa df_init = df[df['INICIATIVA_ID'] == iniciativa_id].copy() # Ordenar por similaridad df_sorted = df_init.sort_values(f'{nivel}_similaridad_cos', ascending=False).reset_index(drop=True) # Se aplica calculo exponencial para acentuar y diferenciar los pesos df_sorted[f'{nivel}_similaridad_cos_exp3'] = df_sorted[f'{nivel}_similaridad_cos'] ** 3 # Normalizar la columna ods_similaridad_cos_exp3 min_val = df_sorted[f'{nivel}_similaridad_cos_exp3'].min() max_val = df_sorted[f'{nivel}_similaridad_cos_exp3'].max() if max_val - min_val > 0: df_sorted[f'{nivel}_similaridad_cos_exp3_norm'] = (df_sorted[f'{nivel}_similaridad_cos_exp3'] - min_val) / (max_val - min_val) else: df_sorted[f'{nivel}_similaridad_cos_exp3_norm'] = 0.0 # Handle case where all values are the same # Calcular Pareto total = df_sorted[f'{nivel}_similaridad_cos_exp3_norm'].sum() df_sorted['acumulado'] = df_sorted[f'{nivel}_similaridad_cos_exp3_norm'].cumsum() df_sorted['porcentaje_acumulado'] = (df_sorted['acumulado'] / total) * 100 # Punto de corte 80% corte_80 = (df_sorted['porcentaje_acumulado'] <= (nivel_pareto * 100)).sum() # Crear figura con doble eje Y fig = go.Figure() # Barras: Similaridad individual fig.add_trace(go.Bar( name='Similaridad', x=[f"{nivel.upper()} {ods}" for ods in df_sorted[f'{nivel.upper()}_ID']], y=df_sorted[f'{nivel}_similaridad_cos_exp3_norm'], marker_color='#4472C4', yaxis='y', hovertemplate='%{x}
Similaridad: %{y:.4f}' )) # Línea: Porcentaje acumulado fig.add_trace(go.Scatter( name='% Acumulado', x=[f"{nivel.upper()} {ods}" for ods in df_sorted[f'{nivel.upper()}_ID']], y=df_sorted['porcentaje_acumulado'], mode='lines+markers', line=dict(color='#FF6B6B', width=3), marker=dict(size=8), yaxis='y2', hovertemplate='%{x}
Acumulado: %{y:.1f}%' )) # Línea de corte 80% fig.add_hline( y=80, line_dash="dash", line_color="red", annotation_text="80% (Regla de Pareto)", annotation_position="right", yref='y2' ) fig.add_hline( y=( nivel_pareto * 100 ), line_dash="dash", line_color="red", annotation_text=f"{nivel_pareto*100:.0f}% (Regla de Pareto)", annotation_position="right", yref='y2' ) # Sombra del área Pareto (top críticos) fig.add_vrect( x0=-0.5, x1=corte_80 - 0.5, fillcolor="green", opacity=0.1, layer="below", line_width=0, annotation_text=f"Top {corte_80} {nivel.upper()}
({nivel_pareto*100:.0f}% del valor)", annotation_position="top left" ) # Layout con doble eje Y fig.update_layout( title=dict( text=f"Diagrama de Pareto - Iniciativa {str(iniciativa_id)[:15]}
Top {corte_80} {nivel.upper()} representan el {nivel_pareto*100:.0f}% de la similaridad total", font=dict(size=18, color='#2E5090') ), xaxis=dict(title=f'{nivel.upper()} ordenados por Similaridad'), yaxis=dict( title='Similaridad Coseno', side='left', showgrid=True ), yaxis2=dict( title='Porcentaje Acumulado (%)', side='right', overlaying='y', range=[0, 105], showgrid=False ), hovermode='x unified', height=600, legend=dict(x=0.7, y=0.95), plot_bgcolor='white' ) return fig, corte_80 def viz_21_ranking_mixto_masivo(df_resultado, nivel): """ Visualización 21: Ranking de ODS - Análisis Masivo Muestra el resultado de la estrategia mixta con barras horizontales coloreadas por score y tamaño por frecuencia """ import plotly.graph_objects as go from plotly.subplots import make_subplots # Ordenar por score descendente df_plot = df_resultado.sort_values('score', ascending=True).tail(15) # Top 15 # # Normalizar score para colores # score_norm = (df_plot['score'] - df_plot['score'].min()) / (df_plot['score'].max() - df_plot['score'].min()) # Obtener colores basados en ODS_ID del diccionario colores_ods colors = [colores_ods[ods_id] for ods_id in df_plot['ODS_ID']] # Normalizar frecuencia al rango [5, 25] para tamaños de círculos proporcionales freq_min = df_plot['frecuencia'].min() freq_max = df_plot['frecuencia'].max() if freq_max > freq_min: freq_norm = (df_plot['frecuencia'] - freq_min) / (freq_max - freq_min) * 20 + 5 else: freq_norm = pd.Series([15] * len(df_plot), index=df_plot.index) # # Crear colores del azul al verde según score # colors = [f'rgb({int(46 + (112-46)*s)}, {int(80 + (173-80)*s)}, {int(144 + (71-144)*s)})' # for s in score_norm] fig = go.Figure() # if nivel == 'meta': # Barras horizontales META fig.add_trace(go.Bar( y=[f"{nivel.upper()} {ods}" for ods in df_plot[f'{nivel.upper()}_ID']], x=df_plot['score'], orientation='h', marker=dict( color=colors, line=dict(color='white', width=1) ), text=[f"{score:.4f}" for score in df_plot['score']], textposition='inside', textfont=dict(color='white', size=11, family='Arial Black'), # hovertemplate=f'{nivel.upper()} %{{y}}
Score: %{{x:.4f}}
Frecuencia: %{{customdata}}', # customdata=df_plot['frecuencia'] #version funcional # hovertemplate=f'{nivel.upper()} %{{y}}
Score: %{{x:.4f}}
ODS: %{{customdata[0]}}
Frecuencia: %{{customdata[1]}}', # customdata=df_plot[['ODS_ID', 'frecuencia']].values # hovertemplate=f'{nivel.upper()} %{{y}}
Score: %{{x:.4f}}
ODS: %{{customdata[0]}}
Frecuencia: %{{customdata[1]}}', hovertemplate=f' %{{y}}
Score: %{{x:.4f}}
ODS: %{{customdata[0]}}
Frecuencia: %{{customdata[1]}}', customdata=df_plot[['ODS_ID', 'frecuencia']].values )) # elif nivel == 'ods': # # Barras horizontales ODS # fig.add_trace(go.Bar( # y=[f"{nivel.upper()} {ods}" for ods in df_plot[f'{nivel.upper()}_ID']], # x=df_plot['score'], # orientation='h', # marker=dict( # color=colors, # line=dict(color='white', width=1) # ), # text=[f"{score:.4f}" for score in df_plot['score']], # textposition='inside', # textfont=dict(color='white', size=11, family='Arial Black'), # # hovertemplate=f'{nivel.upper()} %{{y}}
Score: %{{x:.4f}}
Frecuencia: %{{customdata}}', # # customdata=df_plot['frecuencia'] # #version funcional # # hovertemplate=f'{nivel.upper()} %{{y}}
Score: %{{x:.4f}}
ODS: %{{customdata[0]}}
Frecuencia: %{{customdata[1]}}', # # customdata=df_plot[['ODS_ID', 'frecuencia']].values # hovertemplate=f' %{{y}}
Score: %{{x:.4f}}
ODS: %{{customdata[0]}}
Frecuencia: %{{customdata[1]}} %{{customdata[2]}}', # customdata=df_plot[['ODS_ID', 'frecuencia', 'OBJETIVO']].values # )) # Agregar marcadores de frecuencia (normalizados) fig.add_trace(go.Scatter( y=[f"{nivel.upper()} {ods}" for ods in df_plot[f'{nivel.upper()}_ID']], x=df_plot['score'], mode='markers', marker=dict( size=freq_norm, # Tamaño normalizado proporcional a frecuencia [5-25] color='rgba(255, 255, 255, 0.6)', line=dict(color='#2E5090', width=2) ), showlegend=False, hoverinfo='skip' )) fig.update_layout( title=dict( # text=f"Ranking Global de {nivel.upper()} - Score
Tamaño de círculos = Frecuencia de aparición", text=f"Ranking Global de {nivel.upper()} - Estrategia Mixta
Colores = ODS asociado | Tamaño de círculos = Frecuencia de aparición", font=dict(size=20, color='#2E5090'), x=0.5, xanchor='center' ), xaxis=dict( title='Score Mixto Ponderado', showgrid=True, gridcolor='rgba(0,0,0,0.1)' ), yaxis=dict( title='', showgrid=False ), height=600, plot_bgcolor='white', paper_bgcolor='white', margin=dict(l=80, r=20, t=100, b=60) ) return fig def viz_22_composicion_score_mixto(w_sim=0.5, w_rank=0.3, w_freq=0.2): """ Visualización 22: Composición del Score Mixto Muestra los pesos/porcentajes usados en la estrategia mixta """ import plotly.graph_objects as go # Datos de composición componentes = ['Similaridad', 'Ranking', 'Frecuencia'] pesos = [w_sim, w_rank, w_freq] porcentajes = [w * 100 for w in pesos] colores = ['#2E5090', '#4472C4', '#70AD47'] # Crear figura con solo gráfico de pastel fig = go.Figure() # Gráfico de pastel fig.add_trace( go.Pie( labels=componentes, values=porcentajes, marker=dict(colors=colores, line=dict(color='white', width=2)), textinfo='label+percent', textfont=dict(size=14, color='white', family='Arial Black'), hovertemplate='%{label}
Peso: %{value:.0f}%', hole=0.4 ) ) # Layout fig.update_layout( title=dict( text=f"Composición del Score Mixto
Total: {sum(porcentajes):.0f}%", font=dict(size=20, color='#2E5090'), x=0.5, xanchor='center' ), height=600*0.7, width=700*0.6, showlegend=True, plot_bgcolor='white', paper_bgcolor='white', legend=dict( orientation='h', x=0.5, y=-0.15, xanchor='center', yanchor='top' ) ) # Agregar anotación central en el donut fig.add_annotation( text=f"Score
Mixto", x=0.5, y=0.5, xref='paper', yref='paper', showarrow=False, font=dict(size=18, color='#2E5090', family='Arial Black') ) return fig def viz_23_nube_palabras_paloma(df, columna_texto, max_palabras=100): """ Visualización 23: Nube de Palabras en Forma de Paloma de Paz Genera una nube de palabras con la forma de una paloma mostrando las palabras más frecuentes de una columna de texto Args: df: DataFrame con los datos columna_texto: Nombre de la columna con texto max_palabras: Máximo de palabras a mostrar """ from wordcloud import WordCloud import matplotlib.pyplot as plt import numpy as np from PIL import Image import os import tempfile # Combinar todo el texto de la columna texto_completo = ' '.join(df[columna_texto].dropna().astype(str)) # Palabras a excluir (stopwords en español) stopwords_es = { 'el', 'la', 'de', 'que', 'y', 'a', 'en', 'un', 'ser', 'se', 'no', 'haber', 'por', 'con', 'su', 'para', 'como', 'estar', 'tener', 'le', 'lo', 'todo', 'pero', 'más', 'hacer', 'o', 'poder', 'decir', 'este', 'ir', 'otro', 'ese', 'la', 'si', 'me', 'ya', 'ver', 'porque', 'dar', 'cuando', 'él', 'muy', 'sin', 'vez', 'mucho', 'saber', 'qué', 'sobre', 'mi', 'alguno', 'mismo', 'yo', 'también', 'hasta', 'año', 'dos', 'querer', 'entre', 'así', 'primero', 'desde', 'grande', 'eso', 'ni', 'nos', 'llegar', 'pasar', 'tiempo', 'ella', 'del', 'al', 'los', 'las', 'una', 'unos', 'unas', 'ante', 'bajo', 'cabe', 'donde', 'durante', 'mediante', 'salvo', 'según', 'excepto', 'hacia', 'mediante', 'sus' } # Crear máscara en forma de paloma de paz # Imagen SVG de paloma simplificada como array numpy def crear_mascara_paloma(width=800, height=600): """Crea una máscara en forma de paloma de paz""" from PIL import Image, ImageDraw # Crear imagen en blanco img = Image.new('RGB', (width, height), 'white') draw = ImageDraw.Draw(img) # Coordenadas de la paloma (simplificada) # Cuerpo cuerpo = [(width//2, height//2 + 50), (width//2 + 100, height//2 + 80), (width//2 + 80, height//2 + 120), (width//2 - 20, height//2 + 100)] # Cabeza cabeza_center = (width//2 - 30, height//2 - 20) cabeza_radius = 40 # Ala izquierda (extendida) ala_izq = [(width//2, height//2 + 50), (width//2 - 150, height//2 - 80), (width//2 - 200, height//2 - 100), (width//2 - 180, height//2), (width//2 - 100, height//2 + 20)] # Ala derecha (extendida) ala_der = [(width//2, height//2 + 50), (width//2 + 150, height//2 - 50), (width//2 + 220, height//2 - 80), (width//2 + 200, height//2 + 10), (width//2 + 120, height//2 + 30)] # Cola cola = [(width//2 + 80, height//2 + 120), (width//2 + 120, height//2 + 180), (width//2 + 100, height//2 + 200), (width//2 + 60, height//2 + 170)] # Rama de olivo (en el pico) rama = [(width//2 - 70, height//2 - 10), (width//2 - 120, height//2 - 30), (width//2 - 140, height//2 - 25)] # Dibujar formas en negro (área donde irán las palabras) draw.ellipse([cabeza_center[0]-cabeza_radius, cabeza_center[1]-cabeza_radius, cabeza_center[0]+cabeza_radius, cabeza_center[1]+cabeza_radius], fill='black') draw.polygon(cuerpo, fill='black') draw.polygon(ala_izq, fill='black') draw.polygon(ala_der, fill='black') draw.polygon(cola, fill='black') draw.line(rama, fill='black', width=8) # Ojo (punto blanco) draw.ellipse([cabeza_center[0]-5, cabeza_center[1]-5, cabeza_center[0]+5, cabeza_center[1]+5], fill='white') return np.array(img) # Crear máscara mascara_paloma = crear_mascara_paloma() # Generar nube de palabras wordcloud = WordCloud( width=800, height=600, background_color='white', stopwords=stopwords_es, max_words=max_palabras, mask=mascara_paloma, contour_width=2, contour_color='#2E5090', colormap='Blues', relative_scaling=0.5, min_font_size=8 ).generate(texto_completo) # Crear figura fig, ax = plt.subplots(figsize=(12, 9), facecolor='white') ax.imshow(wordcloud, interpolation='bilinear') ax.axis('off') ax.set_title('Nube de Palabras - Paloma de Paz\nPalabras más frecuentes en análisis ODS', fontsize=18, color='#2E5090', fontweight='bold', pad=20) plt.tight_layout() # Guardar en archivo temporal temp_dir = tempfile.gettempdir() filepath = os.path.join(temp_dir, 'nube_palabras_paloma.png') fig.savefig(filepath, format='png', dpi=150, bbox_inches='tight', facecolor='white') plt.close(fig) return filepath def viz_23_nube_palabras_simple(df, columna_texto, max_palabras=100, forma='paloma'): """ Versión alternativa con forma circular/elíptica (más simple, no requiere crear máscara compleja) """ from wordcloud import WordCloud import matplotlib.pyplot as plt import tempfile import os # Combinar texto texto_completo = ' '.join(df[columna_texto].dropna().astype(str)) # Stopwords español stopwords_es = { 'el', 'la', 'de', 'que', 'y', 'a', 'en', 'un', 'ser', 'se', 'no', 'haber', 'por', 'con', 'su', 'para', 'como', 'estar', 'tener', 'le', 'lo', 'todo', 'pero', 'más', 'hacer', 'o', 'poder', 'decir', 'este', 'ir', 'otro', 'ese', 'si', 'me', 'ya', 'ver', 'porque', 'dar', 'cuando', 'muy', 'sin', 'vez', 'mucho', 'saber', 'sobre', 'mi', 'también', 'hasta', 'año', 'dos', 'entre', 'del', 'al', 'los', 'las', 'una', 'unos', 'unas', 'donde', 'cuando', 'sus', 'según', } # Generar nube con forma elíptica (simula ala de paloma) wordcloud = WordCloud( width=800, height=600, background_color='white', stopwords=stopwords_es, max_words=max_palabras, colormap='Blues', relative_scaling=0.5, min_font_size=10, prefer_horizontal=0.7, collocations=False ).generate(texto_completo) # Crear figura con diseño de paloma sugerido fig, ax = plt.subplots(figsize=(14, 8), facecolor='white') ax.imshow(wordcloud, interpolation='bilinear') ax.axis('off') # Título decorativo # ax.text(0.5, 0.98, '🕊️ Nube de Palabras - Análisis ODS', ax.text(0.5, 0.98, ' ', transform=ax.transAxes, fontsize=20, color='#2E5090', fontweight='bold', ha='center', va='top') ax.text(0.5, 0.02, 'Palabras más frecuentes en descripciones de ODS', transform=ax.transAxes, fontsize=12, color='#666', ha='center', va='bottom') plt.tight_layout() # Guardar temp_dir = tempfile.gettempdir() filepath = os.path.join(temp_dir, 'nube_palabras_ods.png') fig.savefig(filepath, format='png', dpi=150, bbox_inches='tight', facecolor='white') plt.close(fig) return filepath def viz_24_header_conecta_ods(logo_path=None): """ Visualización 24: Header ConectaODS Componente HTML llamativo con logo y descripción Args: logo_path: Ruta al logo (opcional). Si no se provee, usa placeholder """ import base64 import os # Convertir logo a base64 si existe logo_base64 = "" if logo_path and os.path.exists(logo_path): try: with open(logo_path, "rb") as img_file: logo_base64 = base64.b64encode(img_file.read()).decode() logo_src = f"data:image/png;base64,{logo_base64}" except: logo_src = "" else: # Placeholder SVG si no hay logo logo_src = """data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120' viewBox='0 0 120 120'%3E%3Ccircle cx='60' cy='60' r='55' fill='%232E5090'/%3E%3Ctext x='60' y='75' font-family='Arial' font-size='48' font-weight='bold' fill='white' text-anchor='middle'%3EODS%3C/text%3E%3C/svg%3E""" html = f"""
ConectaODS Logo

ConectaODS v1.0

Tu voz en clave de desarrollo sostenible

ConectaODS es una herramienta que convierte relatos, ideas o iniciativas del territorio en conexiones claras con los Objetivos de Desarrollo Sostenible (ODS)

🎯 17 ODS 🎯 169 Metas 📊 244+ Indicadores 🤖 IA + NLP
Desarrollado por: UNFPA Colombia & Gobierno de Colombia
🌍 Análisis Territorial 📈 Visualización Interactiva
""" return html # ============================================================================ # 12. FUNCIÓN PRINCIPAL - GENERAR TODAS LAS VISUALIZACIONES # ============================================================================ def generar_todas_visualizaciones(ruta_archivo, guardar=True, formato='html'): """ Función principal que genera todas las visualizaciones. Parámetros: ----------- ruta_archivo : str Ruta al archivo markdown con los datos guardar : bool Si True, guarda las visualizaciones en archivos formato : str Formato de salida: 'html' para interactivas, 'png' para estáticas Retorna: -------- dict : Diccionario con todas las figuras generadas """ print("Cargando datos...") df = cargar_datos(ruta_archivo) print(f"Datos cargados: {len(df)} registros, {df[id_lvl].nunique()} ODS únicos") figuras = {} print("\n" + "="*70) print("GENERANDO VISUALIZACIONES") print("="*70) # Visualización 1 print("\n[1/10] Generando distribución por ODS (Box Plot)...") figuras['viz1_boxplot'] = viz_1_distribucion_por_ods(df) if guardar: figuras['viz1_boxplot'].write_html('viz1_boxplot_ods.html') # Visualización 2 print("[2/10] Generando heatmap ODS vs Ranking...") figuras['viz2_heatmap'] = viz_2_heatmap_ods_ranking(df) if guardar: figuras['viz2_heatmap'].savefig('viz2_heatmap.png', dpi=300, bbox_inches='tight') plt.close() # Visualización 3 print("[3/10] Generando scatter 3D interactivo...") figuras['viz3_scatter3d'] = viz_3_scatter_3d_interactivo(df) if guardar: figuras['viz3_scatter3d'].write_html('viz3_scatter3d.html') # Visualización 4 print("[4/10] Generando radar chart por ODS...") figuras['viz4_radar'] = viz_4_radar_chart_ods(df) if guardar: figuras['viz4_radar'].write_html('viz4_radar_ods.html') # Visualización 5 print("[5/10] Generando sunburst jerárquico...") figuras['viz5_sunburst'] = viz_5_sunburst_jerarquia(df) if guardar: figuras['viz5_sunburst'].write_html('viz5_sunburst.html') # Visualización 6 print("[6/10] Generando top indicadores por ODS...") figuras['viz6_topn'] = viz_6_top_indicadores_por_ods(df, top_n=5) if guardar: figuras['viz6_topn'].write_html('viz6_top_indicadores.html') # Visualización 7 print("[7/10] Generando stream graph...") figuras['viz7_stream'] = viz_7_streamgraph_similaridad(df) if guardar: figuras['viz7_stream'].write_html('viz7_streamgraph.html') # Visualización 8 print("[8/10] Generando violin plot...") figuras['viz8_violin'] = viz_8_violin_plot_ods(df) if guardar: figuras['viz8_violin'].write_html('viz8_violin_plot.html') # Visualización 9 print("[9/10] Generando dashboard integrado...") figuras['viz9_dashboard'] = viz_9_dashboard_metricas(df) if guardar: figuras['viz9_dashboard'].write_html('viz9_dashboard.html') # Visualización 10 print("[10/10] Generando matriz de transición...") figuras['viz10_matriz'] = viz_10_matriz_transicion(df) if guardar: figuras['viz10_matriz'].savefig('viz10_matriz_transicion.png', dpi=300, bbox_inches='tight') plt.close() print("\n" + "="*70) print("GENERACIÓN COMPLETADA") print("="*70) print(f"\nTotal de visualizaciones generadas: {len(figuras)}") if guardar: print("\nArchivos guardados:") print(" - Visualizaciones interactivas (HTML): 8 archivos") print(" - Visualizaciones estáticas (PNG): 2 archivos") return figuras, df # ============================================================================ # 13. ANÁLISIS ESTADÍSTICO COMPLEMENTARIO # ============================================================================ def analisis_estadistico(df): """ Genera estadísticas descriptivas complementarias para el análisis """ print("\n" + "="*70) print("ANÁLISIS ESTADÍSTICO COMPLEMENTARIO") print("="*70) print("\n1. ESTADÍSTICAS GLOBALES") print("-" * 70) print(f" Similaridad media: {df[score].mean():.4f}") print(f" Desviación estándar: {df[score].std():.4f}") print(f" Similaridad mínima: {df[score].min():.4f}") print(f" Similaridad máxima: {df[score].max():.4f}") print(f" Mediana: {df[score].median():.4f}") print("\n2. ESTADÍSTICAS POR ODS") print("-" * 70) stats_ods = df.groupby(id_lvl)[score].agg([ ('count', 'count'), ('mean', 'mean'), ('std', 'std'), ('min', 'min'), ('max', 'max') ]).round(4) print(stats_ods.to_string()) print("\n3. ODS MÁS REPRESENTADOS EN TOP 50") print("-" * 70) top_50_ods = df.nsmallest(50, rank)[id_lvl].value_counts() print(top_50_ods.to_string()) print("\n4. CORRELACIÓN RANK vs SIMILARIDAD") print("-" * 70) correlacion = df[rank].corr(df[score]) print(f" Correlación de Pearson: {correlacion:.4f}") print(f" Interpretación: {'Negativa fuerte' if correlacion < -0.7 else 'Negativa moderada' if correlacion < -0.4 else 'Negativa débil'}") print(f" (Esperado: correlación negativa, a mayor rank → menor similaridad)") return stats_ods # ============================================================================ # EJECUCIÓN DEL SCRIPT # ============================================================================ if __name__ == "__main__": # Configurar ruta del archivo RUTA_ARCHIVO = '/mnt/user-data/uploads/indicadores_markdown.txt' print("\n" + "="*70) print("SISTEMA DE VISUALIZACIÓN - ANÁLISIS DE SIMILARIDAD ODS") print("="*70) print("\nEste script genera 10 visualizaciones avanzadas para analizar") print("la similaridad coseno como proxy de relevancia entre una iniciativa") print("ciudadana y los indicadores ODS.") # Generar todas las visualizaciones figuras, df = generar_todas_visualizaciones( RUTA_ARCHIVO, guardar=True, formato='html' ) # Análisis estadístico stats = analisis_estadistico(df) print("\n" + "="*70) print("RECOMENDACIONES DE USO") print("="*70) print(""" 1. Use el Dashboard (viz9) como punto de partida para exploración general 2. Use el Heatmap (viz2) para identificar patrones temporales de relevancia 3. Use el Radar Chart (viz4) para comunicar el perfil ODS de la iniciativa 4. Use el Scatter 3D (viz3) para exploración detallada e interactiva 5. Use el Violin Plot (viz8) para análisis estadístico profundo 6. Use el Sunburst (viz5) para presentaciones ejecutivas 7. Use la Matriz de Transición (viz10) para análisis de consistencia NOTA: Los archivos HTML son interactivos - ábralos en un navegador """) print("\n¡Proceso completado exitosamente!")