streamlit_growth_dashboard.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486
  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. from sqlalchemy.exc import OperationalError
  11. except ImportError:
  12. st.set_page_config(page_title="Growth Segmentation Dashboard", layout="wide")
  13. st.error("请安装: pip install sqlalchemy pymysql")
  14. st.stop()
  15. st.set_page_config(page_title="Growth Segmentation Dashboard", layout="wide")
  16. # ---- MySQL config from setting.conf ----
  17. SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
  18. DEFAULT_CONF = os.path.join(SCRIPT_DIR, "setting.conf")
  19. def load_config(conf_path: str) -> configparser.ConfigParser | None:
  20. if not os.path.isfile(conf_path):
  21. return None
  22. cfg = configparser.ConfigParser(interpolation=None)
  23. cfg.read(conf_path, encoding="utf-8")
  24. return cfg
  25. def get_mysql_config(cfg: configparser.ConfigParser) -> dict:
  26. return {
  27. "host": cfg.get("mysql", "host", fallback="127.0.0.1"),
  28. "port": cfg.getint("mysql", "port", fallback=3306),
  29. "user": cfg.get("mysql", "user", fallback="root"),
  30. "password": cfg.get("mysql", "password", fallback=""),
  31. "database": cfg.get("mysql", "database", fallback="Lyrria"),
  32. "charset": cfg.get("mysql", "charset", fallback="utf8mb4"),
  33. }
  34. cfg = load_config(os.getenv("DASHBOARD_CONF", DEFAULT_CONF))
  35. if not cfg or not cfg.has_section("mysql"):
  36. st.error(f"配置文件不存在或缺少 [mysql] 节: {DEFAULT_CONF}")
  37. st.stop()
  38. mysql_kw = get_mysql_config(cfg)
  39. def _make_engine_url() -> str:
  40. user = quote_plus(mysql_kw["user"])
  41. password = quote_plus(mysql_kw["password"])
  42. host = mysql_kw["host"]
  43. port = mysql_kw["port"]
  44. database = mysql_kw["database"]
  45. charset = mysql_kw["charset"]
  46. return f"mysql+pymysql://{user}:{password}@{host}:{port}/{database}?charset={quote_plus(charset)}"
  47. @st.cache_resource
  48. def _get_engine():
  49. # 使用连接保活与周期回收,减少 MySQL 空闲断连(2013/2006)问题
  50. engine = create_engine(
  51. _make_engine_url(),
  52. pool_pre_ping=True,
  53. pool_recycle=1800,
  54. pool_size=5,
  55. max_overflow=10,
  56. pool_timeout=30,
  57. connect_args={
  58. "connect_timeout": 10,
  59. "read_timeout": 120,
  60. "write_timeout": 120,
  61. },
  62. )
  63. with engine.connect() as c:
  64. c.execute(text("SELECT 1"))
  65. return engine
  66. try:
  67. engine = _get_engine()
  68. except Exception as e:
  69. st.error(f"MySQL 连接失败: {e}")
  70. st.stop()
  71. def read_df(sql: str, params: tuple = ()) -> pd.DataFrame:
  72. try:
  73. return pd.read_sql_query(sql, engine, params=params)
  74. except OperationalError:
  75. # 连接中断时主动释放连接池并重试一次
  76. try:
  77. engine.dispose()
  78. except Exception:
  79. pass
  80. return pd.read_sql_query(sql, engine, params=params)
  81. @st.cache_data(ttl=300)
  82. def read_community_members_df() -> pd.DataFrame:
  83. """读取参与共创社区的成员列表(包含邮箱来源)"""
  84. return read_df(
  85. """
  86. SELECT
  87. u.user_id,
  88. COALESCE(NULLIF(TRIM(u.email), ''), NULLIF(TRIM(uec.email), '')) AS community_email,
  89. CASE
  90. WHEN u.email IS NOT NULL AND TRIM(u.email) <> '' THEN 'users.email'
  91. ELSE 'user_early_community.email'
  92. END AS email_source,
  93. u.phone,
  94. COALESCE(ucc.opted_out, 0) AS opted_out,
  95. uec.joined_at
  96. FROM users u
  97. LEFT JOIN user_community_choice ucc
  98. ON ucc.user_id = u.user_id
  99. LEFT JOIN user_early_community uec
  100. ON uec.user_id = u.user_id
  101. WHERE
  102. COALESCE(ucc.opted_out, 0) = 0
  103. AND (
  104. (u.email IS NOT NULL AND TRIM(u.email) <> '')
  105. OR (
  106. (u.email IS NULL OR TRIM(u.email) = '')
  107. AND u.phone IS NOT NULL AND TRIM(u.phone) <> ''
  108. AND uec.email IS NOT NULL AND TRIM(uec.email) <> ''
  109. )
  110. )
  111. ORDER BY community_email, u.user_id
  112. """
  113. )
  114. def _render_community_members_content():
  115. members_df = read_community_members_df()
  116. st.caption("规则:未退出社区,且邮箱来自 users.email 或手机号注册后在 early_community 留存邮箱。")
  117. st.metric("参与成员数", f"{len(members_df):,}")
  118. if members_df.empty:
  119. st.info("当前没有符合条件的共创社区成员。")
  120. return
  121. st.dataframe(
  122. members_df,
  123. use_container_width=True,
  124. hide_index=True,
  125. )
  126. def _apply_dialog_extra_wide_style():
  127. st.markdown(
  128. """
  129. <style>
  130. div[data-testid="stDialog"] div[role="dialog"] {
  131. width: min(96vw, 1600px) !important;
  132. max-width: min(96vw, 1600px) !important;
  133. }
  134. </style>
  135. """,
  136. unsafe_allow_html=True,
  137. )
  138. if hasattr(st, "dialog"):
  139. @st.dialog("共创社区成员", width="large")
  140. def show_community_members_dialog():
  141. _apply_dialog_extra_wide_style()
  142. _render_community_members_content()
  143. else:
  144. def show_community_members_dialog():
  145. st.subheader("共创社区成员")
  146. _render_community_members_content()
  147. st.title("Lyrria Growth Dashboard")
  148. # ---- Sidebar ----
  149. st.sidebar.header("Filters")
  150. segments = read_df("SELECT DISTINCT user_segment AS seg FROM users WHERE user_segment IS NOT NULL ORDER BY seg")["seg"].dropna().tolist()
  151. 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()
  152. segment_default = [s for s in segments if s != "internal"] if segments else []
  153. segment_sel = st.sidebar.multiselect("users.user_segment", options=segments, default=segment_default)
  154. org_sel = st.sidebar.multiselect("org_id_snapshot (school)", options=orgs, default=[])
  155. exclude_test = st.sidebar.checkbox("Exclude users.is_test_user = 1", value=True)
  156. # Date bounds
  157. max_day = pd.to_datetime(read_df("SELECT MAX(activity_date) AS d FROM user_daily_activity")["d"].iloc[0]).date()
  158. min_day = pd.to_datetime(read_df("SELECT MIN(activity_date) AS d FROM user_daily_activity")["d"].iloc[0]).date()
  159. today = date.today()
  160. # UX: 默认选择今天,允许选择今天之前所有日期(若库里最小日期晚于今天,则回退为该最小日期)
  161. target_day = st.sidebar.date_input(
  162. "Target date",
  163. value=today if today >= min_day else min_day,
  164. min_value=min_day,
  165. max_value=today if today >= min_day else min_day,
  166. )
  167. st.sidebar.markdown("---")
  168. if st.sidebar.button("查看共创社区成员", use_container_width=True):
  169. show_community_members_dialog()
  170. # ---- Load users (for universe + test filter) ----
  171. users = read_df("SELECT user_id, is_test_user, phone, email, user_segment FROM users")
  172. if exclude_test:
  173. users = users[users["is_test_user"].fillna(0).astype(int) == 0].copy()
  174. # Base eligibility: phone/email at least one non-empty.
  175. phone_non_empty = users["phone"].fillna("").astype(str).str.strip() != ""
  176. email_non_empty = users["email"].fillna("").astype(str).str.strip() != ""
  177. users = users[phone_non_empty | email_non_empty].copy()
  178. if segment_sel:
  179. users = users[users["user_segment"].isin(segment_sel)].copy()
  180. universe_users = set(users["user_id"].astype(str).tolist())
  181. # ---- Pull daily activity for trend window + recent history (for 7/30d sets) ----
  182. # We only need recent history for rolling-window sets (7/30d) and trend chart(月内).
  183. history_days = 35
  184. start_recent = target_day - timedelta(days=history_days)
  185. daily = read_df(
  186. """
  187. SELECT activity_date, user_id, active_flag, session_count, message_count,
  188. first_seen_date, user_segment_snapshot, org_id_snapshot
  189. FROM user_daily_activity
  190. WHERE activity_date >= %s AND activity_date <= %s
  191. """,
  192. (start_recent.isoformat(), target_day.isoformat()),
  193. )
  194. daily["activity_date"] = pd.to_datetime(daily["activity_date"]).dt.date
  195. daily["first_seen_date"] = pd.to_datetime(daily["first_seen_date"]).dt.date
  196. # Apply snapshot filters
  197. if org_sel:
  198. daily = daily[daily["org_id_snapshot"].isin(org_sel)]
  199. # Apply user universe filter (test users removed)
  200. daily = daily[daily["user_id"].isin(universe_users)]
  201. # Cohort base comes from users table (test/contact/segment filters).
  202. # If org filter is selected, intersect with users ever seen in that org (full history).
  203. if org_sel:
  204. org_placeholders = ",".join(["%s"] * len(org_sel))
  205. org_df = read_df(
  206. f"""
  207. SELECT DISTINCT user_id
  208. FROM user_daily_activity
  209. WHERE org_id_snapshot IN ({org_placeholders})
  210. """,
  211. tuple(org_sel),
  212. )
  213. org_users = set(org_df["user_id"].astype(str).tolist())
  214. cohort_universe = universe_users & org_users
  215. else:
  216. cohort_universe = universe_users
  217. # ---- Trend (DAU, by day) ----
  218. st.subheader("DAU trend")
  219. trend_start = target_day.replace(day=1)
  220. dau_raw = (
  221. daily[
  222. (daily["activity_date"] >= trend_start)
  223. & (daily["activity_date"] <= target_day)
  224. & (daily["active_flag"] == 1)
  225. ]
  226. .groupby("activity_date")["user_id"]
  227. .nunique()
  228. .reset_index(name="dau")
  229. )
  230. # 补齐日期轴到 target_day:无数据的日期显示 0
  231. trend_days = pd.DataFrame(
  232. {"activity_date": pd.date_range(start=trend_start, end=target_day, freq="D").date}
  233. )
  234. trend = trend_days.merge(dau_raw, on="activity_date", how="left")
  235. trend["dau"] = trend["dau"].fillna(0).astype(int)
  236. trend["day"] = trend["activity_date"].astype(str)
  237. chart = (
  238. alt.Chart(trend)
  239. .mark_line(point=True)
  240. .encode(
  241. x=alt.X("day:N", axis=alt.Axis(title="Date", labelAngle=0)),
  242. y=alt.Y("dau:Q", axis=alt.Axis(title="DAU")),
  243. tooltip=["day", "dau"],
  244. )
  245. )
  246. st.altair_chart(chart, use_container_width=True)
  247. # ---- Trend (CURR, by day from curr_daily) ----
  248. st.subheader("CURR trend")
  249. curr_start = target_day.replace(day=1)
  250. curr_df = read_df(
  251. """
  252. SELECT activity_date, prev_day_active_count, retained_count, curr_rate_pct, total_active_today
  253. FROM curr_daily
  254. WHERE activity_date >= %s AND activity_date <= %s
  255. ORDER BY activity_date
  256. """,
  257. (curr_start.isoformat(), target_day.isoformat()),
  258. )
  259. if not curr_df.empty:
  260. curr_df["activity_date"] = pd.to_datetime(curr_df["activity_date"]).dt.date
  261. # 补齐日期轴到 target_day:无数据的日期显示 0
  262. curr_days = pd.DataFrame(
  263. {"activity_date": pd.date_range(start=curr_start, end=target_day, freq="D").date}
  264. )
  265. curr_plot = curr_days.merge(curr_df, on="activity_date", how="left")
  266. for col in ["prev_day_active_count", "retained_count", "curr_rate_pct", "total_active_today"]:
  267. if col in curr_plot.columns:
  268. curr_plot[col] = curr_plot[col].fillna(0)
  269. curr_plot["day"] = curr_plot["activity_date"].astype(str)
  270. curr_chart = (
  271. alt.Chart(curr_plot)
  272. .mark_line(point=True, color="#f59e0b")
  273. .encode(
  274. x=alt.X("day:N", axis=alt.Axis(title="Date", labelAngle=0)),
  275. y=alt.Y("curr_rate_pct:Q", axis=alt.Axis(title="CURR (%)", format=".2f")),
  276. tooltip=[
  277. "day",
  278. alt.Tooltip("curr_rate_pct:Q", title="CURR (%)", format=".2f"),
  279. alt.Tooltip("prev_day_active_count:Q", title="Prev-day active"),
  280. alt.Tooltip("retained_count:Q", title="Retained today"),
  281. alt.Tooltip("total_active_today:Q", title="DAU today"),
  282. ],
  283. )
  284. )
  285. st.altair_chart(curr_chart, use_container_width=True)
  286. # ---- Segment computation on target_day ----
  287. d0 = target_day
  288. d7 = d0 - timedelta(days=7)
  289. d30 = d0 - timedelta(days=30)
  290. # Active sets (from recent daily window)
  291. active_today = set(daily.loc[(daily["activity_date"] == d0) & (daily["active_flag"] == 1), "user_id"])
  292. active_past_7 = set(daily.loc[(daily["activity_date"] < d0) & (daily["activity_date"] >= d7) & (daily["active_flag"] == 1), "user_id"])
  293. active_past_30 = set(daily.loc[(daily["activity_date"] < d0) & (daily["activity_date"] >= d30) & (daily["active_flag"] == 1), "user_id"])
  294. # --- IMPORTANT: Dormant requires "last active before today" using FULL history ---
  295. # We compute:
  296. # prev_last_active_date(user) = max(activity_date) where active_flag=1 and activity_date < target_day
  297. #
  298. # This query uses the daily table (fast) and avoids limiting to a 35-day window.
  299. # Note: For segment/org filters, we still compute last_active across all time, but only for cohort_universe users.
  300. params = [d0.isoformat()]
  301. user_filter_sql = ""
  302. if cohort_universe:
  303. # MySQL 单次 IN 变量过多时可改为临时表
  304. placeholders = ",".join(["%s"] * len(cohort_universe))
  305. user_filter_sql = f" AND user_id IN ({placeholders}) "
  306. params += list(cohort_universe)
  307. last_active_df = read_df(
  308. f"""
  309. SELECT user_id, MAX(activity_date) AS last_active_date
  310. FROM user_daily_activity
  311. WHERE active_flag = 1
  312. AND activity_date < %s
  313. {user_filter_sql}
  314. GROUP BY user_id
  315. """,
  316. tuple(params),
  317. )
  318. if not last_active_df.empty:
  319. last_active_df["last_active_date"] = pd.to_datetime(last_active_df["last_active_date"]).dt.date
  320. prev_last_active = dict(zip(last_active_df["user_id"], last_active_df["last_active_date"])) if not last_active_df.empty else {}
  321. def prev_last_active_date(uid: str):
  322. return prev_last_active.get(uid, None)
  323. # New users: use daily.first_seen_date (which you specified is users.created_at, written once and never overwritten)
  324. # Definition: first_seen_date == target_day AND active today
  325. # (If you want "registered today regardless of active", remove the active_today constraint.)
  326. first_seen_map = daily.groupby("user_id")["first_seen_date"].min().to_dict()
  327. new_users = {u for u in active_today if first_seen_map.get(u) == d0}
  328. # Current / Reactivated
  329. current_users = {u for u in active_today if u in active_past_7}
  330. reactivated_users = {u for u in active_today if (u in active_past_30) and (u not in active_past_7)}
  331. # Inactive segments
  332. at_risk_wau = {u for u in active_past_7 if u not in active_today}
  333. at_risk_mau = {u for u in active_past_30 if (u not in active_past_7) and (u not in active_today)}
  334. # Dormant: users not active today AND (never active before today OR previous last active < d0-30)
  335. dormant_users = {
  336. u for u in cohort_universe
  337. if (u not in active_today) and ((prev_last_active_date(u) is None) or (prev_last_active_date(u) < d30))
  338. }
  339. # ---- Segments visualization (Duolingo-like, no tables) ----
  340. st.subheader(f"Segments on {d0.isoformat()}")
  341. active_buckets = [
  342. ("New", len(new_users)),
  343. ("Current", len(current_users)),
  344. ("Reactivated", len(reactivated_users)),
  345. ]
  346. active_total = len(active_today)
  347. inactive_total = max(0, len(cohort_universe) - len(active_today))
  348. inactive_buckets = [
  349. ("At-risk (7d)", len(at_risk_wau)),
  350. ("At-risk (30d)", len(at_risk_mau)),
  351. ("Dormant (≥30d)", len(dormant_users)),
  352. ]
  353. def segment_card(label: str, value: int, denom: int, hint: str):
  354. pct = (value / denom) if denom > 0 else 0.0
  355. st.markdown(
  356. (
  357. "<div style='display:flex; align-items:baseline; gap:8px;'>"
  358. f"<span style='font-weight:700;'>{label}</span>"
  359. f"<span style='font-size:30px; font-weight:800;'>{value:,}</span>"
  360. "</div>"
  361. ),
  362. unsafe_allow_html=True,
  363. )
  364. st.progress(min(max(pct, 0.0), 1.0))
  365. st.caption(f"{pct:.1%} of {denom:,}. {hint}")
  366. # KPI tiles (top row)
  367. #k1, k2, k3, k4, k5 = st.columns(5)
  368. #k1.metric("DAU", len(active_today))
  369. #k2.metric("New", len(new_users))
  370. #k3.metric("Current", len(current_users))
  371. #k4.metric("Reactivated", len(reactivated_users))
  372. #k5.metric("Resurrected", len(resurrected_users))
  373. #
  374. #k6, k7, k8 = st.columns(3)
  375. #k6.metric("At-risk WAU", len(at_risk_wau))
  376. #k7.metric("At-risk MAU", len(at_risk_mau))
  377. #k8.metric("Dormant", len(dormant_users))
  378. st.markdown("")
  379. left, right = st.columns(2, gap="large")
  380. with left:
  381. t1, t2 = st.columns([3, 1], vertical_alignment="center")
  382. with t1:
  383. st.markdown("### Active today composition")
  384. with t2:
  385. st.metric("DAU", len(active_today))
  386. st.caption("How today's DAU is composed.")
  387. for label, val in active_buckets:
  388. segment_card(label, val, active_total, "Share of DAU.")
  389. with right:
  390. t1, t2 = st.columns([2, 2], vertical_alignment="center")
  391. with t1:
  392. st.markdown("### Not active today")
  393. with t2:
  394. st.metric("Inactive / Total", f"{inactive_total:,} / {len(cohort_universe):,}")
  395. st.caption("Breakdown of users who are not active today (within the current filtered cohort).")
  396. for label, val in inactive_buckets:
  397. segment_card(label, val, inactive_total, "Share of users not active today.")
  398. st.markdown("---")
  399. # ---- Drill down ----
  400. st.subheader("Drill-down (first 50 user_ids)")
  401. tab1, tab2, tab3 = st.tabs(["Active today", "At-risk", "Dormant"])
  402. with tab1:
  403. st.write(sorted(list(active_today))[:50])
  404. with tab2:
  405. st.write({
  406. "at_risk_wau": sorted(list(at_risk_wau))[:50],
  407. "at_risk_mau": sorted(list(at_risk_mau))[:50],
  408. })
  409. with tab3:
  410. st.write(sorted(list(dormant_users))[:50])
  411. #st.caption(
  412. # "Notes: Dormant/Resurrected use prev_last_active_date computed from full user_daily_activity history "
  413. # "(MAX(activity_date) where active_flag=1 and activity_date < target_day). "
  414. # "If your cohort is very large, replace the IN (...) filter with a temp table join."
  415. #)