简介这套个性化居家健身推荐系统是基于Python Flask框架开发的Web应用项目借助基于内容的过滤算法根据用户健身目标、体能水平和可用设备生成定制化锻炼计划主要面向居家健身人群及计算机相关专业毕业设计、课程实践者。资源包共含2000个文件大小约96.4MB类型以1114张JPG图片界面及动作示意图、874个JSON数据文件推荐规则与用户配置、5个HTML前端页面、2个CSV运动数据表和1个Python入口脚本及Markdown说明为主结构清晰便于研读。目前已有111人学习该资源系统代码经过严格测试确保可正常运行。下载后可获得完整项目源码、前后端页面、处理后的锻炼数据集及推荐算法实现参考README即可快速启动。通过该项目可深入理解Flask Web开发、基于内容的推荐原理以及数据分析在健身场景的落地应用适合作为毕业设计或课设的完整范本。1. 个性化居家健身推荐系统先别急着解压想清楚给谁用队友或老师发来一个压缩包名字叫《个性化居家健身推荐系统.zip》。在双击解压之前我建议你先问一句这个系统到底要解决谁的问题居家健身和去健身房完全不同用户可能只有一张瑜伽垫、一对可调哑铃一次训练就 30 分钟还要绕开膝盖痛或者腰伤的旧问题。推荐系统在这里不是“推荐视频”而是把一个可执行的训练计划拼出来动作、顺序、时长、难度缺一不可。网上聊推荐系统动辄讲深度模型、Spark 集群但这类项目的数据量往往只有几千条训练记录单机 Python 就能跑通。真正的难点不在算法而在三个地方没有用户历史怎么办、家里没杠铃怎么过滤、以及拿到别人给你的 zip 包之后怎么从解压到运行不翻车。这篇文章面向的读者是做课程设计、想给家人做个训练助手、或者准备把它发展成产品的开发者读完后你能得到一条可运行的推荐链路以及打包交付一个 zip 项目的完整经验。2. 推荐算法选型协同过滤为什么比深度学习更适合居家健身推荐系统这个概念被讲得太玄说到底是一个矩阵问题用户看成行动作看成列评分填进交叉点。可是居家健身场景里这个矩阵还特别稀疏——一个用户练过几十个动作动作库却可能有几百个。要让推荐结果真正“个性化”第一步不是选什么高级模型而是选一个能在这种稀疏度下稳定工作的方法。网上那些“一文看懂推荐系统”的标题通常把矩阵分解和深度学习放在最显眼的位置好像不上一套神经网络就不够高级。但居家健身的数据规模通常不具备这个条件。这个场景里用户数量几百到几千动作项几百个单条评分还受当天状态影响矩阵分解在这种数据上训练出来的隐向量往往不稳定。反而是协同过滤这类直接利用用户行为共现关系的方法在小数据上更容易解释也更容易调参。2.1 三类常见推荐方案哪一个在健身场景不会翻车先把方案选型摆到桌面上比较一下各自的边界方案核心思路在居家健身场景的问题基于规则按目标手动配置动作组合没有个性化千人一面用户练两周就腻协同过滤从用户历史行为中找相似用户或相似动作冷启动需要外部兜底但数据一够效果稳定矩阵分解把用户和动作映射到低维隐向量评分矩阵稀疏时隐向量难收敛解释性差深度学习对行为序列和特征做复杂建模数据量撑不起模型容量且不好向用户解释我的判断是如果你手上只有几千条训练记录直接把 UserCF 或 ItemCF 作为召回基线。不要一上来就上深度模型否则后面每调一个超参数都像是在猜出了问题也无从排查。说白了这个体量下深度学习的天花板被数据限制住了而协同过滤的下限足够高。2.2 行为数据怎么来从原始训练日志到评分矩阵推荐系统的地基是“用户—动作”评分矩阵。在居家健身场景里物品就是一个个训练动作比如“标准俯卧撑”“深蹲”“哑铃划船”。行为数据常见来源有三类完成记录用户练了哪些动作、组数、次数、主观反馈训练结束后的自觉用力程度 RPE1—10 分、客观指标心率、时长、动作次数。如果没有现成数据先用合成数据把链路跑通等有真实数据再替换数据源这是最务实的做法。import pandas as pd import numpy as np rng np.random.default_rng(42) users [fu{i:03d} for i in range(1, 200)] exercises [fex{j:03d} for j in range(1, 120)] records [] for u in users: n int(rng.integers(10, 30)) # 每个用户练过 10~30 个动作 chosen rng.choice(exercises, sizen, replaceFalse) for ex in chosen: base rng.integers(1, 6) # 动作基础强度系数 noise rng.normal(0, 0.5) # 个人偏好波动 score np.clip(base noise, 1, 5) records.append((u, ex, round(float(score), 2))) df pd.DataFrame(records, columns[user_id, exercise_id, rating]) pivot df.pivot_table(indexuser_id, columnsexercise_id, valuesrating) print(pivot.shape) print(缺失占比:, round(pivot.isna().sum().sum() / pivot.size, 4))这段代码有三个关键参数。rng.integers(10, 30)控制每个用户的训练记录条数刻意让矩阵稀疏因为真实用户绝不可能练过所有动作。replaceFalse保证同一个用户不会对同一个动作生成两条历史避免后面算相似度时重复计数。np.clip(base noise, 1, 5)把评分限制在 1 到 5 分相当于五星制。固定 seed 是为了让你的结果可复现之后排查推荐结果时能重放同一份数据。2.3 基线与进阶UserCF 的 Python 实现与参数先用最直白的 UserCF 跑通找和目标用户口味相近的一批用户把他们练过而目标用户没练过的动作按相似度加权汇总过滤掉已练过的动作取 Top-N。from sklearn.metrics.pairwise import cosine_similarity import numpy as np # 缺失值填 0 只是为了算余弦相似度不代表真实评分为 0 user_vec pivot.fillna(0).values sim cosine_similarity(user_vec) def recommend_for(user_id, k10, top_n5): uidx list(pivot.index).index(user_id) target_row pivot.iloc[uidx] scores sim[uidx].copy() scores[uidx] 0 # 排除自己 top_k np.argsort(-scores)[:k] rec {} for nidx in top_k: w scores[nidx] row pivot.iloc[nidx].dropna() for ex, r in row.items(): if pd.notna(target_row[ex]): continue # 练过的不再推荐 rec[ex] rec.get(ex, 0) w * r ranked sorted(rec.items(), keylambda x: -x[1])[:top_n] return [(ex, round(score, 2)) for ex, score in ranked] print(recommend_for(u000, k10, top_n5))这里有两个必须说清楚的参数。k10是邻居数量数据稀疏时k太小会让推荐集中到少数热门动作k太大又会把不相干用户引入加权。top_n5是最终推荐动作数居家健身一次训练建议 5—6 个动作正好组成一轮循环。推荐结果如果总是高峰期动作占据就调小k或者改用 ItemCF——按动作算相似度推荐“和你练过的动作相似的动作”适合动作库变化不频繁的场景。为什么我不建议在这个阶段上 Spark千万不要因为看过“基于 Spark 的电商系统推荐”这类教程就顺手把居家健身项目迁到分布式框架。你的数据量连单机内存都装不满时集群的序列化和调度开销反而成为主要耗时。推荐系统的复杂度应当和业务规模匹配这个原则比选哪个模型更重要。3. 从评分到计划过滤、冷启动与重排的 Pipeline协同过滤的输出是 Top-N 列表但它注定是个半成品模型不知道用户今天膝盖疼也不关心动作顺序是否科学。所以生产级的个性化健身推荐必须把模型结果塞进一个规则管线的下游。我的做法是固定一条流水线召回 → 硬过滤 → 冷启动补足 → 重排 → 解释。3.1 为什么必须加规则引擎器械、时长与身体禁忌动作相似不等于动作合适。用户常练深蹲协同过滤可能推荐杠铃硬拉但用户家里根本没有杠铃用户膝盖不适模型也不会知道。所以在个性化推荐系统里动作元数据不是辅助信息而是硬约束。我一般给动作库维护一份配置表而不是把规则写死在代码里。字段示例说明exercise_idex002唯一标识name深蹲展示给用户看的名称musclelegs主肌群equipmentfreefree 表示徒手dumbbell 表示哑铃typestrength训练类型strength/stability/cardiodifficulty21—5 难度等级冷启动用duration8单组预估分钟安排计划用risk[knee]风险标签用户有禁忌时直接排除对应代码exercise_meta { ex001: {name: 标准俯卧撑, muscle: chest, equipment: free, type: strength, difficulty: 1, duration: 6, risk: []}, ex002: {name: 深蹲, muscle: legs, equipment: free, type: strength, difficulty: 2, duration: 8, risk: [knee]}, ex003: {name: 哑铃划船, muscle: back, equipment: dumbbell, type: strength, difficulty: 2, duration: 8, risk: []}, ex004: {name: 平板支撑, muscle: core, equipment: free, type: stability, difficulty: 1, duration: 5, risk: []}, } def apply_hard_filters(candidates, available_equipment, forbid_tags): out [] for ex_id in candidates: meta exercise_meta.get(ex_id) if meta is None: continue if meta[equipment] not in available_equipment: continue if set(meta[risk]) set(forbid_tags): continue out.append(ex_id) return outavailable_equipment是用户勾选的小工具集合例如{free, dumbbell}forbid_tags是身体禁忌标签。注意规则引擎不是可选项协同过滤只学用户和动作的历史共现永远学不进“今天膝盖不舒服”这种临时状态所以必须给用户留一个输入约束的口子否则系统推荐得再“准”现场也不能执行。3.2 冷启动新用户只有三条记录怎么办新用户没有历史评分协同过滤直接哑火。常见做法是让用户回答三个问题目标是减脂还是增肌、家里有什么器械、每次能练多久。这个交互成本远低于填一份十项问卷却已经能把动作库压缩到三分之一。把这些回答映射成一个画像字典再用内容匹配补一个“热门动作 冷门探索”的混合排名。import numpy as np hot_list {ex001: 100, ex004: 80, ex002: 60} # 动作被练习的总次数 def cold_start_rank(user_profile, exercise_meta, hot_list): score [] for ex_id, meta in exercise_meta.items(): s 0.0 if user_profile[goal] strength and meta[type] strength: s 1.0 if meta[difficulty] user_profile[fitness_level]: s 0.5 s 0.1 * (1 np.log1p(hot_list.get(ex_id, 0))) score.append((ex_id, s)) score.sort(keylambda x: -x[1]) return [ex_id for ex_id, _ in score[:10]]这里的核心参数是0.1 * (1 log1p(hot_list))这一项。它用对数平滑热度防止热门动作直接淹没个性化得分。log1p就是把 0 次映射为 0 的对数变换热度从 100 涨到 1000 时这一项只增加约 0.23 分这样冷启动推荐既不会太 “冷”也不会被头部动作锁死。fitness_level用 1—3 表示新手到进阶难度过滤必须放在热度加权之前否则会推一批用户做不了的动作。3.3 召回-过滤-重排一条流水线的完整代码把前两节的函数串起来就是完整的推荐管线。def build_plan(user_id, user_profile, pivot, exercise_meta, hot_list): sim_top recommend_for(user_id, k10, top_n20) candidates [ex_id for ex_id, _ in sim_top] filtered apply_hard_filters( candidates, available_equipmentuser_profile[equipment], forbid_tagsuser_profile[forbid_tags], ) if len(filtered) 5: extra cold_start_rank(user_profile, exercise_meta, hot_list) for ex in extra: if ex not in filtered: filtered.append(ex) if len(filtered) 8: break return reorder_for_session(filtered, exercise_meta, user_profile[duration])user_profile是一个字典包含equipment、forbid_tags、goal、fitness_level、duration。协同过滤先召回 20 个候选硬过滤后如果不足 5 个就用冷启动内容匹配补到 8 个以内。最后的reorder_for_session负责把候选列表变成真正可执行的训练顺序def reorder_for_session(plan, exercise_meta, total_minutes, max_same_muscle1): ordered [] last_muscle None used 0 for ex in plan: muscle exercise_meta[ex][muscle] if muscle last_muscle and used total_minutes: continue if used exercise_meta[ex][duration] total_minutes: break ordered.append(ex) used exercise_meta[ex][duration] last_muscle muscle return ordered这一段我故意用了最简单的连续重复跳过避免同一肌群动作连排导致肌肉过早力竭。它的代价是可能把计划截短实际使用时你可以改成“隔两个动作后再排同肌群”的贪心策略。总时长控制用used duration total_minutes判断保证用户说练 30 分钟系统不会塞一个 45 分钟的计划。3.4 可解释性给推荐一个理由推荐结果如果没有理由用户很难信任。尤其是健身场景用户要判断“为什么今天让我练这个”否则会觉得是随机生成。我在管线末尾加了一个解释函数理由模板基于两个信息用户历史偏好和硬过滤条件。def explain_recommendation(rec_ex, user_history, exercise_meta): reasons [] for his in user_history: if exercise_meta[his][muscle] exercise_meta[rec_ex][muscle]: reasons.append(f因为你常练{exercise_meta[his][name]}) break if not reasons: reasons.append(根据你的目标和可选器械) return .join(reasons) f推荐你试{exercise_meta[rec_ex][name]}输出类似“因为你常练标准俯卧撑推荐你试哑铃划船”。这个理由虽然简单但它把推荐链路的两个中间产物——协同过滤的相似性和规则引擎的约束——都暴露给了用户。模型是黑匣子没关系管线解释可以透明。4. 解压到跑通的四个坑伪加密、依赖、编码与路径排查从“拿到 zip 包”到“服务能在自己电脑跑起来”中间有四个坑几乎每个项目都会遇到。按出现顺序排逐个说现象、原因和解决方式。4.1 zip 伪加密解压弹出密码但数据根本没加密现象双击解压解压工具弹窗要求输入密码你用常见的密码组合试一遍都失败网上找的“zip 解压密码清除工具”读完后提示这不是一个真正加密的文件。原因zip 文件头里有一个通用标志位第 0 位表示“文件加密”。伪加密就是只把这个标志位设为 1而实际文件数据并未被加密。打包工具误操作或共享文件的人故意加假锁Windows 资源管理器和 7-Zip 看到标志位就会要求输密码于是项目包卡死在解压这一步。解决自己写一个小脚本把本地文件头和中央目录头里的加密标志位清除重新保存不需要任何第三方工具。import struct src project.zip dst project_fixed.zip with open(src, rb) as f: data bytearray(f.read()) for sig in (bPK\x03\x04, bPK\x01\x02): # 本地头 / 中央目录头 off 0 while True: idx data.find(sig, off) if idx -1: break flag_off idx 6 if sig bPK\x03\x04 else idx 8 flag int.from_bytes(data[flag_off:flag_off 2], little) data[flag_off:flag_off 2] struct.pack(H, flag ~0x0001) off idx 1 with open(dst, wb) as f: f.write(data)注意脚本里的off idx 1不能跳过整个文件头继续查找因为文件内容里也可能出现PK\x03\x04序列只有逐字节移动才能保证不遗漏。本地文件头和中央目录头需要同时修改只改其中一个解压工具依然可能报错。如果是真加密flag清零后解压出来的数据会是乱码或直接报 CRC 错误所以这个脚本只用来处理伪加密别拿它当“密码移除工具”去解真正的加密包。4.2 依赖版本不一致导致的 ModuleNotFoundError现象运行python app.py报ModuleNotFoundError: No module named sklearn安装scikit-learn后再跑又报 pandas 版本 API 不兼容或者pip install -r requirements.txt时某个包在 Python 3.8 上装不下来。原因打包的人用 Python 3.10 开发你在 Python 3.8 运行或者 requirements.txt 里写的是numpy1.26但这个版本不再支持旧版 Python。还有一类低级问题代码里import sklearn而 requirements 里写的是scikit-learn包名和 import 名不一致。解决统一用虚拟环境别往系统 Python 里裸装依赖。先创建虚拟环境再安装python -m venv .venv source .venv/bin/activate # Windows 下用 .venv\Scripts\activate pip install -r requirements.txt python app.py如果安装过程中某个包失败不要装作没看见继续往下装先解决第一条失败的结论。常见做法是分两步先装 numpy、pandas、scikit-learn 这类核心库再装源码里自定义或本地引用的包。requirements.txt 里带有-e .或本地路径时核心库装好后还需要单独执行一次pip install -e .否则 import 自研模块也会报找不到。4.3 中文文件名乱码Windows 打包Linux 解压后目录名全是乱码现象在 Linux 下执行unzip project.zip解压出的文件目录名变成绔?/之类的乱码路径识别不了项目里所有依赖相对路径的代码全部失去作用。原因zip 规范里文件名有两种编码来源。Windows 下打包工具默认用 GBK/CP936 编码文件名而 Linux 的 unzip 默认按 UTF-8 解码。文件名本身没有损坏只是两边的编码标记对不上。解决如果你的 unzip 支持-O参数直接指定编码解压unzip -O CP936 project.zip如果发行版的 unzip 不支持-O就用 Python zipfile 手动解压并把文件名从 CP437 转回 GBKimport zipfile import os src project.zip dst_dir extracted with zipfile.ZipFile(src) as z: for info in z.infolist(): raw info.filename.encode(cp437) correct raw.decode(gbk) target os.path.join(dst_dir, correct) os.makedirs(os.path.dirname(target), exist_okTrue) with open(target, wb) as f: f.write(z.read(info))这里的关键是encode(cp437)zipfile 在没有 UTF-8 标志时会默认按 cp437 解码文件名所以想拿回原始字节就必须先按 cp437 编码回去再用真实的语言编码decode(gbk)。如果你在 Windows 上解压 Linux 打的中文 zip 包乱码把gbk换成utf-8即可逻辑完全一致。4.4 打包后路径写死换一台机器就找不到数据现象程序报FileNotFoundError: data/user_ratings.csv在项目根目录跑能通过切到子目录或换个目录启动就崩队友解压后反馈“按你说的命令跑的但读不到文件”。原因代码里用了绝对路径如/home/xxx/...或者用相对路径data/xxx.csv依赖“当前工作目录”。zip 包交付后别人解压到的路径不可能和你的开发路径一致相对路径立刻失效。解决用当前文件所在位置锚定数据目录而不是依赖工作目录。from pathlib import Path BASE_DIR Path(__file__).resolve().parent DATA_DIR BASE_DIR / data ratings_path DATA_DIR / user_ratings.csv__file__是当前脚本的路径.resolve().parent拿到脚本所在目录的绝对路径之后无论你在哪里执行启动命令DATA_DIR都能正确定位。这一步做完再配合一个判断如果文件不存在打开日志打印DATA_DIR排障时一眼就能看出它找的是哪个目录。这是所有可交付项目里最常见的“在我机器上能跑”根因没有之一。5. 交付一个干净的 zip打包清单、命令与完整性校验前面的链路能跑通只算完成一半。另一半是把它整理成一个别人拿到手不会骂人的 zip 包。交付这件事信息过少和垃圾文件过多一样致命。5.1 压缩包里应该放什么不是所有文件都能往里塞我先列一份推荐的项目结构这份结构同样适用于课程设计和开源交付路径用途注意事项README.md项目简介、运行命令、参数说明放在包根目录别放两层子目录里requirements.txt第三方依赖清单锁定到小版本临界版本用会埋雷src/推荐算法与管线源码包路径统一方便importdata/模拟或脱敏数据不要把真实用户手机号、体重数据放进去tests/冒烟测试与单元测试至少包含一个smoke_test.pyscripts/数据生成、打包、校验脚本可选但能降低使用门槛.env.example环境变量样例不给真实密钥只给配置项结构坚决不要放进去的是.venv、__pycache__、*.pyc、.pytest_cache、IDE 的.idea或.vscode。虚拟环境体积动辄几百 MB别人拿到还得删缓存文件会让代码路径查找出现离奇问题。README 里要写明如何生成模拟数据这样用户不需要找你要历史数据集就能把推荐链路跑起来。5.2 打包命令Linux 下一行 zip 排除垃圾文件在项目根目录执行打包是避免套娃目录最直接的办法zip -r home-fitness-recsys.zip \ README.md requirements.txt \ src/ data/ tests/ scripts/ \ -x *.pyc *__pycache__/* .pytest_cache/* .venv/*-r递归打包目录-x是排除规则这一步决定了别人解压后第一眼看到的目录是否干净。还有一个实用习惯打完包立刻用unzip -l home-fitness-recsys.zip列出包内文件检查有没有不该出现的路径前缀。如果你的发布时间线比较长建议在压缩包内保留一个CHANGELOG.md哪怕只写一行“v0.1支持 UserCF 与硬过滤”也能给接收方省掉大量追问。关于压缩包加密我的观点是zip -P设置的 ZipCrypto 加密并不安全流密码加密的内容在拿到密文后很容易被还原zip 的加密更像一把“防君子不防小人”的锁。真正需要加密交付时用 7z 的 AES-256同时对文件名也做加密别假装一个 zip 能承担加密交付的职责。5.3 交付前自检先解压一遍再跑一遍再算一遍哈希交付前的自检我固定做三件事全部是一行命令的事但能拦住绝大多数翻车# 1) 解压到全新目录确保没有依赖当前目录 rm -rf /tmp/verify mkdir -p /tmp/verify unzip home-fitness-recsys.zip -d /tmp/verify # 2) 在干净的虚拟环境里跑冒烟测试 cd /tmp/verify python -m venv .venv source .venv/bin/activate pip install -r requirements.txt -q python -m pytest tests/smoke_test.py -q # 3) 计算校验和随包一起提供 sha256sum home-fitness-recsys.zip CHECKSUM.txt为什么必须先解压到/tmp/verify因为直接在自己的项目目录测试会掩盖路径写死问题——第 4.4 节踩的坑就在这里。冒烟测试至少覆盖三个动作加载评分数据、运行一次推荐、输出一个训练计划。脚本通过后sha256sum生成校验文件接收方用sha256sum -c CHECKSUM.txt就能验证包是否被改坏。这个是给双方都留的后悔药万一解压报错先看校验值而不是来回猜是谁动了文件。6. 推荐效果怎么验证评估指标与下一步进化方向推荐系统上线前先量化再谈感受。居家健身场景用三个指标就够PrecisionK衡量推荐的动作在测试集里被用户练过的比例Coverage衡量推荐动作占动作库的比例防止每天推同一批热门动作Diversity统计一次计划里包含的不同肌群数量低于 3 基本不能算一个合格的训练计划。离线评测的做法很简单把历史评分按 8:2 划分训练集和验证集只基于训练集算相似度然后看验证集的命中情况。def evaluate(recommender, test_df, pivot, top_n5): hit 0 total 0 for user_id, group in test_df.groupby(user_id): seen set(pivot.loc[user_id].dropna().index) pred [ex for ex, _ in recommender(user_id, top_ntop_n)] hit len([ex for ex in pred if ex in set(group[exercise_id])]) total len(pred) return hit / max(total, 1)Precision5的真实含义是“推荐的 5 个动作里用户历史上真的练过的比例”。这个指标并不完美因为用户没练过不代表不想练但它能反映推荐结果是否偏离用户已有偏好。真正要看的还有覆盖率如果 500 个动作永远只推那 20 个再准也是个失败的推荐系统。再往后走我的建议不是立刻换模型而是先把反馈信号补强。我自己吃过一个亏用户反馈“推荐动作太难”但我只看评分矩阵完全没有把“难度完成度”放进权重导致增肌用户天天被推高难度动作。后来把 RPE 评分作为权重因子乘到协同过滤分数上推荐质量立刻提升了一个档次。下一步可以尝试的方向是用姿态估计算法自动识别动作完成次数把完成度转为评分信号或者用上下文多臂老虎机模型对新动作做探索避免新动作永远没有曝光机会。架构上继续用召回过滤重排的管线只是把召回模型从 UserCF 替换成双塔模型其他环节不动。这套链路我从合成数据做到真实用户最有价值的一条习惯是每一次改动都留一份离线评测结果不凭感觉上线。希望帮到你。本文还有配套的精品资源点击获取
