analyze.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398
  1. """
  2. 体重数据分析 CLI
  3. =================
  4. 两个子命令:
  5. python analyze.py generate 生成 180 天合成数据(synthetic-user-001)写入 MySQL
  6. python analyze.py report 从库里读数据,输出终端报告 + 一张趋势 PNG
  7. 设计原则:
  8. - 合成数据有真实的减重轨迹 + 测量噪声 + 标签事件 + 若干异常点,
  9. 方便直接演示"数据洞察",不需要真实用户数据。
  10. - 所有 SQL 都写出来,方便面试官 review。
  11. """
  12. from __future__ import annotations
  13. import sys
  14. from dataclasses import dataclass
  15. from datetime import date, datetime, timedelta
  16. from pathlib import Path
  17. from uuid import uuid4
  18. import matplotlib
  19. matplotlib.use("Agg") # 无 GUI 环境也能出图
  20. import matplotlib.pyplot as plt
  21. # Windows 中文字体,避免图里中文变方框
  22. plt.rcParams["font.sans-serif"] = ["Microsoft YaHei", "SimHei", "DejaVu Sans"]
  23. plt.rcParams["axes.unicode_minus"] = False
  24. import numpy as np
  25. import pandas as pd
  26. import pymysql
  27. import typer
  28. from rich.console import Console
  29. from rich.table import Table
  30. app = typer.Typer(help="体重数据合成与分析 CLI", no_args_is_help=True)
  31. console = Console()
  32. # ---------------------------------------------------------------------------
  33. # 数据库连接配置(与 backend/.env 保持一致)
  34. # ---------------------------------------------------------------------------
  35. import os
  36. DB_CONFIG = dict(
  37. host=os.getenv("DB_HOST", "localhost"),
  38. port=int(os.getenv("DB_PORT", "3306")),
  39. user=os.getenv("DB_USERNAME", "analytics"),
  40. password=os.getenv("DB_PASSWORD", "123456"),
  41. database=os.getenv("DB_DATABASE", "campus_health"),
  42. charset="utf8mb4",
  43. autocommit=False,
  44. )
  45. SYNTH_USER_ID = "synthetic-user-001"
  46. SYNTH_USER_PHONE = "13900000001"
  47. SYNTH_USER_NICKNAME = "合成用户·小A"
  48. OUTPUT_DIR = Path(__file__).parent / "output"
  49. def get_conn(password: str | None = None) -> pymysql.Connection:
  50. cfg = dict(DB_CONFIG)
  51. if password:
  52. cfg["password"] = password
  53. try:
  54. return pymysql.connect(**cfg)
  55. except pymysql.err.OperationalError as e:
  56. if e.args[0] == 1045 and not password:
  57. # 密码错误或未提供,交互式问一次
  58. pwd = typer.prompt("MySQL 密码", hide_input=True)
  59. cfg["password"] = pwd
  60. return pymysql.connect(**cfg)
  61. console.print(f"[red]数据库连接失败:{e}[/red]")
  62. console.print(
  63. "请确认:1) MySQL 服务已启动 2) 已执行 backend/database/init.sql 和 schema.sql"
  64. )
  65. sys.exit(1)
  66. # ---------------------------------------------------------------------------
  67. # 数据生成器
  68. # ---------------------------------------------------------------------------
  69. @app.command()
  70. def generate(
  71. days: int = typer.Option(180, help="生成多少天的数据"),
  72. start_weight: float = typer.Option(75.0, help="初始体重 kg"),
  73. target_weight: float = typer.Option(66.0, help="目标体重 kg"),
  74. seed: int = typer.Option(42, help="随机种子,保证可复现"),
  75. ):
  76. """生成 180 天合成体重数据并写入 campus_health.weight_records"""
  77. rng = np.random.default_rng(seed)
  78. end_date = date.today()
  79. start_date = end_date - timedelta(days=days - 1)
  80. conn = get_conn()
  81. with conn.cursor() as cur:
  82. # 幂等:先清掉这个用户的旧数据
  83. cur.execute("DELETE FROM weight_record_tags WHERE weight_record_id IN (SELECT id FROM weight_records WHERE user_id=%s)", (SYNTH_USER_ID,))
  84. cur.execute("DELETE FROM weight_records WHERE user_id=%s", (SYNTH_USER_ID,))
  85. cur.execute("DELETE FROM weight_goals WHERE user_id=%s", (SYNTH_USER_ID,))
  86. cur.execute("DELETE FROM anomaly_logs WHERE user_id=%s", (SYNTH_USER_ID,))
  87. cur.execute(
  88. "INSERT INTO users (id, phone, nickname, grade) VALUES (%s,%s,%s,%s) "
  89. "ON DUPLICATE KEY UPDATE nickname=VALUES(nickname)",
  90. (SYNTH_USER_ID, SYNTH_USER_PHONE, SYNTH_USER_NICKNAME, "大三"),
  91. )
  92. # ---- 目标 ----
  93. cur.execute(
  94. "INSERT INTO weight_goals (id,user_id,target_weight,target_body_fat,target_date,"
  95. "start_weight,start_body_fat,start_date,weekly_target,status) "
  96. "VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,'active')",
  97. (
  98. str(uuid4()), SYNTH_USER_ID, target_weight, 21.0, end_date + timedelta(days=30),
  99. start_weight, 28.0, start_date, 0.5,
  100. ),
  101. )
  102. # ---- 标签事件(相对天数偏移)----
  103. tag_events = {
  104. 0: "开始运动",
  105. 30: "饮食改变",
  106. 60: "生病",
  107. 90: "假期",
  108. 120: "目标调整",
  109. 150: "压力期",
  110. }
  111. # 取系统标签 ID
  112. cur.execute("SELECT id, tag_name FROM tags WHERE user_id IS NULL")
  113. tag_id_by_name = {name: tid for tid, name in cur.fetchall()}
  114. # ---- 逐日生成 ----
  115. records = [] # (record_id, date, weight, body_fat, muscle, condition, notes)
  116. total_drop = start_weight - target_weight
  117. for i in range(days):
  118. d = start_date + timedelta(days=i)
  119. # 周末 20% 概率不称
  120. if d.weekday() >= 5 and rng.random() < 0.35:
  121. continue
  122. # 基线减重曲线:线性,但在标签事件点有折点
  123. progress = i / days
  124. baseline = start_weight - total_drop * progress
  125. # 事件扰动
  126. if 60 <= i < 67: # 生病:急性脱水,体重骤降但体脂反升
  127. baseline -= 1.8
  128. elif 90 <= i < 98: # 假期:饮食放纵,反弹
  129. baseline += 1.5
  130. elif 120 <= i < 140: # 压力期:皮质醇高,平台
  131. baseline += 0.4
  132. # 测量噪声
  133. noise = rng.normal(0, 0.25)
  134. # 测量条件:80% 早晨空腹,20% 餐后
  135. if rng.random() < 0.8:
  136. condition = "fasting"
  137. time_str = "07:45:00"
  138. condition_offset = 0.0
  139. else:
  140. condition = "after_meal"
  141. time_str = "12:30:00"
  142. condition_offset = rng.uniform(0.8, 1.4) # 餐后称重偏高
  143. weight = round(baseline + noise + condition_offset, 2)
  144. # 体脂:跟随体重下降,但生病周会反向
  145. bf_base = 28.0 - 7.0 * progress
  146. if 60 <= i < 67:
  147. bf_base += 1.5
  148. body_fat = round(np.clip(bf_base + rng.normal(0, 0.2), 5, 60), 2)
  149. # 肌肉量:缓慢上升
  150. muscle = round(26.0 + 1.2 * progress + rng.normal(0, 0.15), 2)
  151. notes = None
  152. if i in tag_events:
  153. notes = f"事件:{tag_events[i]}"
  154. records.append((str(uuid4()), d, time_str, weight, body_fat, muscle, condition, notes))
  155. cur.executemany(
  156. "INSERT INTO weight_records (id,user_id,record_date,measurement_time,weight,body_fat,muscle_mass,measurement_condition,notes) "
  157. "VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)",
  158. [(r[0], SYNTH_USER_ID, r[1], r[2], r[3], r[4], r[5], r[6], r[7]) for r in records],
  159. )
  160. # 关联标签到事件当天的记录
  161. for offset, tag_name in tag_events.items():
  162. d = start_date + timedelta(days=offset)
  163. cur.execute(
  164. "SELECT id FROM weight_records WHERE user_id=%s AND record_date=%s LIMIT 1",
  165. (SYNTH_USER_ID, d),
  166. )
  167. row = cur.fetchone()
  168. if row and tag_name in tag_id_by_name:
  169. cur.execute(
  170. "INSERT INTO weight_record_tags (id,weight_record_id,tag_id) VALUES (%s,%s,%s) "
  171. "ON DUPLICATE KEY UPDATE id=id",
  172. (str(uuid4()), row[0], tag_id_by_name[tag_name]),
  173. )
  174. # 埋一个 anomaly_log 示例
  175. cur.execute(
  176. "INSERT INTO anomaly_logs (id,user_id,anomaly_type,severity,message,detected_date) "
  177. "VALUES (%s,%s,'rapid_change','warning','近7天体重骤降2.1kg(脱水/生病期),请结合体脂与症状判断',%s)",
  178. (str(uuid4()), SYNTH_USER_ID, start_date + timedelta(days=63)),
  179. )
  180. conn.commit()
  181. conn.close()
  182. console.print(f"[green]✓ 已写入 {len(records)} 条记录,{len(tag_events)} 个标签事件[/green]")
  183. console.print(f" 日期范围:{start_date} → {end_date}")
  184. console.print(f" 体重区间:{start_weight}kg → {target_weight}kg(线性减重曲线 + 事件扰动)")
  185. # ---------------------------------------------------------------------------
  186. # 分析报告
  187. # ---------------------------------------------------------------------------
  188. def load_data() -> pd.DataFrame:
  189. sql = """
  190. SELECT wr.record_date, wr.weight, wr.body_fat, wr.muscle_mass,
  191. wr.measurement_condition, wr.notes,
  192. (SELECT GROUP_CONCAT(t.tag_name)
  193. FROM weight_record_tags wrt JOIN tags t ON t.id = wrt.tag_id
  194. WHERE wrt.weight_record_id = wr.id) AS tags
  195. FROM weight_records wr
  196. WHERE wr.user_id = %s AND wr.deleted_at IS NULL
  197. ORDER BY wr.record_date
  198. """
  199. conn = get_conn()
  200. df = pd.read_sql(sql, conn, params=[SYNTH_USER_ID])
  201. conn.close()
  202. df["record_date"] = pd.to_datetime(df["record_date"])
  203. return df
  204. def report_overview(df: pd.DataFrame) -> None:
  205. table = Table(title="总体进度", show_header=True, header_style="bold cyan")
  206. table.add_column("指标")
  207. table.add_column("数值", justify="right")
  208. current = df.iloc[-1]
  209. initial = df.iloc[0]
  210. total_change = current["weight"] - initial["weight"]
  211. days = (df["record_date"].iloc[-1] - df["record_date"].iloc[0]).days
  212. weekly_avg = total_change / days * 7
  213. # 最近 4 周趋势
  214. recent = df[df["record_date"] >= df["record_date"].max() - pd.Timedelta(days=28)]
  215. recent_weekly = (recent["weight"].iloc[-1] - recent["weight"].iloc[0]) / 4
  216. table.add_row("记录天数", str(len(df)))
  217. table.add_row("起始体重", f"{initial['weight']:.2f} kg")
  218. table.add_row("当前体重", f"{current['weight']:.2f} kg")
  219. table.add_row("累计变化", f"{total_change:+.2f} kg")
  220. table.add_row("周均变化(全程)", f"{weekly_avg:+.3f} kg/周")
  221. table.add_row("周均变化(近4周)", f"{recent_weekly:+.3f} kg/周")
  222. table.add_row("体脂变化", f"{current['body_fat'] - initial['body_fat']:+.2f} %")
  223. console.print(table)
  224. def report_condition_bias(df: pd.DataFrame) -> None:
  225. """空腹 vs 餐后的称重偏差——这是个真实且有价值的洞察"""
  226. fasting = df[df["measurement_condition"] == "fasting"]["weight"]
  227. after = df[df["measurement_condition"] == "after_meal"]["weight"]
  228. if len(fasting) < 3 or len(after) < 3:
  229. console.print("[yellow]样本不足,跳过测量偏差分析[/yellow]")
  230. return
  231. table = Table(title="测量条件偏差(餐后 vs 空腹)", show_header=True, header_style="bold magenta")
  232. table.add_column("条件")
  233. table.add_column("样本数", justify="right")
  234. table.add_column("均值 (kg)", justify="right")
  235. table.add_column("标准差", justify="right")
  236. table.add_row("空腹", str(len(fasting)), f"{fasting.mean():.2f}", f"{fasting.std():.2f}")
  237. table.add_row("餐后", str(len(after)), f"{after.mean():.2f}", f"{after.std():.2f}")
  238. table.add_row("差值", "—", f"{after.mean() - fasting.mean():+.2f}", "—")
  239. console.print(table)
  240. console.print(
  241. f"[dim]→ 餐后称重比空腹平均高 {after.mean()-fasting.mean():.2f} kg,"
  242. "跨条件比较时必须先按条件分层,否则会把'刚吃完饭'误读成'长胖了'。[/dim]"
  243. )
  244. def report_tag_impact(df: pd.DataFrame) -> None:
  245. """每个标签事件前后 7 天的周减重速度对比"""
  246. events = df[df["tags"].notna() & (df["tags"] != "")]
  247. if events.empty:
  248. return
  249. table = Table(title="标签事件影响(事件日 ±7 天的周减重速度)", show_header=True, header_style="bold green")
  250. table.add_column("标签")
  251. table.add_column("事件日")
  252. table.add_column("前7天周速 (kg/周)")
  253. table.add_column("后7天周速 (kg/周)")
  254. table.add_column("变化")
  255. for _, row in events.iterrows():
  256. tags = str(row["tags"]).split(",")
  257. for tag in tags:
  258. event_date = row["record_date"]
  259. pre = df[(df["record_date"] < event_date) & (df["record_date"] >= event_date - pd.Timedelta(days=7))]
  260. post = df[(df["record_date"] > event_date) & (df["record_date"] <= event_date + pd.Timedelta(days=7))]
  261. if len(pre) < 2 or len(post) < 2:
  262. continue
  263. pre_speed = (pre["weight"].iloc[-1] - pre["weight"].iloc[0]) / max(len(pre) - 1, 1) * 7
  264. post_speed = (post["weight"].iloc[-1] - post["weight"].iloc[0]) / max(len(post) - 1, 1) * 7
  265. delta = post_speed - pre_speed
  266. table.add_row(
  267. tag,
  268. event_date.strftime("%Y-%m-%d"),
  269. f"{pre_speed:+.2f}",
  270. f"{post_speed:+.2f}",
  271. f"{delta:+.2f}",
  272. )
  273. console.print(table)
  274. def report_anomaly(df: pd.DataFrame) -> None:
  275. """简单的异常检测:周变化 > 2kg 或 体重降但体脂升"""
  276. df = df.sort_values("record_date").reset_index(drop=True)
  277. table = Table(title="自动异常扫描", show_header=True, header_style="bold red")
  278. table.add_column("日期")
  279. table.add_column("类型")
  280. table.add_column("说明")
  281. for i in range(7, len(df)):
  282. week_ago = df.iloc[i - 7]
  283. now = df.iloc[i]
  284. dw = now["weight"] - week_ago["weight"]
  285. dbf = now["body_fat"] - week_ago["body_fat"]
  286. if abs(dw) > 2.0:
  287. table.add_row(
  288. now["record_date"].strftime("%Y-%m-%d"),
  289. "rapid_change",
  290. f"7天体重变化 {dw:+.2f} kg(阈值 ±2.0)",
  291. )
  292. if dw < -0.5 and dbf > 0.5:
  293. table.add_row(
  294. now["record_date"].strftime("%Y-%m-%d"),
  295. "body_fat_anomaly",
  296. f"体重 {dw:+.2f} kg 但体脂 {dbf:+.2f}%——疑似脱水而非减脂",
  297. )
  298. if table.row_count == 0:
  299. console.print("[green]未发现异常[/green]")
  300. else:
  301. console.print(table)
  302. def draw_chart(df: pd.DataFrame) -> Path:
  303. OUTPUT_DIR.mkdir(exist_ok=True)
  304. fig, ax = plt.subplots(figsize=(11, 5.5))
  305. fasting = df[df["measurement_condition"] == "fasting"]
  306. after = df[df["measurement_condition"] == "after_meal"]
  307. ax.scatter(after["record_date"], after["weight"], s=18, alpha=0.5, color="#f59e0b", label="餐后")
  308. ax.plot(fasting["record_date"], fasting["weight"], color="#2563eb", linewidth=1.5, label="空腹趋势")
  309. # 标签事件竖线
  310. events = df[df["tags"].notna() & (df["tags"] != "")]
  311. for _, row in events.iterrows():
  312. ax.axvline(row["record_date"], color="#9ca3af", linestyle="--", alpha=0.6)
  313. ax.text(row["record_date"], ax.get_ylim()[1], row["tags"].split(",")[0], rotation=90,
  314. fontsize=8, va="top", ha="right", color="#374151")
  315. ax.set_title("Synthetic User — Weight Trajectory")
  316. ax.set_xlabel("Date")
  317. ax.set_ylabel("Weight (kg)")
  318. ax.legend(loc="upper right")
  319. ax.grid(alpha=0.3)
  320. fig.tight_layout()
  321. out = OUTPUT_DIR / "weight_trend.png"
  322. fig.savefig(out, dpi=130)
  323. plt.close(fig)
  324. return out
  325. @app.command()
  326. def report():
  327. """从库里读数据,输出分析报告 + 趋势图"""
  328. df = load_data()
  329. if df.empty:
  330. console.print("[red]没有数据,请先运行:python analyze.py generate[/red]")
  331. raise typer.Exit(1)
  332. console.print(f"\n[bold]数据窗口:[/bold]{df['record_date'].iloc[0].date()} → {df['record_date'].iloc[-1].date()},共 {len(df)} 条\n")
  333. report_overview(df)
  334. console.print()
  335. report_condition_bias(df)
  336. console.print()
  337. report_tag_impact(df)
  338. console.print()
  339. report_anomaly(df)
  340. chart_path = draw_chart(df)
  341. console.print(f"\n[green]✓ 趋势图已保存:{chart_path}[/green]")
  342. if __name__ == "__main__":
  343. app()