streamlit_growth_dashboard_demo.py 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  1. """
  2. Growth Dashboard - Demo 版:使用本地 SQLite demo_growth.db,便于与 MySQL 版对照。
  3. 启动: streamlit run streamlit_growth_dashboard_demo.py
  4. """
  5. import os
  6. from datetime import date, timedelta
  7. import pandas as pd
  8. import streamlit as st
  9. try:
  10. from sqlalchemy import create_engine
  11. except ImportError:
  12. st.set_page_config(page_title="Growth Dashboard (Demo)", layout="wide")
  13. st.error("请安装: pip install sqlalchemy")
  14. st.stop()
  15. st.set_page_config(page_title="Growth Dashboard (Demo)", layout="wide")
  16. # ---- SQLite demo_growth.db ----
  17. SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
  18. DEFAULT_DB = os.path.join(SCRIPT_DIR, "demo_growth.db")
  19. DB_PATH = os.getenv("DEMO_DB_PATH", DEFAULT_DB)
  20. if not os.path.exists(DB_PATH):
  21. st.error(f"Demo 数据库不存在: {DB_PATH}")
  22. st.stop()
  23. # SQLite 使用 ? 占位符;SQLAlchemy 连接避免 pandas 警告
  24. @st.cache_resource
  25. def _get_engine():
  26. return create_engine(f"sqlite:///{DB_PATH}", connect_args={"check_same_thread": False})
  27. _engine = _get_engine()
  28. def read_df(sql: str, params: tuple = ()) -> pd.DataFrame:
  29. return pd.read_sql_query(sql, _engine, params=params)
  30. st.title("Lyrria Growth Dashboard (Demo)")
  31. st.sidebar.caption("数据源: SQLite · demo_growth.db")
  32. # ---- Sidebar ----
  33. st.sidebar.header("Filters")
  34. segments = read_df("SELECT DISTINCT user_segment_snapshot AS seg FROM user_daily_activity ORDER BY seg")["seg"].dropna().tolist()
  35. 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()
  36. segment_default = [s for s in segments if s != "internal"] if segments else []
  37. segment_sel = st.sidebar.multiselect("user_segment_snapshot", options=segments, default=segment_default)
  38. org_sel = st.sidebar.multiselect("org_id_snapshot (school)", options=orgs, default=[])
  39. exclude_test = st.sidebar.checkbox("Exclude users.is_test_user = 1", value=True)
  40. # Date bounds (from daily table)
  41. max_day = pd.to_datetime(read_df("SELECT MAX(activity_date) AS d FROM user_daily_activity")["d"].iloc[0]).date()
  42. min_day = pd.to_datetime(read_df("SELECT MIN(activity_date) AS d FROM user_daily_activity")["d"].iloc[0]).date()
  43. target_day = st.sidebar.date_input("Target date", value=max_day, min_value=min_day, max_value=max_day)
  44. lookback_days = st.sidebar.selectbox("Trend window (days)", [14, 30, 60, 90], index=1)
  45. # ---- Load users (for universe + test filter) ----
  46. users = read_df("SELECT user_id, is_test_user FROM users")
  47. if exclude_test:
  48. users = users[users["is_test_user"].fillna(0).astype(int) == 0].copy()
  49. universe_users = set(users["user_id"].astype(str).tolist())
  50. # ---- Pull daily activity for trend window + recent history (for 7/30d sets) ----
  51. start_recent = target_day - timedelta(days=max(lookback_days, 35))
  52. daily = read_df(
  53. """
  54. SELECT activity_date, user_id, active_flag, session_count, message_count,
  55. first_seen_date, user_segment_snapshot, org_id_snapshot
  56. FROM user_daily_activity
  57. WHERE activity_date >= ? AND activity_date <= ?
  58. """,
  59. (start_recent.isoformat(), target_day.isoformat()),
  60. )
  61. daily["activity_date"] = pd.to_datetime(daily["activity_date"]).dt.date
  62. daily["first_seen_date"] = pd.to_datetime(daily["first_seen_date"]).dt.date
  63. if segment_sel:
  64. daily = daily[daily["user_segment_snapshot"].isin(segment_sel)]
  65. if org_sel:
  66. daily = daily[daily["org_id_snapshot"].isin(org_sel)]
  67. daily = daily[daily["user_id"].isin(universe_users)]
  68. if segment_sel or org_sel:
  69. cohort_universe = set(daily["user_id"].unique().tolist())
  70. else:
  71. cohort_universe = universe_users
  72. # ---- Trend (DAU) ----
  73. st.subheader("DAU trend")
  74. trend = (
  75. daily[(daily["activity_date"] >= (target_day - timedelta(days=lookback_days))) & (daily["activity_date"] <= target_day)]
  76. .groupby("activity_date")["user_id"].nunique()
  77. .reset_index(name="dau")
  78. )
  79. trend["activity_date"] = pd.to_datetime(trend["activity_date"])
  80. st.line_chart(trend.set_index("activity_date")["dau"])
  81. # ---- Segment computation on target_day ----
  82. d0 = target_day
  83. d7 = d0 - timedelta(days=7)
  84. d30 = d0 - timedelta(days=30)
  85. active_today = set(daily.loc[(daily["activity_date"] == d0) & (daily["active_flag"] == 1), "user_id"])
  86. active_past_7 = set(daily.loc[(daily["activity_date"] < d0) & (daily["activity_date"] >= d7) & (daily["active_flag"] == 1), "user_id"])
  87. active_past_30 = set(daily.loc[(daily["activity_date"] < d0) & (daily["activity_date"] >= d30) & (daily["active_flag"] == 1), "user_id"])
  88. params = [d0.isoformat()]
  89. user_filter_sql = ""
  90. if cohort_universe:
  91. placeholders = ",".join(["?"] * len(cohort_universe))
  92. user_filter_sql = f" AND user_id IN ({placeholders}) "
  93. params += list(cohort_universe)
  94. last_active_df = read_df(
  95. f"""
  96. SELECT user_id, MAX(activity_date) AS last_active_date
  97. FROM user_daily_activity
  98. WHERE active_flag = 1
  99. AND activity_date < ?
  100. {user_filter_sql}
  101. GROUP BY user_id
  102. """,
  103. tuple(params),
  104. )
  105. if not last_active_df.empty:
  106. last_active_df["last_active_date"] = pd.to_datetime(last_active_df["last_active_date"]).dt.date
  107. prev_last_active = dict(zip(last_active_df["user_id"], last_active_df["last_active_date"])) if not last_active_df.empty else {}
  108. def prev_last_active_date(uid: str):
  109. return prev_last_active.get(uid, None)
  110. first_seen_map = daily.groupby("user_id")["first_seen_date"].min().to_dict()
  111. new_users = {u for u in active_today if first_seen_map.get(u) == d0}
  112. current_users = {u for u in active_today if u in active_past_7}
  113. reactivated_users = {u for u in active_today if (u in active_past_30) and (u not in active_past_7)}
  114. resurrected_users = {
  115. u for u in active_today
  116. if (prev_last_active_date(u) is not None) and (prev_last_active_date(u) < d30)
  117. }
  118. at_risk_wau = {u for u in active_past_7 if u not in active_today}
  119. at_risk_mau = {u for u in active_past_30 if (u not in active_past_7) and (u not in active_today)}
  120. dormant_users = {
  121. u for u in cohort_universe
  122. if (u not in active_today) and ((prev_last_active_date(u) is None) or (prev_last_active_date(u) < d30))
  123. }
  124. # ---- Segments visualization ----
  125. st.subheader(f"Segments on {d0.isoformat()}")
  126. other_active = active_today - new_users - current_users - reactivated_users - resurrected_users
  127. active_buckets = [
  128. ("New", len(new_users)),
  129. ("Current", len(current_users)),
  130. ("Reactivated", len(reactivated_users)),
  131. ("Resurrected", len(resurrected_users)),
  132. ("Other active", len(other_active)),
  133. ]
  134. active_total = len(active_today)
  135. inactive_total = max(0, len(cohort_universe) - len(active_today))
  136. inactive_buckets = [
  137. ("At-risk (7d)", len(at_risk_wau)),
  138. ("At-risk (30d)", len(at_risk_mau)),
  139. ("Dormant (≥30d)", len(dormant_users)),
  140. ]
  141. def segment_card(label: str, value: int, denom: int, hint: str):
  142. pct = (value / denom) if denom > 0 else 0.0
  143. st.markdown(
  144. f"**{label}**"
  145. f"<span style='font-size:30px; font-weight:800;'>{value:,}</span>",
  146. unsafe_allow_html=True,
  147. )
  148. st.progress(min(max(pct, 0.0), 1.0))
  149. st.caption(f"{pct:.1%} of {denom:,}. {hint}")
  150. st.markdown("")
  151. left, right = st.columns(2, gap="large")
  152. with left:
  153. t1, t2 = st.columns([3, 1], vertical_alignment="center")
  154. with t1:
  155. st.markdown("### Active today composition")
  156. with t2:
  157. st.metric("DAU", len(active_today))
  158. st.caption("How today's DAU is composed (buckets sum to DAU, with 'Other active' as remainder).")
  159. for label, val in active_buckets:
  160. if val == 0:
  161. continue
  162. segment_card(label, val, active_total, "Share of DAU.")
  163. with right:
  164. t1, t2 = st.columns([2, 2], vertical_alignment="center")
  165. with t1:
  166. st.markdown("### Not active today")
  167. with t2:
  168. st.metric("Inactive / Total", f"{inactive_total:,} / {len(cohort_universe):,}")
  169. st.caption("Breakdown of users who are not active today (within the current filtered cohort).")
  170. for label, val in inactive_buckets:
  171. if val == 0:
  172. continue
  173. segment_card(label, val, inactive_total, "Share of users not active today.")
  174. st.markdown("---")
  175. st.subheader("Drill-down (first 50 user_ids)")
  176. tab1, tab2, tab3 = st.tabs(["Active today", "At-risk", "Dormant"])
  177. with tab1:
  178. st.write(sorted(list(active_today))[:50])
  179. with tab2:
  180. st.write({
  181. "at_risk_wau": sorted(list(at_risk_wau))[:50],
  182. "at_risk_mau": sorted(list(at_risk_mau))[:50],
  183. })
  184. with tab3:
  185. st.write(sorted(list(dormant_users))[:50])