""" 体重数据分析 CLI ================= 两个子命令: python analyze.py generate 生成 180 天合成数据(synthetic-user-001)写入 MySQL python analyze.py report 从库里读数据,输出终端报告 + 一张趋势 PNG 设计原则: - 合成数据有真实的减重轨迹 + 测量噪声 + 标签事件 + 若干异常点, 方便直接演示"数据洞察",不需要真实用户数据。 - 所有 SQL 都写出来,方便面试官 review。 """ from __future__ import annotations import sys from dataclasses import dataclass from datetime import date, datetime, timedelta from pathlib import Path from uuid import uuid4 import matplotlib matplotlib.use("Agg") # 无 GUI 环境也能出图 import matplotlib.pyplot as plt # Windows 中文字体,避免图里中文变方框 plt.rcParams["font.sans-serif"] = ["Microsoft YaHei", "SimHei", "DejaVu Sans"] plt.rcParams["axes.unicode_minus"] = False import numpy as np import pandas as pd import pymysql import typer from rich.console import Console from rich.table import Table app = typer.Typer(help="体重数据合成与分析 CLI", no_args_is_help=True) console = Console() # --------------------------------------------------------------------------- # 数据库连接配置(与 backend/.env 保持一致) # --------------------------------------------------------------------------- import os DB_CONFIG = dict( host=os.getenv("DB_HOST", "localhost"), port=int(os.getenv("DB_PORT", "3306")), user=os.getenv("DB_USERNAME", "analytics"), password=os.getenv("DB_PASSWORD", "123456"), database=os.getenv("DB_DATABASE", "campus_health"), charset="utf8mb4", autocommit=False, ) SYNTH_USER_ID = "synthetic-user-001" SYNTH_USER_PHONE = "13900000001" SYNTH_USER_NICKNAME = "合成用户·小A" OUTPUT_DIR = Path(__file__).parent / "output" def get_conn(password: str | None = None) -> pymysql.Connection: cfg = dict(DB_CONFIG) if password: cfg["password"] = password try: return pymysql.connect(**cfg) except pymysql.err.OperationalError as e: if e.args[0] == 1045 and not password: # 密码错误或未提供,交互式问一次 pwd = typer.prompt("MySQL 密码", hide_input=True) cfg["password"] = pwd return pymysql.connect(**cfg) console.print(f"[red]数据库连接失败:{e}[/red]") console.print( "请确认:1) MySQL 服务已启动 2) 已执行 backend/database/init.sql 和 schema.sql" ) sys.exit(1) # --------------------------------------------------------------------------- # 数据生成器 # --------------------------------------------------------------------------- @app.command() def generate( days: int = typer.Option(180, help="生成多少天的数据"), start_weight: float = typer.Option(75.0, help="初始体重 kg"), target_weight: float = typer.Option(66.0, help="目标体重 kg"), seed: int = typer.Option(42, help="随机种子,保证可复现"), ): """生成 180 天合成体重数据并写入 campus_health.weight_records""" rng = np.random.default_rng(seed) end_date = date.today() start_date = end_date - timedelta(days=days - 1) conn = get_conn() with conn.cursor() as cur: # 幂等:先清掉这个用户的旧数据 cur.execute("DELETE FROM weight_record_tags WHERE weight_record_id IN (SELECT id FROM weight_records WHERE user_id=%s)", (SYNTH_USER_ID,)) cur.execute("DELETE FROM weight_records WHERE user_id=%s", (SYNTH_USER_ID,)) cur.execute("DELETE FROM weight_goals WHERE user_id=%s", (SYNTH_USER_ID,)) cur.execute("DELETE FROM anomaly_logs WHERE user_id=%s", (SYNTH_USER_ID,)) cur.execute( "INSERT INTO users (id, phone, nickname, grade) VALUES (%s,%s,%s,%s) " "ON DUPLICATE KEY UPDATE nickname=VALUES(nickname)", (SYNTH_USER_ID, SYNTH_USER_PHONE, SYNTH_USER_NICKNAME, "大三"), ) # ---- 目标 ---- cur.execute( "INSERT INTO weight_goals (id,user_id,target_weight,target_body_fat,target_date," "start_weight,start_body_fat,start_date,weekly_target,status) " "VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,'active')", ( str(uuid4()), SYNTH_USER_ID, target_weight, 21.0, end_date + timedelta(days=30), start_weight, 28.0, start_date, 0.5, ), ) # ---- 标签事件(相对天数偏移)---- tag_events = { 0: "开始运动", 30: "饮食改变", 60: "生病", 90: "假期", 120: "目标调整", 150: "压力期", } # 取系统标签 ID cur.execute("SELECT id, tag_name FROM tags WHERE user_id IS NULL") tag_id_by_name = {name: tid for tid, name in cur.fetchall()} # ---- 逐日生成 ---- records = [] # (record_id, date, weight, body_fat, muscle, condition, notes) total_drop = start_weight - target_weight for i in range(days): d = start_date + timedelta(days=i) # 周末 20% 概率不称 if d.weekday() >= 5 and rng.random() < 0.35: continue # 基线减重曲线:线性,但在标签事件点有折点 progress = i / days baseline = start_weight - total_drop * progress # 事件扰动 if 60 <= i < 67: # 生病:急性脱水,体重骤降但体脂反升 baseline -= 1.8 elif 90 <= i < 98: # 假期:饮食放纵,反弹 baseline += 1.5 elif 120 <= i < 140: # 压力期:皮质醇高,平台 baseline += 0.4 # 测量噪声 noise = rng.normal(0, 0.25) # 测量条件:80% 早晨空腹,20% 餐后 if rng.random() < 0.8: condition = "fasting" time_str = "07:45:00" condition_offset = 0.0 else: condition = "after_meal" time_str = "12:30:00" condition_offset = rng.uniform(0.8, 1.4) # 餐后称重偏高 weight = round(baseline + noise + condition_offset, 2) # 体脂:跟随体重下降,但生病周会反向 bf_base = 28.0 - 7.0 * progress if 60 <= i < 67: bf_base += 1.5 body_fat = round(np.clip(bf_base + rng.normal(0, 0.2), 5, 60), 2) # 肌肉量:缓慢上升 muscle = round(26.0 + 1.2 * progress + rng.normal(0, 0.15), 2) notes = None if i in tag_events: notes = f"事件:{tag_events[i]}" records.append((str(uuid4()), d, time_str, weight, body_fat, muscle, condition, notes)) cur.executemany( "INSERT INTO weight_records (id,user_id,record_date,measurement_time,weight,body_fat,muscle_mass,measurement_condition,notes) " "VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)", [(r[0], SYNTH_USER_ID, r[1], r[2], r[3], r[4], r[5], r[6], r[7]) for r in records], ) # 关联标签到事件当天的记录 for offset, tag_name in tag_events.items(): d = start_date + timedelta(days=offset) cur.execute( "SELECT id FROM weight_records WHERE user_id=%s AND record_date=%s LIMIT 1", (SYNTH_USER_ID, d), ) row = cur.fetchone() if row and tag_name in tag_id_by_name: cur.execute( "INSERT INTO weight_record_tags (id,weight_record_id,tag_id) VALUES (%s,%s,%s) " "ON DUPLICATE KEY UPDATE id=id", (str(uuid4()), row[0], tag_id_by_name[tag_name]), ) # 埋一个 anomaly_log 示例 cur.execute( "INSERT INTO anomaly_logs (id,user_id,anomaly_type,severity,message,detected_date) " "VALUES (%s,%s,'rapid_change','warning','近7天体重骤降2.1kg(脱水/生病期),请结合体脂与症状判断',%s)", (str(uuid4()), SYNTH_USER_ID, start_date + timedelta(days=63)), ) conn.commit() conn.close() console.print(f"[green]✓ 已写入 {len(records)} 条记录,{len(tag_events)} 个标签事件[/green]") console.print(f" 日期范围:{start_date} → {end_date}") console.print(f" 体重区间:{start_weight}kg → {target_weight}kg(线性减重曲线 + 事件扰动)") # --------------------------------------------------------------------------- # 分析报告 # --------------------------------------------------------------------------- def load_data() -> pd.DataFrame: sql = """ SELECT wr.record_date, wr.weight, wr.body_fat, wr.muscle_mass, wr.measurement_condition, wr.notes, (SELECT GROUP_CONCAT(t.tag_name) FROM weight_record_tags wrt JOIN tags t ON t.id = wrt.tag_id WHERE wrt.weight_record_id = wr.id) AS tags FROM weight_records wr WHERE wr.user_id = %s AND wr.deleted_at IS NULL ORDER BY wr.record_date """ conn = get_conn() df = pd.read_sql(sql, conn, params=[SYNTH_USER_ID]) conn.close() df["record_date"] = pd.to_datetime(df["record_date"]) return df def report_overview(df: pd.DataFrame) -> None: table = Table(title="总体进度", show_header=True, header_style="bold cyan") table.add_column("指标") table.add_column("数值", justify="right") current = df.iloc[-1] initial = df.iloc[0] total_change = current["weight"] - initial["weight"] days = (df["record_date"].iloc[-1] - df["record_date"].iloc[0]).days weekly_avg = total_change / days * 7 # 最近 4 周趋势 recent = df[df["record_date"] >= df["record_date"].max() - pd.Timedelta(days=28)] recent_weekly = (recent["weight"].iloc[-1] - recent["weight"].iloc[0]) / 4 table.add_row("记录天数", str(len(df))) table.add_row("起始体重", f"{initial['weight']:.2f} kg") table.add_row("当前体重", f"{current['weight']:.2f} kg") table.add_row("累计变化", f"{total_change:+.2f} kg") table.add_row("周均变化(全程)", f"{weekly_avg:+.3f} kg/周") table.add_row("周均变化(近4周)", f"{recent_weekly:+.3f} kg/周") table.add_row("体脂变化", f"{current['body_fat'] - initial['body_fat']:+.2f} %") console.print(table) def report_condition_bias(df: pd.DataFrame) -> None: """空腹 vs 餐后的称重偏差——这是个真实且有价值的洞察""" fasting = df[df["measurement_condition"] == "fasting"]["weight"] after = df[df["measurement_condition"] == "after_meal"]["weight"] if len(fasting) < 3 or len(after) < 3: console.print("[yellow]样本不足,跳过测量偏差分析[/yellow]") return table = Table(title="测量条件偏差(餐后 vs 空腹)", show_header=True, header_style="bold magenta") table.add_column("条件") table.add_column("样本数", justify="right") table.add_column("均值 (kg)", justify="right") table.add_column("标准差", justify="right") table.add_row("空腹", str(len(fasting)), f"{fasting.mean():.2f}", f"{fasting.std():.2f}") table.add_row("餐后", str(len(after)), f"{after.mean():.2f}", f"{after.std():.2f}") table.add_row("差值", "—", f"{after.mean() - fasting.mean():+.2f}", "—") console.print(table) console.print( f"[dim]→ 餐后称重比空腹平均高 {after.mean()-fasting.mean():.2f} kg," "跨条件比较时必须先按条件分层,否则会把'刚吃完饭'误读成'长胖了'。[/dim]" ) def report_tag_impact(df: pd.DataFrame) -> None: """每个标签事件前后 7 天的周减重速度对比""" events = df[df["tags"].notna() & (df["tags"] != "")] if events.empty: return table = Table(title="标签事件影响(事件日 ±7 天的周减重速度)", show_header=True, header_style="bold green") table.add_column("标签") table.add_column("事件日") table.add_column("前7天周速 (kg/周)") table.add_column("后7天周速 (kg/周)") table.add_column("变化") for _, row in events.iterrows(): tags = str(row["tags"]).split(",") for tag in tags: event_date = row["record_date"] pre = df[(df["record_date"] < event_date) & (df["record_date"] >= event_date - pd.Timedelta(days=7))] post = df[(df["record_date"] > event_date) & (df["record_date"] <= event_date + pd.Timedelta(days=7))] if len(pre) < 2 or len(post) < 2: continue pre_speed = (pre["weight"].iloc[-1] - pre["weight"].iloc[0]) / max(len(pre) - 1, 1) * 7 post_speed = (post["weight"].iloc[-1] - post["weight"].iloc[0]) / max(len(post) - 1, 1) * 7 delta = post_speed - pre_speed table.add_row( tag, event_date.strftime("%Y-%m-%d"), f"{pre_speed:+.2f}", f"{post_speed:+.2f}", f"{delta:+.2f}", ) console.print(table) def report_anomaly(df: pd.DataFrame) -> None: """简单的异常检测:周变化 > 2kg 或 体重降但体脂升""" df = df.sort_values("record_date").reset_index(drop=True) table = Table(title="自动异常扫描", show_header=True, header_style="bold red") table.add_column("日期") table.add_column("类型") table.add_column("说明") for i in range(7, len(df)): week_ago = df.iloc[i - 7] now = df.iloc[i] dw = now["weight"] - week_ago["weight"] dbf = now["body_fat"] - week_ago["body_fat"] if abs(dw) > 2.0: table.add_row( now["record_date"].strftime("%Y-%m-%d"), "rapid_change", f"7天体重变化 {dw:+.2f} kg(阈值 ±2.0)", ) if dw < -0.5 and dbf > 0.5: table.add_row( now["record_date"].strftime("%Y-%m-%d"), "body_fat_anomaly", f"体重 {dw:+.2f} kg 但体脂 {dbf:+.2f}%——疑似脱水而非减脂", ) if table.row_count == 0: console.print("[green]未发现异常[/green]") else: console.print(table) def draw_chart(df: pd.DataFrame) -> Path: OUTPUT_DIR.mkdir(exist_ok=True) fig, ax = plt.subplots(figsize=(11, 5.5)) fasting = df[df["measurement_condition"] == "fasting"] after = df[df["measurement_condition"] == "after_meal"] ax.scatter(after["record_date"], after["weight"], s=18, alpha=0.5, color="#f59e0b", label="餐后") ax.plot(fasting["record_date"], fasting["weight"], color="#2563eb", linewidth=1.5, label="空腹趋势") # 标签事件竖线 events = df[df["tags"].notna() & (df["tags"] != "")] for _, row in events.iterrows(): ax.axvline(row["record_date"], color="#9ca3af", linestyle="--", alpha=0.6) ax.text(row["record_date"], ax.get_ylim()[1], row["tags"].split(",")[0], rotation=90, fontsize=8, va="top", ha="right", color="#374151") ax.set_title("Synthetic User — Weight Trajectory") ax.set_xlabel("Date") ax.set_ylabel("Weight (kg)") ax.legend(loc="upper right") ax.grid(alpha=0.3) fig.tight_layout() out = OUTPUT_DIR / "weight_trend.png" fig.savefig(out, dpi=130) plt.close(fig) return out @app.command() def report(): """从库里读数据,输出分析报告 + 趋势图""" df = load_data() if df.empty: console.print("[red]没有数据,请先运行:python analyze.py generate[/red]") raise typer.Exit(1) console.print(f"\n[bold]数据窗口:[/bold]{df['record_date'].iloc[0].date()} → {df['record_date'].iloc[-1].date()},共 {len(df)} 条\n") report_overview(df) console.print() report_condition_bias(df) console.print() report_tag_impact(df) console.print() report_anomaly(df) chart_path = draw_chart(df) console.print(f"\n[green]✓ 趋势图已保存:{chart_path}[/green]") if __name__ == "__main__": app()