streamlit_growth_dashboard.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329
  1. import configparser
  2. import os
  3. from urllib.parse import quote_plus
  4. from datetime import date, timedelta
  5. import altair as alt
  6. import pandas as pd
  7. import streamlit as st
  8. try:
  9. from sqlalchemy import create_engine, text
  10. except ImportError:
  11. st.set_page_config(page_title="Growth Segmentation Dashboard", layout="wide")
  12. st.error("请安装: pip install sqlalchemy pymysql")
  13. st.stop()
  14. st.set_page_config(page_title="Growth Segmentation Dashboard", layout="wide")
  15. # ---- MySQL config from setting.conf ----
  16. SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
  17. DEFAULT_CONF = os.path.join(SCRIPT_DIR, "setting.conf")
  18. def load_config(conf_path: str) -> configparser.ConfigParser | None:
  19. if not os.path.isfile(conf_path):
  20. return None
  21. cfg = configparser.ConfigParser(interpolation=None)
  22. cfg.read(conf_path, encoding="utf-8")
  23. return cfg
  24. def get_mysql_config(cfg: configparser.ConfigParser) -> dict:
  25. return {
  26. "host": cfg.get("mysql", "host", fallback="127.0.0.1"),
  27. "port": cfg.getint("mysql", "port", fallback=3306),
  28. "user": cfg.get("mysql", "user", fallback="root"),
  29. "password": cfg.get("mysql", "password", fallback=""),
  30. "database": cfg.get("mysql", "database", fallback="Lyrria"),
  31. "charset": cfg.get("mysql", "charset", fallback="utf8mb4"),
  32. }
  33. cfg = load_config(os.getenv("DASHBOARD_CONF", DEFAULT_CONF))
  34. if not cfg or not cfg.has_section("mysql"):
  35. st.error(f"配置文件不存在或缺少 [mysql] 节: {DEFAULT_CONF}")
  36. st.stop()
  37. mysql_kw = get_mysql_config(cfg)
  38. def _make_engine_url() -> str:
  39. user = quote_plus(mysql_kw["user"])
  40. password = quote_plus(mysql_kw["password"])
  41. host = mysql_kw["host"]
  42. port = mysql_kw["port"]
  43. database = mysql_kw["database"]
  44. charset = mysql_kw["charset"]
  45. return f"mysql+pymysql://{user}:{password}@{host}:{port}/{database}?charset={quote_plus(charset)}"
  46. @st.cache_resource
  47. def _get_engine():
  48. engine = create_engine(_make_engine_url())
  49. with engine.connect() as c:
  50. c.execute(text("SELECT 1"))
  51. return engine
  52. try:
  53. engine = _get_engine()
  54. except Exception as e:
  55. st.error(f"MySQL 连接失败: {e}")
  56. st.stop()
  57. def read_df(sql: str, params: tuple = ()) -> pd.DataFrame:
  58. return pd.read_sql_query(sql, engine, params=params)
  59. st.title("Lyrria Growth Dashboard")
  60. # ---- Sidebar ----
  61. st.sidebar.header("Filters")
  62. segments = read_df("SELECT DISTINCT user_segment_snapshot AS seg FROM user_daily_activity ORDER BY seg")["seg"].dropna().tolist()
  63. orgs = read_df("SELECT DISTINCT org_id_snapshot AS org FROM user_daily_activity WHERE org_id_snapshot IS NOT NULL ORDER BY org")["org"].tolist()
  64. segment_default = [s for s in segments if s != "internal"] if segments else []
  65. segment_sel = st.sidebar.multiselect("user_segment_snapshot", options=segments, default=segment_default)
  66. org_sel = st.sidebar.multiselect("org_id_snapshot (school)", options=orgs, default=[])
  67. exclude_test = st.sidebar.checkbox("Exclude users.is_test_user = 1", value=True)
  68. # Date bounds (from daily table)
  69. max_day = pd.to_datetime(read_df("SELECT MAX(activity_date) AS d FROM user_daily_activity")["d"].iloc[0]).date()
  70. min_day = pd.to_datetime(read_df("SELECT MIN(activity_date) AS d FROM user_daily_activity")["d"].iloc[0]).date()
  71. target_day = st.sidebar.date_input("Target date", value=max_day, min_value=min_day, max_value=max_day)
  72. lookback_days = st.sidebar.selectbox("Trend window (days)", [14, 30, 60, 90], index=1)
  73. # ---- Load users (for universe + test filter) ----
  74. users = read_df("SELECT user_id, is_test_user FROM users")
  75. if exclude_test:
  76. users = users[users["is_test_user"].fillna(0).astype(int) == 0].copy()
  77. universe_users = set(users["user_id"].astype(str).tolist())
  78. # ---- Pull daily activity for trend window + recent history (for 7/30d sets) ----
  79. # We only need recent for rolling-window sets & trend chart.
  80. start_recent = target_day - timedelta(days=max(lookback_days, 35))
  81. daily = read_df(
  82. """
  83. SELECT activity_date, user_id, active_flag, session_count, message_count,
  84. first_seen_date, user_segment_snapshot, org_id_snapshot
  85. FROM user_daily_activity
  86. WHERE activity_date >= %s AND activity_date <= %s
  87. """,
  88. (start_recent.isoformat(), target_day.isoformat()),
  89. )
  90. daily["activity_date"] = pd.to_datetime(daily["activity_date"]).dt.date
  91. daily["first_seen_date"] = pd.to_datetime(daily["first_seen_date"]).dt.date
  92. # Apply snapshot filters
  93. if segment_sel:
  94. daily = daily[daily["user_segment_snapshot"].isin(segment_sel)]
  95. if org_sel:
  96. daily = daily[daily["org_id_snapshot"].isin(org_sel)]
  97. # Apply user universe filter (test users removed)
  98. daily = daily[daily["user_id"].isin(universe_users)]
  99. # If segment/org filters are applied, universe becomes "users within that cohort".
  100. # We derive cohort universe from snapshots (all users who ever appeared in daily within recent window).
  101. # In production, if you need cohort-universe across all time, build a cohort-user table or widen this query.
  102. if segment_sel or org_sel:
  103. cohort_universe = set(daily["user_id"].unique().tolist())
  104. else:
  105. cohort_universe = universe_users
  106. # ---- Trend (DAU, by day) ----
  107. st.subheader("DAU trend")
  108. trend = (
  109. daily[
  110. (daily["activity_date"] >= (target_day - timedelta(days=lookback_days)))
  111. & (daily["activity_date"] <= target_day)
  112. ]
  113. .groupby("activity_date")["user_id"]
  114. .nunique()
  115. .reset_index(name="dau")
  116. )
  117. # 使用字符串日期 + Altair,控制 X 轴标签为横向
  118. trend["activity_date"] = pd.to_datetime(trend["activity_date"]).dt.date
  119. trend["day"] = trend["activity_date"].astype(str)
  120. chart = (
  121. alt.Chart(trend)
  122. .mark_line(point=True)
  123. .encode(
  124. x=alt.X("day:N", axis=alt.Axis(title="Date", labelAngle=0)),
  125. y=alt.Y("dau:Q", axis=alt.Axis(title="DAU")),
  126. tooltip=["day", "dau"],
  127. )
  128. )
  129. st.altair_chart(chart, use_container_width=True)
  130. # ---- Segment computation on target_day ----
  131. d0 = target_day
  132. d7 = d0 - timedelta(days=7)
  133. d30 = d0 - timedelta(days=30)
  134. # Active sets (from recent daily window)
  135. active_today = set(daily.loc[(daily["activity_date"] == d0) & (daily["active_flag"] == 1), "user_id"])
  136. active_past_7 = set(daily.loc[(daily["activity_date"] < d0) & (daily["activity_date"] >= d7) & (daily["active_flag"] == 1), "user_id"])
  137. active_past_30 = set(daily.loc[(daily["activity_date"] < d0) & (daily["activity_date"] >= d30) & (daily["active_flag"] == 1), "user_id"])
  138. # --- IMPORTANT: Dormant/Resurrected require "last active before today" using FULL history ---
  139. # We compute:
  140. # prev_last_active_date(user) = max(activity_date) where active_flag=1 and activity_date < target_day
  141. #
  142. # This query uses the daily table (fast) and avoids limiting to a 35-day window.
  143. # Note: For segment/org filters, we still compute last_active across all time, but only for cohort_universe users.
  144. params = [d0.isoformat()]
  145. user_filter_sql = ""
  146. if cohort_universe:
  147. # MySQL 单次 IN 变量过多时可改为临时表
  148. placeholders = ",".join(["%s"] * len(cohort_universe))
  149. user_filter_sql = f" AND user_id IN ({placeholders}) "
  150. params += list(cohort_universe)
  151. last_active_df = read_df(
  152. f"""
  153. SELECT user_id, MAX(activity_date) AS last_active_date
  154. FROM user_daily_activity
  155. WHERE active_flag = 1
  156. AND activity_date < %s
  157. {user_filter_sql}
  158. GROUP BY user_id
  159. """,
  160. tuple(params),
  161. )
  162. if not last_active_df.empty:
  163. last_active_df["last_active_date"] = pd.to_datetime(last_active_df["last_active_date"]).dt.date
  164. prev_last_active = dict(zip(last_active_df["user_id"], last_active_df["last_active_date"])) if not last_active_df.empty else {}
  165. def prev_last_active_date(uid: str):
  166. return prev_last_active.get(uid, None)
  167. # New users: use daily.first_seen_date (which you specified is users.created_at, written once and never overwritten)
  168. # Definition: first_seen_date == target_day AND active today
  169. # (If you want "registered today regardless of active", remove the active_today constraint.)
  170. first_seen_map = daily.groupby("user_id")["first_seen_date"].min().to_dict()
  171. new_users = {u for u in active_today if first_seen_map.get(u) == d0}
  172. # Current / Reactivated
  173. current_users = {u for u in active_today if u in active_past_7}
  174. reactivated_users = {u for u in active_today if (u in active_past_30) and (u not in active_past_7)}
  175. # Resurrected: active today AND previous last active < d0-30 (strictly before last 30 days)
  176. resurrected_users = {
  177. u for u in active_today
  178. if (prev_last_active_date(u) is not None) and (prev_last_active_date(u) < d30)
  179. }
  180. # Inactive segments
  181. at_risk_wau = {u for u in active_past_7 if u not in active_today}
  182. at_risk_mau = {u for u in active_past_30 if (u not in active_past_7) and (u not in active_today)}
  183. # Dormant: users not active today AND (never active before today OR previous last active < d0-30)
  184. dormant_users = {
  185. u for u in cohort_universe
  186. if (u not in active_today) and ((prev_last_active_date(u) is None) or (prev_last_active_date(u) < d30))
  187. }
  188. # ---- Segments visualization (Duolingo-like, no tables) ----
  189. st.subheader(f"Segments on {d0.isoformat()}")
  190. # Active buckets (composition of DAU). We add an "Other active" bucket as a safe remainder.
  191. other_active = active_today - new_users - current_users - reactivated_users - resurrected_users
  192. active_buckets = [
  193. ("New", len(new_users)),
  194. ("Current", len(current_users)),
  195. ("Reactivated", len(reactivated_users)),
  196. ("Resurrected", len(resurrected_users)),
  197. ("Other active", len(other_active)),
  198. ]
  199. active_total = len(active_today)
  200. inactive_total = max(0, len(cohort_universe) - len(active_today))
  201. inactive_buckets = [
  202. ("At-risk (7d)", len(at_risk_wau)),
  203. ("At-risk (30d)", len(at_risk_mau)),
  204. ("Dormant (≥30d)", len(dormant_users)),
  205. ]
  206. def segment_card(label: str, value: int, denom: int, hint: str):
  207. pct = (value / denom) if denom > 0 else 0.0
  208. st.markdown(
  209. f"**{label}**"
  210. f"<span style='font-size:30px; font-weight:800;'>{value:,}</span>",
  211. unsafe_allow_html=True,
  212. )
  213. st.progress(min(max(pct, 0.0), 1.0))
  214. st.caption(f"{pct:.1%} of {denom:,}. {hint}")
  215. # KPI tiles (top row)
  216. #k1, k2, k3, k4, k5 = st.columns(5)
  217. #k1.metric("DAU", len(active_today))
  218. #k2.metric("New", len(new_users))
  219. #k3.metric("Current", len(current_users))
  220. #k4.metric("Reactivated", len(reactivated_users))
  221. #k5.metric("Resurrected", len(resurrected_users))
  222. #
  223. #k6, k7, k8 = st.columns(3)
  224. #k6.metric("At-risk WAU", len(at_risk_wau))
  225. #k7.metric("At-risk MAU", len(at_risk_mau))
  226. #k8.metric("Dormant", len(dormant_users))
  227. st.markdown("")
  228. left, right = st.columns(2, gap="large")
  229. with left:
  230. t1, t2 = st.columns([3, 1], vertical_alignment="center")
  231. with t1:
  232. st.markdown("### Active today composition")
  233. with t2:
  234. st.metric("DAU", len(active_today))
  235. st.caption("How today's DAU is composed (buckets sum to DAU, with 'Other active' as remainder).")
  236. for label, val in active_buckets:
  237. if val == 0:
  238. continue
  239. segment_card(label, val, active_total, "Share of DAU.")
  240. with right:
  241. t1, t2 = st.columns([2, 2], vertical_alignment="center")
  242. with t1:
  243. st.markdown("### Not active today")
  244. with t2:
  245. st.metric("Inactive / Total", f"{inactive_total:,} / {len(cohort_universe):,}")
  246. st.caption("Breakdown of users who are not active today (within the current filtered cohort).")
  247. for label, val in inactive_buckets:
  248. if val == 0:
  249. continue
  250. segment_card(label, val, inactive_total, "Share of users not active today.")
  251. st.markdown("---")
  252. # ---- Drill down ----
  253. st.subheader("Drill-down (first 50 user_ids)")
  254. tab1, tab2, tab3 = st.tabs(["Active today", "At-risk", "Dormant"])
  255. with tab1:
  256. st.write(sorted(list(active_today))[:50])
  257. with tab2:
  258. st.write({
  259. "at_risk_wau": sorted(list(at_risk_wau))[:50],
  260. "at_risk_mau": sorted(list(at_risk_mau))[:50],
  261. })
  262. with tab3:
  263. st.write(sorted(list(dormant_users))[:50])
  264. #st.caption(
  265. # "Notes: Dormant/Resurrected use prev_last_active_date computed from full user_daily_activity history "
  266. # "(MAX(activity_date) where active_flag=1 and activity_date < target_day). "
  267. # "If your cohort is very large, replace the IN (...) filter with a temp table join."
  268. #)