Spaces:
Sleeping
Sleeping
| # app.py | |
| # -*- coding: utf-8 -*- | |
| """ | |
| EarningALZ Information Flow Observatory | |
| A dark sci-fi Streamlit dashboard deployable on Hugging Face Spaces. | |
| It loads Parquet data directly from Hugging Face datasets and visualizes | |
| company-level, cluster-level, and signal-level information propagation. | |
| """ | |
| from __future__ import annotations | |
| import math | |
| import re | |
| from pathlib import Path | |
| from typing import Dict, Iterable, List, Tuple | |
| import numpy as np | |
| import pandas as pd | |
| import networkx as nx | |
| import plotly.graph_objects as go | |
| import streamlit as st | |
| from huggingface_hub import hf_hub_download, list_repo_files | |
| # ============================================================ | |
| # Page config and dark sci-fi CSS | |
| # ============================================================ | |
| st.set_page_config( | |
| page_title="EarningALZ Information Flow Observatory", | |
| page_icon="🛰️", | |
| layout="wide", | |
| initial_sidebar_state="expanded", | |
| ) | |
| st.markdown( | |
| """ | |
| <style> | |
| :root { | |
| --bg: #050A12; | |
| --panel: rgba(12, 20, 36, 0.92); | |
| --cyan: #00E5FF; | |
| --blue: #4CC9F0; | |
| --violet: #9D4EDD; | |
| --green: #00F5A0; | |
| --amber: #FFB703; | |
| --red: #FF5A5F; | |
| --text: #E5F3FF; | |
| --muted: #9FB3C8; | |
| } | |
| .stApp { | |
| background: | |
| radial-gradient(circle at top left, rgba(0, 229, 255, 0.11), transparent 28%), | |
| radial-gradient(circle at top right, rgba(157, 78, 221, 0.12), transparent 25%), | |
| linear-gradient(135deg, #050A12 0%, #08111F 45%, #030712 100%); | |
| color: var(--text); | |
| } | |
| .block-container { padding-top: 1.2rem; padding-bottom: 2rem; } | |
| [data-testid="stSidebar"] { | |
| background: linear-gradient(180deg, rgba(8, 17, 31, 0.98), rgba(3, 7, 18, 0.98)); | |
| border-right: 1px solid rgba(0, 229, 255, 0.22); | |
| } | |
| h1, h2, h3 { color: #E5F3FF !important; letter-spacing: 0.02em; } | |
| h1 { text-shadow: 0 0 18px rgba(0, 229, 255, 0.45); } | |
| div[data-testid="stMetric"] { | |
| background: linear-gradient(180deg, rgba(17, 24, 39, 0.92), rgba(5, 10, 18, 0.90)); | |
| border: 1px solid rgba(0, 229, 255, 0.22); | |
| border-radius: 16px; | |
| padding: 14px 16px; | |
| box-shadow: 0 0 24px rgba(0, 229, 255, 0.08); | |
| } | |
| div[data-testid="stMetricLabel"] { color: #9FB3C8 !important; } | |
| div[data-testid="stMetricValue"] { | |
| color: #00E5FF !important; | |
| text-shadow: 0 0 10px rgba(0, 229, 255, 0.38); | |
| } | |
| .dashboard-card { | |
| background: linear-gradient(180deg, rgba(17,24,39,0.92), rgba(5,10,18,0.88)); | |
| border: 1px solid rgba(0,229,255,0.20); | |
| border-radius: 18px; | |
| padding: 16px; | |
| box-shadow: 0 0 28px rgba(0,229,255,0.08); | |
| } | |
| .small-muted { color: #9FB3C8; font-size: 0.88rem; } | |
| .neon-line { | |
| height: 1px; | |
| background: linear-gradient(90deg, transparent, rgba(0,229,255,0.7), transparent); | |
| margin: 0.65rem 0 1rem 0; | |
| } | |
| </style> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| # ============================================================ | |
| # Constants | |
| # ============================================================ | |
| DEFAULT_TWO_PART_DATASET = "soysouce/earningALZ_twopart" | |
| DEFAULT_EVIDENCE_DATASET = "soysouce/earningALZ_SBERT_evidence" | |
| TWO_PART_FILES = { | |
| "outlook": "cleaned_outlook_all.parquet", | |
| "relationships": "matched_company_relationships.parquet", | |
| "cross_events": "cross_quarter_events.parquet", | |
| "same_events": "same_quarter_events.parquet", | |
| "clusters": "best_company_cluster_assignment.parquet", | |
| } | |
| EVIDENCE_FILES = { | |
| "chunks": "rag_evidence_chunks_flat_full_gpu_direct.parquet", | |
| } | |
| SIGNAL_COLORS = { | |
| "demand_outlook": "#00E5FF", | |
| "margin_outlook": "#00F5A0", | |
| "supply_outlook": "#FFB703", | |
| "inventory_outlook": "#9D4EDD", | |
| "pricing_outlook": "#FF4D9D", | |
| "capex_outlook": "#4CC9F0", | |
| } | |
| DIRECTION_COLORS = { | |
| "positive": "#00F5A0", | |
| "negative": "#FF5A5F", | |
| "mixed": "#FFB703", | |
| "neutral": "#9FB3C8", | |
| "not_mentioned": "#334155", | |
| "": "#334155", | |
| } | |
| RELATION_COLORS = { | |
| "upstream": "#FFB703", | |
| "downstream": "#00E5FF", | |
| "partner": "#9D4EDD", | |
| "parent": "#4CC9F0", | |
| "subsidiary": "#00F5A0", | |
| "competitor": "#FF4D9D", | |
| "related": "#94A3B8", | |
| "customer": "#38BDF8", | |
| "customer_group": "#38BDF8", | |
| "supplier_group": "#F59E0B", | |
| "unknown": "#64748B", | |
| } | |
| # ============================================================ | |
| # Utility functions | |
| # ============================================================ | |
| def clean_node(value) -> str: | |
| if pd.isna(value): | |
| return "" | |
| text = str(value).strip() | |
| if not text or text.lower() in {"nan", "none", "null", "0"}: | |
| return "" | |
| return text | |
| def quarter_to_index(quarter: str) -> float: | |
| match = re.match(r"^(\d{4})Q([1-4])$", str(quarter).strip()) | |
| if not match: | |
| return np.nan | |
| return int(match.group(1)) * 4 + int(match.group(2)) | |
| def sort_quarters(quarters: Iterable[str]) -> List[str]: | |
| return sorted([q for q in quarters if not pd.isna(quarter_to_index(q))], key=quarter_to_index) | |
| def normalize_bool(series: pd.Series) -> pd.Series: | |
| if series.dtype == bool: | |
| return series.fillna(False) | |
| return series.astype(str).str.lower().isin(["true", "1", "yes", "y"]) | |
| def safe_float(value, default=0.0) -> float: | |
| try: | |
| if pd.isna(value): | |
| return default | |
| return float(value) | |
| except Exception: | |
| return default | |
| def fmt_rate(value) -> str: | |
| if pd.isna(value): | |
| return "N/A" | |
| return f"{100 * float(value):.1f}%" | |
| def prefixed(prefix: str, filename: str) -> str: | |
| prefix = prefix.strip().strip("/") | |
| return f"{prefix}/{filename}" if prefix else filename | |
| # ============================================================ | |
| # Hugging Face loaders | |
| # ============================================================ | |
| def list_hf_parquets(repo_id: str, revision: str = "main") -> List[str]: | |
| files = list_repo_files(repo_id=repo_id, repo_type="dataset", revision=revision) | |
| return sorted([f for f in files if f.endswith(".parquet")]) | |
| def select_hf_file(repo_id: str, filename: str, prefix: str = "", revision: str = "main") -> str: | |
| files = list_hf_parquets(repo_id, revision) | |
| stem = Path(filename).stem | |
| candidates = [ | |
| prefixed(prefix, filename), | |
| filename, | |
| prefixed(prefix, f"data/{filename}"), | |
| prefixed(prefix, f"results/{filename}"), | |
| prefixed(prefix, f"two_part_network_prediction_analysis_hf/{filename}"), | |
| prefixed(prefix, f"two_part_network_prediction_analysis_parquet/{filename}"), | |
| prefixed(prefix, f"cluster_method_comparison_v4_hf/{filename}"), | |
| ] | |
| candidates += [f for f in files if Path(f).name == filename] | |
| candidates += [f for f in files if stem in Path(f).stem] | |
| seen = set() | |
| for candidate in candidates: | |
| if candidate in seen: | |
| continue | |
| seen.add(candidate) | |
| if candidate in files: | |
| return candidate | |
| raise FileNotFoundError(f"Could not find {filename} in {repo_id}. Available parquet files: {files[:80]}") | |
| def read_hf_parquet(repo_id: str, filename: str, prefix: str = "", revision: str = "main") -> pd.DataFrame: | |
| remote_file = select_hf_file(repo_id, filename, prefix, revision) | |
| local_path = hf_hub_download(repo_id=repo_id, filename=remote_file, repo_type="dataset", revision=revision) | |
| df = pd.read_parquet(local_path) | |
| df["_hf_dataset"] = repo_id | |
| df["_hf_file"] = remote_file | |
| return df | |
| def load_dashboard_data(two_part_dataset: str, evidence_dataset: str, prefix: str, revision: str) -> Dict[str, pd.DataFrame]: | |
| data = {} | |
| data["outlook"] = read_hf_parquet(two_part_dataset, TWO_PART_FILES["outlook"], prefix, revision) | |
| data["relationships"] = read_hf_parquet(two_part_dataset, TWO_PART_FILES["relationships"], prefix, revision) | |
| data["cross_events"] = read_hf_parquet(two_part_dataset, TWO_PART_FILES["cross_events"], prefix, revision) | |
| data["same_events"] = read_hf_parquet(two_part_dataset, TWO_PART_FILES["same_events"], prefix, revision) | |
| try: | |
| data["clusters"] = read_hf_parquet(two_part_dataset, TWO_PART_FILES["clusters"], prefix, revision) | |
| except Exception: | |
| data["clusters"] = pd.DataFrame() | |
| try: | |
| data["evidence_chunks"] = read_hf_parquet(evidence_dataset, EVIDENCE_FILES["chunks"], "", revision) | |
| except Exception: | |
| data["evidence_chunks"] = pd.DataFrame() | |
| return data | |
| # ============================================================ | |
| # Data preparation | |
| # ============================================================ | |
| def prepare_events(cross_events: pd.DataFrame, same_events: pd.DataFrame) -> pd.DataFrame: | |
| cross = cross_events.copy() | |
| same = same_events.copy() | |
| if "analysis_mode" not in cross.columns: | |
| cross["analysis_mode"] = "cross_quarter" | |
| if "analysis_mode" not in same.columns: | |
| same["analysis_mode"] = "same_quarter" | |
| events = pd.concat([cross, same], ignore_index=True, sort=False) | |
| for col in ["source_active", "target_active", "direction_match", "exact_match"]: | |
| events[col] = normalize_bool(events[col]) if col in events.columns else False | |
| for col in ["source_node", "target_node", "source_quarter", "target_quarter", "signal", "relation_group"]: | |
| events[col] = events[col].astype(str).str.strip() if col in events.columns else "" | |
| for col in ["source_direction", "target_direction", "source_label", "target_label"]: | |
| events[col] = events[col].astype(str).str.strip() if col in events.columns else "" | |
| events["success"] = events["source_active"] & events["target_active"] & events["direction_match"] | |
| events["window"] = events["source_quarter"] + "→" + events["target_quarter"] | |
| events["release_date_gap_days"] = pd.to_numeric(events.get("release_date_gap_days", np.nan), errors="coerce") | |
| return events | |
| def prepare_relationships(relationships: pd.DataFrame) -> pd.DataFrame: | |
| rel = relationships.copy() | |
| if "source_company_node" not in rel.columns and "source_node" in rel.columns: | |
| rel["source_company_node"] = rel["source_node"] | |
| if "target_company_node" not in rel.columns and "target_node" in rel.columns: | |
| rel["target_company_node"] = rel["target_node"] | |
| rel["source_company_node"] = rel["source_company_node"].map(clean_node) | |
| rel["target_company_node"] = rel["target_company_node"].map(clean_node) | |
| if "relation_group_clean" in rel.columns: | |
| rel["relation_group_view"] = rel["relation_group_clean"].astype(str).str.strip().str.lower() | |
| elif "relation_group" in rel.columns: | |
| rel["relation_group_view"] = rel["relation_group"].astype(str).str.strip().str.lower() | |
| else: | |
| rel["relation_group_view"] = "unknown" | |
| rel = rel[ | |
| rel["source_company_node"].ne("") | |
| & rel["target_company_node"].ne("") | |
| & rel["source_company_node"].ne(rel["target_company_node"]) | |
| ].copy() | |
| return rel | |
| def build_company_table(outlook: pd.DataFrame, events: pd.DataFrame) -> pd.DataFrame: | |
| frames = [] | |
| if {"company_node", "ticker", "current_company"}.issubset(outlook.columns): | |
| frames.append(outlook[["company_node", "ticker", "current_company"]].rename(columns={"current_company": "company"})) | |
| for side in ["source", "target"]: | |
| node_col = f"{side}_node" | |
| ticker_col = f"{side}_ticker" | |
| company_col = f"{side}_company" | |
| if node_col in events.columns: | |
| cols = [node_col] | |
| if ticker_col in events.columns: | |
| cols.append(ticker_col) | |
| if company_col in events.columns: | |
| cols.append(company_col) | |
| x = events[cols].rename(columns={node_col: "company_node", ticker_col: "ticker", company_col: "company"}) | |
| frames.append(x) | |
| if not frames: | |
| nodes = sorted(set(events["source_node"]) | set(events["target_node"])) | |
| return pd.DataFrame({"company_node": nodes, "ticker": nodes, "company": nodes, "display_name": nodes}) | |
| company = pd.concat(frames, ignore_index=True, sort=False).fillna("") | |
| if "ticker" not in company.columns: | |
| company["ticker"] = "" | |
| if "company" not in company.columns: | |
| company["company"] = "" | |
| company["company_node"] = company["company_node"].map(clean_node) | |
| company = company[company["company_node"].ne("")].copy() | |
| out = company.groupby("company_node", as_index=False).agg( | |
| ticker=("ticker", lambda x: next((str(v) for v in x if clean_node(v)), "")), | |
| company=("company", lambda x: next((str(v) for v in x if clean_node(v)), "")), | |
| ) | |
| out["display_name"] = np.where(out["ticker"].astype(str).str.len() > 0, out["ticker"], out["company_node"].str.replace("COMPANY::", "", regex=False)) | |
| return out | |
| def build_clusters(relationships: pd.DataFrame, clusters: pd.DataFrame, company_table: pd.DataFrame) -> pd.DataFrame: | |
| if not clusters.empty and "company_node" in clusters.columns: | |
| out = clusters.copy() | |
| if "cluster_id" not in out.columns: | |
| id_cols = [c for c in out.columns if "cluster" in c.lower() and "id" in c.lower()] | |
| out["cluster_id"] = out[id_cols[0]] if id_cols else 0 | |
| if "cluster_theme_label" not in out.columns: | |
| label_cols = [c for c in out.columns if "theme" in c.lower() or "label" in c.lower()] | |
| out["cluster_theme_label"] = out[label_cols[0]] if label_cols else "Network community " + out["cluster_id"].astype(str) | |
| return out[["company_node", "cluster_id", "cluster_theme_label"]].drop_duplicates() | |
| graph = nx.Graph() | |
| for _, row in relationships.iterrows(): | |
| s, t = row["source_company_node"], row["target_company_node"] | |
| if s and t and s != t: | |
| graph.add_edge(s, t, weight=graph.get_edge_data(s, t, default={}).get("weight", 0) + 1) | |
| if graph.number_of_nodes() == 0: | |
| return pd.DataFrame({"company_node": company_table["company_node"], "cluster_id": 0, "cluster_theme_label": "All companies"}) | |
| communities = list(nx.algorithms.community.greedy_modularity_communities(graph, weight="weight")) | |
| rows = [] | |
| for cid, nodes in enumerate(communities): | |
| for node in nodes: | |
| rows.append({"company_node": node, "cluster_id": cid, "cluster_theme_label": f"Network community {cid}"}) | |
| out = pd.DataFrame(rows) | |
| missing = sorted(set(company_table["company_node"]) - set(out["company_node"])) | |
| if missing: | |
| out = pd.concat([out, pd.DataFrame({"company_node": missing, "cluster_id": -1, "cluster_theme_label": "Unassigned"})], ignore_index=True) | |
| return out | |
| def build_leader_follower(events: pd.DataFrame) -> pd.DataFrame: | |
| active = events[events["source_active"]].copy() | |
| if active.empty: | |
| return pd.DataFrame() | |
| source = active.groupby("source_node", as_index=False).agg( | |
| outgoing_exposures=("signal", "count"), | |
| outgoing_successes=("success", "sum"), | |
| outgoing_direction_match_rate=("direction_match", "mean"), | |
| distinct_targets=("target_node", "nunique"), | |
| avg_gap_days=("release_date_gap_days", "mean"), | |
| ).rename(columns={"source_node": "company_node"}) | |
| target = active.groupby("target_node", as_index=False).agg( | |
| incoming_exposures=("signal", "count"), | |
| incoming_successes=("success", "sum"), | |
| incoming_direction_match_rate=("direction_match", "mean"), | |
| distinct_sources=("source_node", "nunique"), | |
| ).rename(columns={"target_node": "company_node"}) | |
| out = source.merge(target, on="company_node", how="outer").fillna(0) | |
| out["leader_score"] = np.log1p(out["outgoing_exposures"]) * out["outgoing_direction_match_rate"] | |
| out["follower_score"] = np.log1p(out["incoming_exposures"]) * out["incoming_direction_match_rate"] | |
| out["leader_minus_follower"] = out["leader_score"] - out["follower_score"] | |
| return out.sort_values("leader_score", ascending=False) | |
| # ============================================================ | |
| # Filtering | |
| # ============================================================ | |
| def filter_events(events: pd.DataFrame, mode: str, signal: str, directions: List[str], relations: List[str], quarters: List[str], only_successful: bool) -> pd.DataFrame: | |
| data = events.copy() | |
| if mode != "Both": | |
| data = data[data["analysis_mode"].eq(mode)] | |
| if signal != "All": | |
| data = data[data["signal"].eq(signal)] | |
| if directions and "All" not in directions: | |
| data = data[data["source_direction"].isin(directions)] | |
| if relations and "All" not in relations: | |
| data = data[data["relation_group"].isin(relations)] | |
| if quarters: | |
| qset = set(quarters) | |
| data = data[data["source_quarter"].isin(qset) | data["target_quarter"].isin(qset)] | |
| if only_successful: | |
| data = data[data["success"]] | |
| return data.copy() | |
| def ego_nodes_from_events(events: pd.DataFrame, center_node: str, depth: int, max_nodes: int = 250) -> set[str]: | |
| graph = nx.Graph() | |
| for _, row in events.iterrows(): | |
| s = clean_node(row.get("source_node", "")) | |
| t = clean_node(row.get("target_node", "")) | |
| if s and t and s != t: | |
| graph.add_edge(s, t) | |
| if center_node not in graph: | |
| return {center_node} | |
| distances = nx.single_source_shortest_path_length(graph, center_node, cutoff=depth) | |
| ordered = sorted(distances.keys(), key=lambda n: (distances[n], n)) | |
| return set(ordered[:max_nodes]) | |
| def nodes_from_cluster(cluster_df: pd.DataFrame, cluster_label: str, max_nodes: int = 350) -> set[str]: | |
| if cluster_label == "All": | |
| return set(cluster_df["company_node"].head(max_nodes)) | |
| data = cluster_df[cluster_df["cluster_theme_label"].eq(cluster_label)] | |
| return set(data["company_node"].head(max_nodes)) | |
| # ============================================================ | |
| # Network construction and plotting | |
| # ============================================================ | |
| def build_edge_summary(events: pd.DataFrame) -> pd.DataFrame: | |
| if events.empty: | |
| return pd.DataFrame() | |
| group_cols = ["source_node", "target_node", "signal", "relation_group", "source_direction"] | |
| summary = events.groupby(group_cols, dropna=False).agg( | |
| event_count=("signal", "count"), | |
| success_count=("success", "sum"), | |
| target_active_rate=("target_active", "mean"), | |
| direction_match_rate=("direction_match", "mean"), | |
| avg_gap_days=("release_date_gap_days", "mean"), | |
| first_source_quarter=("source_quarter", "min"), | |
| last_target_quarter=("target_quarter", "max"), | |
| ).reset_index() | |
| summary["success_rate"] = summary["success_count"] / summary["event_count"].replace(0, np.nan) | |
| return summary.sort_values(["success_count", "event_count"], ascending=[False, False]) | |
| def compute_layout(edge_summary_records: list, seed: int = 42) -> Dict[str, Tuple[float, float]]: | |
| edge_summary = pd.DataFrame(edge_summary_records) | |
| graph = nx.Graph() | |
| for _, row in edge_summary.iterrows(): | |
| graph.add_edge(row["source_node"], row["target_node"], weight=max(1.0, safe_float(row.get("event_count", 1)))) | |
| if graph.number_of_nodes() == 0: | |
| return {} | |
| iterations = 80 if graph.number_of_nodes() <= 600 else 35 | |
| pos = nx.spring_layout(graph, seed=seed, iterations=iterations, weight="weight") | |
| return {node: (float(x), float(y)) for node, (x, y) in pos.items()} | |
| def build_network_figure(edge_summary: pd.DataFrame, company_table: pd.DataFrame, cluster_table: pd.DataFrame, leader_table: pd.DataFrame, animate: bool, selected_signal: str, max_edges: int = 600) -> go.Figure: | |
| if edge_summary.empty: | |
| fig = go.Figure() | |
| fig.update_layout(template="plotly_dark", paper_bgcolor="#050A12", plot_bgcolor="#050A12", title="No network events match the current filters") | |
| return fig | |
| edge_summary = edge_summary.head(max_edges).copy() | |
| layout = compute_layout(edge_summary.to_dict(orient="records")) | |
| nodes = sorted(set(edge_summary["source_node"]) | set(edge_summary["target_node"])) | |
| company_map = company_table.set_index("company_node").to_dict(orient="index") if not company_table.empty else {} | |
| cluster_map = cluster_table.set_index("company_node").to_dict(orient="index") if not cluster_table.empty else {} | |
| leader_map = leader_table.set_index("company_node").to_dict(orient="index") if not leader_table.empty else {} | |
| traces = [] | |
| for relation, group in edge_summary.groupby("relation_group", dropna=False): | |
| x_lines, y_lines = [], [] | |
| for _, row in group.iterrows(): | |
| s, t = row["source_node"], row["target_node"] | |
| if s not in layout or t not in layout: | |
| continue | |
| x0, y0 = layout[s] | |
| x1, y1 = layout[t] | |
| x_lines += [x0, x1, None] | |
| y_lines += [y0, y1, None] | |
| traces.append(go.Scatter(x=x_lines, y=y_lines, mode="lines", line=dict(width=1.2, color=RELATION_COLORS.get(str(relation), "#64748B")), opacity=0.36, hoverinfo="skip", name=f"edge: {relation}")) | |
| arrow_x, arrow_y, arrow_text, arrow_color, arrow_size = [], [], [], [], [] | |
| for _, row in edge_summary.iterrows(): | |
| s, t = row["source_node"], row["target_node"] | |
| if s not in layout or t not in layout: | |
| continue | |
| x0, y0 = layout[s] | |
| x1, y1 = layout[t] | |
| arrow_x.append(0.68 * x0 + 0.32 * x1) | |
| arrow_y.append(0.68 * y0 + 0.32 * y1) | |
| signal = row.get("signal", selected_signal) | |
| direction = row.get("source_direction", "") | |
| arrow_color.append(SIGNAL_COLORS.get(signal, DIRECTION_COLORS.get(direction, "#00E5FF"))) | |
| arrow_size.append(8 + 10 * min(1.0, safe_float(row.get("success_rate", 0)))) | |
| arrow_text.append( | |
| f"<b>{s}</b> → <b>{t}</b><br>signal: {signal}<br>direction: {direction}<br>relation: {row.get('relation_group', '')}<br>events: {int(row.get('event_count', 0))}<br>success rate: {fmt_rate(row.get('success_rate', np.nan))}<br>avg gap days: {safe_float(row.get('avg_gap_days', np.nan), np.nan):.1f}" | |
| ) | |
| traces.append(go.Scatter(x=arrow_x, y=arrow_y, mode="markers", marker=dict(size=arrow_size, color=arrow_color, symbol="triangle-right", line=dict(width=0.6, color="#E5F3FF"), opacity=0.88), text=arrow_text, hoverinfo="text", name="flow direction")) | |
| node_x, node_y, node_text, node_size, node_color = [], [], [], [], [] | |
| palette = ["#00E5FF", "#9D4EDD", "#00F5A0", "#FFB703", "#FF4D9D", "#4CC9F0", "#F97316", "#A3E635"] | |
| for node in nodes: | |
| if node not in layout: | |
| continue | |
| x, y = layout[node] | |
| node_x.append(x) | |
| node_y.append(y) | |
| company_info = company_map.get(node, {}) | |
| cluster_info = cluster_map.get(node, {}) | |
| leader_info = leader_map.get(node, {}) | |
| display_name = company_info.get("display_name", node.replace("COMPANY::", "")) | |
| cluster_label = cluster_info.get("cluster_theme_label", "Unknown community") | |
| visible_degree = int((edge_summary["source_node"].eq(node)).sum() + (edge_summary["target_node"].eq(node)).sum()) | |
| leader_score = safe_float(leader_info.get("leader_score", 0)) | |
| follower_score = safe_float(leader_info.get("follower_score", 0)) | |
| node_text.append(f"<b>{display_name}</b><br>node: {node}<br>cluster: {cluster_label}<br>visible degree: {visible_degree}<br>leader score: {leader_score:.3f}<br>follower score: {follower_score:.3f}") | |
| node_size.append(12 + 4 * math.log1p(visible_degree) + 2 * math.log1p(max(leader_score, follower_score))) | |
| cid = int(safe_float(cluster_info.get("cluster_id", 0), 0)) | |
| node_color.append(palette[cid % len(palette)]) | |
| traces.append(go.Scatter(x=node_x, y=node_y, mode="markers", marker=dict(size=node_size, color=node_color, opacity=0.94, line=dict(width=1.4, color="#E5F3FF")), text=node_text, hoverinfo="text", name="companies")) | |
| frames = [] | |
| if animate: | |
| animated_edges = edge_summary.head(min(120, len(edge_summary))).copy() | |
| steps = 22 | |
| for step in range(steps): | |
| tval = step / max(1, steps - 1) | |
| px, py, ptext, pcolor, psize = [], [], [], [], [] | |
| for _, row in animated_edges.iterrows(): | |
| s, t = row["source_node"], row["target_node"] | |
| if s not in layout or t not in layout: | |
| continue | |
| x0, y0 = layout[s] | |
| x1, y1 = layout[t] | |
| tt = (tval + 0.07 * (hash(s + t) % 10)) % 1.0 | |
| px.append((1 - tt) * x0 + tt * x1) | |
| py.append((1 - tt) * y0 + tt * y1) | |
| pcolor.append(SIGNAL_COLORS.get(row.get("signal", selected_signal), "#00E5FF")) | |
| psize.append(8 + 12 * min(1.0, safe_float(row.get("success_rate", 0)))) | |
| ptext.append(f"Pulse: {s} → {t}<br>signal: {row.get('signal', '')}<br>relation: {row.get('relation_group', '')}<br>window: {row.get('first_source_quarter', '')} → {row.get('last_target_quarter', '')}") | |
| frames.append(go.Frame(data=[go.Scatter(x=px, y=py, mode="markers", marker=dict(size=psize, color=pcolor, opacity=0.96, symbol="circle", line=dict(width=1.0, color="#FFFFFF")), text=ptext, hoverinfo="text", name="diffusion pulse")], name=f"frame_{step}")) | |
| if frames: | |
| traces.append(frames[0].data[0]) | |
| updatemenus = [] | |
| if animate and frames: | |
| updatemenus = [dict(type="buttons", showactive=False, x=0.02, y=1.06, xanchor="left", yanchor="top", buttons=[ | |
| dict(label="▶ Play diffusion", method="animate", args=[None, {"frame": {"duration": 150, "redraw": True}, "fromcurrent": True, "transition": {"duration": 40}}]), | |
| dict(label="⏸ Pause", method="animate", args=[[None], {"frame": {"duration": 0, "redraw": False}, "mode": "immediate", "transition": {"duration": 0}}]), | |
| ])] | |
| fig = go.Figure(data=traces, frames=frames) | |
| fig.update_layout(template="plotly_dark", paper_bgcolor="#050A12", plot_bgcolor="#050A12", height=760, margin=dict(l=10, r=10, t=50, b=10), title=dict(text="Information Flow Network", font=dict(size=22, color="#E5F3FF")), showlegend=True, legend=dict(orientation="h", yanchor="bottom", y=1.01, xanchor="right", x=1, bgcolor="rgba(5,10,18,0.35)", font=dict(color="#CFE9FF", size=10)), xaxis=dict(visible=False), yaxis=dict(visible=False), updatemenus=updatemenus) | |
| return fig | |
| # ============================================================ | |
| # Charts | |
| # ============================================================ | |
| def make_timeline_chart(events: pd.DataFrame) -> go.Figure: | |
| active = events[events["source_active"]].copy() | |
| if active.empty: | |
| return go.Figure() | |
| summary = active.groupby(["source_quarter", "signal"], as_index=False).agg(event_count=("signal", "count"), success_rate=("success", "mean")) | |
| summary["quarter_index"] = summary["source_quarter"].map(quarter_to_index) | |
| summary = summary.sort_values("quarter_index") | |
| fig = go.Figure() | |
| for signal, group in summary.groupby("signal"): | |
| fig.add_trace(go.Scatter(x=group["source_quarter"], y=group["success_rate"], mode="lines+markers", name=signal, line=dict(color=SIGNAL_COLORS.get(signal, "#00E5FF"), width=2.5), marker=dict(size=np.clip(np.log1p(group["event_count"]) * 2.5, 6, 20)), customdata=group["event_count"], hovertemplate="quarter=%{x}<br>success rate=%{y:.3f}<br>events=%{customdata}<extra></extra>")) | |
| fig.update_layout(template="plotly_dark", paper_bgcolor="#050A12", plot_bgcolor="#050A12", height=360, title="Signal Propagation Timeline", yaxis_title="success rate", xaxis_title="source quarter", legend=dict(orientation="h", y=1.08)) | |
| return fig | |
| def make_relation_bar(events: pd.DataFrame) -> go.Figure: | |
| active = events[events["source_active"]].copy() | |
| if active.empty: | |
| return go.Figure() | |
| summary = active.groupby("relation_group", as_index=False).agg(event_count=("signal", "count"), success_rate=("success", "mean")).sort_values("success_rate", ascending=True).tail(20) | |
| fig = go.Figure(go.Bar(x=summary["success_rate"], y=summary["relation_group"], orientation="h", marker=dict(color="#00E5FF", line=dict(color="#E5F3FF", width=0.5)), text=summary["event_count"], hovertemplate="relation=%{y}<br>success rate=%{x:.3f}<br>events=%{text}<extra></extra>")) | |
| fig.update_layout(template="plotly_dark", paper_bgcolor="#050A12", plot_bgcolor="#050A12", height=360, title="Propagation Strength by Relationship Type", xaxis_title="success rate", yaxis_title="") | |
| return fig | |
| def find_evidence_rows(evidence_chunks: pd.DataFrame, chunk_ids: str, max_rows: int = 12) -> pd.DataFrame: | |
| if evidence_chunks.empty or not chunk_ids: | |
| return pd.DataFrame() | |
| ids = [x.strip() for x in re.split(r"[|,;]", str(chunk_ids)) if x.strip()] | |
| if not ids: | |
| return pd.DataFrame() | |
| possible_cols = [c for c in ["chunk_id", "chunk_uid", "evidence_id"] if c in evidence_chunks.columns] | |
| if not possible_cols: | |
| return pd.DataFrame() | |
| mask = pd.Series(False, index=evidence_chunks.index) | |
| for col in possible_cols: | |
| mask |= evidence_chunks[col].astype(str).isin(ids) | |
| cols = [c for c in ["doc_id", "ticker", "current_company", "quarter", "chunk_id", "chunk_uid", "query_group", "rank", "hybrid_score", "text", "chunk_text"] if c in evidence_chunks.columns] | |
| return evidence_chunks.loc[mask, cols].head(max_rows).copy() | |
| # ============================================================ | |
| # App layout | |
| # ============================================================ | |
| st.markdown(""" | |
| # 🛰️ EarningALZ Information Flow Observatory | |
| <div class="small-muted">Dark sci-fi dashboard for exploring earnings-call signal propagation across company networks.</div> | |
| <div class="neon-line"></div> | |
| """, unsafe_allow_html=True) | |
| with st.sidebar: | |
| st.header("Data Source") | |
| two_part_dataset = st.text_input("Two-part HF dataset", value=DEFAULT_TWO_PART_DATASET) | |
| evidence_dataset = st.text_input("Evidence HF dataset", value=DEFAULT_EVIDENCE_DATASET) | |
| two_part_prefix = st.text_input("Optional two-part prefix", value="") | |
| revision = st.text_input("HF revision", value="main") | |
| with st.spinner("Loading Hugging Face parquet data..."): | |
| raw = load_dashboard_data(two_part_dataset, evidence_dataset, two_part_prefix, revision) | |
| events = prepare_events(raw["cross_events"], raw["same_events"]) | |
| relationships = prepare_relationships(raw["relationships"]) | |
| company_table = build_company_table(raw["outlook"], events) | |
| cluster_table = build_clusters(relationships, raw["clusters"], company_table) | |
| leader_table = build_leader_follower(events) | |
| events = events.merge(cluster_table.rename(columns={"company_node": "source_node", "cluster_id": "source_cluster_id", "cluster_theme_label": "source_cluster_theme_label"}), on="source_node", how="left") | |
| events = events.merge(cluster_table.rename(columns={"company_node": "target_node", "cluster_id": "target_cluster_id", "cluster_theme_label": "target_cluster_theme_label"}), on="target_node", how="left") | |
| st.markdown("---") | |
| st.header("Filters") | |
| mode_options = ["Both"] + sorted(events["analysis_mode"].dropna().unique().tolist()) | |
| selected_mode = st.selectbox("Propagation mode", mode_options, index=0) | |
| signal_options = ["All"] + sorted(events["signal"].dropna().unique().tolist()) | |
| selected_signal = st.selectbox("Signal", signal_options, index=signal_options.index("demand_outlook") if "demand_outlook" in signal_options else 0) | |
| direction_options = ["All"] + sorted([x for x in events["source_direction"].dropna().unique().tolist() if x]) | |
| selected_directions = st.multiselect("Source direction", direction_options, default=["All"]) | |
| relation_options = ["All"] + sorted([x for x in events["relation_group"].dropna().unique().tolist() if x]) | |
| selected_relations = st.multiselect("Relationship type", relation_options, default=["All"]) | |
| quarter_options = sort_quarters(set(events["source_quarter"]) | set(events["target_quarter"])) | |
| if quarter_options: | |
| start_q, end_q = st.select_slider("Quarter range", options=quarter_options, value=(quarter_options[0], quarter_options[-1])) | |
| selected_quarters = [q for q in quarter_options if quarter_to_index(start_q) <= quarter_to_index(q) <= quarter_to_index(end_q)] | |
| else: | |
| selected_quarters = [] | |
| only_successful = st.checkbox("Show only successful same-direction events", value=False) | |
| st.markdown("---") | |
| st.header("Focus") | |
| focus_mode = st.radio("Focus mode", ["Whole network", "Company", "Cluster"], horizontal=False) | |
| selected_company_node = None | |
| selected_cluster = "All" | |
| hop_depth = 1 | |
| if focus_mode == "Company": | |
| company_options = company_table.sort_values("display_name") | |
| name_to_node = dict(zip(company_options["display_name"], company_options["company_node"])) | |
| company_name = st.selectbox("Company", list(name_to_node.keys())) | |
| selected_company_node = name_to_node[company_name] | |
| hop_depth = st.slider("Hop depth", 1, 3, 2) | |
| elif focus_mode == "Cluster": | |
| cluster_options = ["All"] + sorted(cluster_table["cluster_theme_label"].dropna().unique().tolist()) | |
| selected_cluster = st.selectbox("Cluster / community", cluster_options) | |
| st.markdown("---") | |
| st.header("Display") | |
| max_edges = st.slider("Maximum visible edges", 50, 1200, 500, 50) | |
| animate_flow = st.checkbox("Enable animated diffusion pulses", value=True) | |
| show_evidence = st.checkbox("Enable evidence inspector", value=True) | |
| base_filtered = filter_events(events, selected_mode, selected_signal, selected_directions, selected_relations, selected_quarters, only_successful) | |
| if focus_mode == "Company" and selected_company_node: | |
| focus_nodes = ego_nodes_from_events(base_filtered, selected_company_node, hop_depth) | |
| filtered_events = base_filtered[base_filtered["source_node"].isin(focus_nodes) & base_filtered["target_node"].isin(focus_nodes)].copy() | |
| elif focus_mode == "Cluster": | |
| focus_nodes = nodes_from_cluster(cluster_table, selected_cluster) | |
| filtered_events = base_filtered[base_filtered["source_node"].isin(focus_nodes) | base_filtered["target_node"].isin(focus_nodes)].copy() | |
| else: | |
| filtered_events = base_filtered.copy() | |
| edge_summary = build_edge_summary(filtered_events) | |
| active_events = filtered_events[filtered_events["source_active"]].copy() | |
| success_rate = float(active_events["success"].mean()) if not active_events.empty else np.nan | |
| target_active_rate = float(active_events["target_active"].mean()) if not active_events.empty else np.nan | |
| visible_nodes = set(edge_summary["source_node"]) | set(edge_summary["target_node"]) if not edge_summary.empty else set() | |
| k1, k2, k3, k4, k5 = st.columns(5) | |
| k1.metric("Visible companies", f"{len(visible_nodes):,}") | |
| k2.metric("Visible edges", f"{len(edge_summary):,}") | |
| k3.metric("Source-active events", f"{len(active_events):,}") | |
| k4.metric("Target active rate", fmt_rate(target_active_rate)) | |
| k5.metric("Success rate", fmt_rate(success_rate)) | |
| st.markdown("<div class='neon-line'></div>", unsafe_allow_html=True) | |
| tab_network, tab_timeline, tab_leaders, tab_events, tab_evidence = st.tabs(["🌐 Network Flow", "⏱️ Timeline", "🧭 Leaders", "📡 Events", "🔎 Evidence"]) | |
| with tab_network: | |
| left, right = st.columns([3.2, 1.0]) | |
| with left: | |
| fig = build_network_figure(edge_summary, company_table, cluster_table, leader_table, animate_flow, selected_signal, max_edges=max_edges) | |
| st.plotly_chart(fig, use_container_width=True) | |
| with right: | |
| st.markdown("### Top visible edges") | |
| if not edge_summary.empty: | |
| cols = ["source_node", "target_node", "signal", "relation_group", "source_direction", "event_count", "success_rate", "avg_gap_days"] | |
| st.dataframe(edge_summary[[c for c in cols if c in edge_summary.columns]].head(25), use_container_width=True, height=610) | |
| else: | |
| st.info("No edges match the current filters.") | |
| with tab_timeline: | |
| c1, c2 = st.columns([1.3, 1.0]) | |
| with c1: | |
| st.plotly_chart(make_timeline_chart(filtered_events), use_container_width=True) | |
| with c2: | |
| st.plotly_chart(make_relation_bar(filtered_events), use_container_width=True) | |
| st.markdown("### Quarter-window summary") | |
| if not active_events.empty: | |
| summary = active_events.groupby(["window", "signal"], as_index=False).agg(event_count=("signal", "count"), success_rate=("success", "mean"), target_active_rate=("target_active", "mean"), direction_match_rate=("direction_match", "mean")) | |
| st.dataframe(summary, use_container_width=True, height=420) | |
| else: | |
| st.info("No active events match the current filters.") | |
| with tab_leaders: | |
| merged = leader_table.merge(company_table, on="company_node", how="left").merge(cluster_table, on="company_node", how="left") | |
| if focus_mode == "Cluster" and selected_cluster != "All": | |
| merged = merged[merged["cluster_theme_label"].eq(selected_cluster)] | |
| elif focus_mode == "Company" and selected_company_node: | |
| focus_nodes = ego_nodes_from_events(base_filtered, selected_company_node, hop_depth) | |
| merged = merged[merged["company_node"].isin(focus_nodes)] | |
| l1, l2 = st.columns(2) | |
| with l1: | |
| st.markdown("### Top information leaders") | |
| cols = ["display_name", "company_node", "cluster_theme_label", "leader_score", "outgoing_exposures", "outgoing_successes", "outgoing_direction_match_rate", "distinct_targets"] | |
| st.dataframe(merged.sort_values("leader_score", ascending=False)[[c for c in cols if c in merged.columns]].head(30), use_container_width=True, height=560) | |
| with l2: | |
| st.markdown("### Top followers") | |
| cols = ["display_name", "company_node", "cluster_theme_label", "follower_score", "incoming_exposures", "incoming_successes", "incoming_direction_match_rate", "distinct_sources"] | |
| st.dataframe(merged.sort_values("follower_score", ascending=False)[[c for c in cols if c in merged.columns]].head(30), use_container_width=True, height=560) | |
| with tab_events: | |
| st.markdown("### Filtered propagation events") | |
| event_cols = ["analysis_mode", "source_quarter", "target_quarter", "source_node", "target_node", "signal", "source_direction", "target_direction", "source_label", "target_label", "relation_group", "success", "release_date_gap_days", "source_doc_ids", "target_doc_ids", "relationship_doc_id", "source_signal_raw_values", "target_signal_raw_values", "source_signal_mapping_values", "target_signal_mapping_values"] | |
| st.dataframe(filtered_events[[c for c in event_cols if c in filtered_events.columns]].head(2000), use_container_width=True, height=650) | |
| with tab_evidence: | |
| if not show_evidence: | |
| st.info("Evidence inspector is disabled in the sidebar.") | |
| elif filtered_events.empty: | |
| st.info("No events available for evidence inspection.") | |
| else: | |
| st.markdown("### Evidence inspector") | |
| selectable = filtered_events.copy().reset_index(drop=True) | |
| selectable["event_label"] = selectable.index.astype(str) + " | " + selectable["source_node"].astype(str).str.replace("COMPANY::", "", regex=False) + " → " + selectable["target_node"].astype(str).str.replace("COMPANY::", "", regex=False) + " | " + selectable["signal"].astype(str) + " | " + selectable["source_quarter"].astype(str) + "→" + selectable["target_quarter"].astype(str) | |
| selected_event_label = st.selectbox("Select event", selectable["event_label"].head(5000).tolist()) | |
| selected_index = int(selected_event_label.split(" | ")[0]) | |
| row = selectable.iloc[selected_index] | |
| c1, c2, c3 = st.columns(3) | |
| c1.metric("Source", row.get("source_node", "")) | |
| c2.metric("Target", row.get("target_node", "")) | |
| c3.metric("Signal", row.get("signal", "")) | |
| evidence_fields = ["source_doc_ids", "target_doc_ids", "relationship_doc_id", "source_outlook_row_ids", "target_outlook_row_ids", "relationship_row_id", "source_outlook_chunk_ids", "target_outlook_chunk_ids", "relationship_chunk_id", "source_signal_raw_values", "target_signal_raw_values", "source_signal_mapping_values", "target_signal_mapping_values"] | |
| evidence_fields = [c for c in evidence_fields if c in selectable.columns] | |
| st.markdown("#### Evidence-chain fields") | |
| st.json({field: str(row.get(field, "")) for field in evidence_fields}) | |
| chunk_parts = [] | |
| for field in ["source_outlook_chunk_ids", "target_outlook_chunk_ids", "relationship_chunk_id"]: | |
| if field in selectable.columns: | |
| chunk_parts.append(str(row.get(field, ""))) | |
| evidence_rows = find_evidence_rows(raw["evidence_chunks"], "|".join(chunk_parts)) | |
| if not evidence_rows.empty: | |
| st.markdown("#### Retrieved evidence chunks") | |
| st.dataframe(evidence_rows, use_container_width=True, height=520) | |
| else: | |
| st.info("No matching evidence chunk rows were found. This may happen if chunk IDs are not available in the selected event data.") | |
| st.markdown("<div class='neon-line'></div>", unsafe_allow_html=True) | |
| st.caption("This dashboard visualizes transcript-derived signal alignment and candidate information propagation, not confirmed causal transmission.") | |