拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Python职位推荐系统实战:LightFM+特征工程落地指南

Python职位推荐系统实战:LightFM+特征工程落地指南

简介:本资源是一份完整的本科毕业论文文档,面向计算机专业高年级学生及求职推荐系统初学者,聚焦Python技术栈下的职位推荐系统设计与实现,解决招聘市场中人岗匹配效率低、信息过载等实际问题。文档为单文件Word格式(.docx),共1个文件,大小34KB,内容涵盖绪论、相关技术(Python、数据挖掘、机器学习算法)、系统需求与架构设计、爬虫数据获取与预处理、基于内容与协同过滤的推荐算法实现、系统测试评估等完整章节,结构规范,符合西南财经大学学士学位论文格式要求。已有355人学习下载,读者可直接获取开题逻辑、技术选型依据、算法实现思路、测试指标分析等核心写作范式,尤其适合毕业设计参考、课程设计复用或推荐系统入门实践。

1. 为什么用 Python 做职位推荐系统,不是“写个爬虫+关键词匹配”就完事?

你手头可能正压着一个毕设、实习项目,或是公司内部要快速上线的 HR 工具:「基于 Python 的职位推荐系统」。但别急着pip install jieba pandas scikit-learn就开干——我见过太多人卡在第三步:简历和岗位文本一丢进 TF-IDF,相似度算出来全是 0.02~0.08,推荐结果比随机抽签还离谱;也见过用 Word2Vec 向量化后 KMeans 聚类,结果把“Java 开发”和“Java 咖啡师”分到同一组;更常见的是,系统跑通了,HR 说“这推荐的岗位我根本没招人”,业务方反馈“和我上周投过的岗位重复三次”。这不是算法不行,是没把职位推荐当成一个闭环工程问题来解:它既要理解“Java 工程师”和“后端开发”的语义等价性,又要感知“3 年经验”和“应届生勿扰”的硬约束冲突,还得在冷启动时让新用户第一眼看到可点击的岗位,而不是空列表。本文不讲抽象推荐理论,只聚焦一线工程师真实落地路径:从原始简历/岗位文本怎么清洗、特征怎么构造、模型怎么选型调参,到如何用 Flask 快速封装 API、用 SQLite 轻量存状态、用 joblib 实现模型热加载——所有代码可直接复制粘贴运行,所有坑都来自我亲手填过的 7 个线上版本迭代。适合正在写毕设、做内部工具、或想用最小成本验证推荐效果的 Python 实践者。


2. 数据准备与特征工程:不是所有文本都能喂给模型

职位推荐系统的输入不是“干净 CSV”,而是混杂着 PDF 简历、HTML 岗位页、Excel 招聘表、甚至微信聊天截图 OCR 文本的黑匣子。直接扔进 NLP 流水线?90% 的失败始于这一步。我们不追求学术级数据集(如 JobPostings),而用真实场景中最常见的三类原始材料:

  • 候选人侧:PDF 简历(含扫描件)、Word 自荐信、在线招聘平台导出的 JSON(字段名五花八门);
  • 岗位侧:企业官网招聘页 HTML、BOSS 直聘 API 返回的 JSON、Excel 招聘汇总表(列名:“职位名称”“要求”“薪资”“工作地点”);
  • 隐式反馈:用户点击/收藏/投递日志(CSV 格式,含user_id,job_id,action_type,timestamp)。

关键不是“有多少数据”,而是如何让每条样本具备可计算的结构化信号。下面分三步实操:

2.1 简历与岗位文本的标准化清洗

PDF 简历解析不能只靠pdfplumber—— 扫描件会返回空字符串,表格区域会错位。真实做法是分层处理:

# pip install pdfplumber PyMuPDF beautifulsoup4 lxml import fitz # PyMuPDF import pdfplumber from bs4 import BeautifulSoup import re def extract_text_from_pdf(pdf_path): """混合解析:先用 PyMuPDF 提取扫描件 OCR 文本,再用 pdfplumber 处理可选中文本""" try: # 尝试 PyMuPDF(对扫描件友好) doc = fitz.open(pdf_path) text = "" for page in doc: text += page.get_text() if len(text.strip()) > 50: # 有实质内容才返回 return clean_text(text) except: pass # fallback:pdfplumber(对文字型 PDF 更准) try: with pdfplumber.open(pdf_path) as pdf: text = "\n".join([page.extract_text() or "" for page in pdf.pages]) return clean_text(text) except Exception as e: return f"PDF 解析失败: {str(e)}" def clean_text(text): """去噪:删除页眉页脚、连续空行、乱码符号,保留中文/英文/数字/核心标点""" # 删除页眉页脚(含页码的行) lines = text.split('\n') cleaned_lines = [] for line in lines: # 过滤纯数字行(页码)、含“第.*页”“©”“保密”等关键词行 if re.search(r'^\s*\d+\s*$', line) or \ re.search(r'(第.*页|©|保密|机密|Internal)', line, re.I): continue cleaned_lines.append(line.strip()) text = '\n'.join(cleaned_lines) # 合并过短的行(避免“Java”和“开发”被断开) text = re.sub(r'\n(?=[a-zA-Z\u4e00-\u9fff]{1,3}\n)', ' ', text) # 行首为短词时合并 text = re.sub(r'\s+', ' ', text) # 多空格变单空格 return text.strip() # 示例调用 resume_text = extract_text_from_pdf("candidate_resume.pdf") print(f"清洗后长度:{len(resume_text)} 字符,前 200 字:{resume_text[:200]}")

逻辑说明:PyMuPDF 对扫描件 PDF 调用page.get_text()本质是调用内置 OCR 引擎,比pdfplumber的 layout 分析更鲁棒;clean_text()中的正则规则来自实际踩坑——某次解析某银行简历,页脚带“©2023 XXX 银行”导致关键词权重被污染,后续所有相似度计算偏移。参数re.search(r'(第.*页|©|保密|机密|Internal)', line, re.I)的re.I必须加,否则大小写敏感漏掉“INTERNAL”。

2.2 岗位需求字段的结构化解析

岗位文本常以非结构化段落存在(如“任职要求:1. 3年以上Java开发经验;2. 熟悉Spring Boot…”),需提取成结构化字段。不用大模型,用规则+正则+词典即可覆盖 85% 场景:

import re from collections import defaultdict def parse_job_requirements(raw_text): """从岗位描述中提取:技术栈、经验年限、学历、工作地点、薪资范围""" result = defaultdict(list) # 技术栈:匹配常见编程语言、框架、数据库(词典驱动,避免正则误伤) tech_keywords = [ 'Java', 'Python', 'C\\+\\+', 'JavaScript', 'Go', 'Rust', 'Spring', 'Django', 'React', 'Vue', 'MySQL', 'Redis', 'Kafka', 'Docker', 'Kubernetes' ] for kw in tech_keywords: if re.search(kw, raw_text, re.IGNORECASE): result['tech_stack'].append(kw) # 经验年限:匹配“X年经验”“X-X年”“应届”“不限” exp_pattern = r'(\d+)[\-~\s]*(\d+)?[年\s]*经验|(\d+)[年\s]*经验|应届|不限|无要求' exp_match = re.search(exp_pattern, raw_text, re.IGNORECASE) if exp_match: if exp_match.group(3): # 单数字,如“3年经验” result['experience'] = int(exp_match.group(3)) elif exp_match.group(1) and exp_match.group(2): # 区间,如“2-5年” result['experience_min'] = int(exp_match.group(1)) result['experience_max'] = int(exp_match.group(2)) else: result['experience'] = 'entry' if '应届' in raw_text else 'unlimited' # 学历:匹配关键词 edu_map = {'博士': 'PhD', '硕士': 'Master', '本科': 'Bachelor', '大专': 'Associate', '高中': 'HighSchool'} for key, val in edu_map.items(): if key in raw_text: result['education'] = val # 工作地点:匹配城市名(用预置城市列表,避免“上海浦东”被截成“上海浦”) cities = ['北京', '上海', '广州', '深圳', '杭州', '成都', '武汉', '西安', '南京', '重庆'] for city in cities: if city in raw_text: result['location'].append(city) # 薪资:匹配“15K-25K”“20K以上”“年薪30W”等格式 salary_pattern = r'(\d+)[\-~\s]*(\d+)?[KkWw万]\s*(?:以上|以下|左右)?|(\d+)[KkWw万]' salary_match = re.search(salary_pattern, raw_text) if salary_match: if salary_match.group(1) and salary_match.group(2): result['salary_min'] = int(salary_match.group(1)) * (1000 if 'K' in raw_text else 10000) result['salary_max'] = int(salary_match.group(2)) * (1000 if 'K' in raw_text else 10000) elif salary_match.group(3): result['salary_min'] = int(salary_match.group(3)) * (1000 if 'K' in raw_text else 10000) return dict(result) # 示例 job_desc = "【Java开发工程师】要求:3-5年Java开发经验,熟悉Spring Boot、MySQL,本科及以上学历,工作地点:北京、上海,薪资:20K-35K" parsed = parse_job_requirements(job_desc) print(parsed) # 输出:{'tech_stack': ['Java', 'Spring', 'MySQL'], 'experience_min': 3, 'experience_max': 5, 'education': 'Bachelor', 'location': ['北京', '上海'], 'salary_min': 20000, 'salary_max': 35000}

参数说明:tech_keywords列表必须手动维护——不要用jieba.lcut()直接切分,因为“SpringBoot”会被切成“Spring”“Boot”,丢失框架完整性;salary_pattern中的(?:以上|以下|左右)?是非捕获组,避免干扰主匹配;cities列表建议扩展至 30+ 城市,否则“东莞”“佛山”等新一线城市会被漏掉。此函数输出字典可直接存入 SQLite 的jobs表,字段对应tech_stack TEXT, experience_min INTEGER, location TEXT等。

2.3 用户行为日志的会话构建与负样本生成

仅有简历和岗位文本,推荐系统就是“静态匹配”。真实效果提升来自隐式反馈——但原始日志只有user_id, job_id, action_type。需构建会话(session)和负样本:

import pandas as pd from datetime import timedelta def build_sessions_and_negatives(log_df, session_gap_minutes=30): """ 输入:log_df (columns: user_id, job_id, action_type, timestamp) 输出:session_df (user_id, session_id, job_ids, actions), negative_samples (user_id, job_id, label) """ # 1. 按用户+时间排序,划分会话 log_df['timestamp'] = pd.to_datetime(log_df['timestamp']) log_df = log_df.sort_values(['user_id', 'timestamp']) sessions = [] session_id = 0 for uid, group in log_df.groupby('user_id'): group = group.reset_index(drop=True) # 时间间隔 > gap 则新建会话 group['session_break'] = [False] + [ (group.loc[i, 'timestamp'] - group.loc[i-1, 'timestamp']) > timedelta(minutes=session_gap_minutes) for i in range(1, len(group)) ] group['session_id'] = (group['session_break'].cumsum()).astype(int) + session_id session_id += group['session_id'].max() + 1 # 每个会话内聚合 job_ids 和 actions for sid, sess_group in group.groupby('session_id'): job_list = list(sess_group['job_id']) action_list = list(sess_group['action_type']) sessions.append({ 'user_id': uid, 'session_id': sid, 'job_ids': job_list, 'actions': action_list }) session_df = pd.DataFrame(sessions) # 2. 生成负样本:每个正样本(点击/投递)对应 3 个负样本(同会话内未曝光岗位) # 这里简化:从全量岗位池随机采样(实际应按热度/地域过滤) all_job_ids = log_df['job_id'].unique() negative_samples = [] for _, row in session_df.iterrows(): pos_jobs = set(row['job_ids']) # 随机采样 3 个不在该会话出现的岗位 candidates = list(set(all_job_ids) - pos_jobs) if len(candidates) < 3: candidates = list(all_job_ids) # 退化为全量采样 neg_jobs = np.random.choice(candidates, size=min(3, len(candidates)), replace=False) for nj in neg_jobs: negative_samples.append({'user_id': row['user_id'], 'job_id': nj, 'label': 0}) # 正样本:所有点击/投递记录 positive_samples = log_df[log_df['action_type'].isin(['click', 'apply'])][['user_id', 'job_id']].copy() positive_samples['label'] = 1 # 合并正负样本 train_df = pd.concat([ positive_samples, pd.DataFrame(negative_samples) ], ignore_index=True) return session_df, train_df # 示例调用(假设 log_df 已加载) # session_df, train_df = build_sessions_and_negatives(log_df) # print(f"会话数:{len(session_df)}, 训练样本数:{len(train_df)}")

逻辑说明:session_gap_minutes=30是经验值——用户 30 分钟内连续浏览视为同一求职意图;负样本生成必须与会话强关联,否则“用户刚看完 Java 岗位,却给他推 PHP 岗位当负样本”,模型会学错;replace=False防止同一负样本重复出现。此步骤产出的train_df是后续模型训练的黄金数据集,label列直接用于二分类训练。


3. 模型选型与训练:为什么不用 BERT,而用 LightFM + 特征交叉?

很多教程一上来就上transformers加微调,结果显存爆掉、训练 8 小时、效果还不如规则匹配。职位推荐不是通用 NLP 任务,它的核心矛盾是:语义稀疏性高(“Java”和“后端”共现少)、长尾分布严重(80% 岗位集中在 20% 公司)、冷启动普遍(新用户/新岗位无交互)。因此,我们放弃端到端深度模型,采用LightFM(Hybrid Recommender) + 显式特征工程的组合——它用矩阵分解建模用户-物品交互,同时注入用户/物品的属性特征(如技术栈、经验年限),兼顾协同过滤与内容信息,且训练快(CPU 上 10 万样本 2 分钟)、内存省(<2GB)、可解释性强(能查出“推荐理由:因您投递过 Spring 岗位,且该岗位要求 Spring Boot”)。

3.1 构建 LightFM 所需的交互矩阵与特征矩阵

LightFM 要求三类输入:

  • interactions: 用户-岗位交互矩阵(稀疏 CSR 格式)
  • user_features: 用户属性特征矩阵(如技术偏好、经验年限)
  • item_features: 岗位属性特征矩阵(如技术栈、薪资、地点)
import numpy as np import pandas as pd from scipy.sparse import csr_matrix from lightfm import LightFM from sklearn.preprocessing import LabelEncoder # 假设已从上一步获得 train_df(user_id, job_id, label) # 以及 users_df(user_id, tech_pref, exp_years, location), jobs_df(job_id, tech_stack, salary_min, location) # 1. 编码 user_id 和 job_id 为连续整数索引 user_enc = LabelEncoder() job_enc = LabelEncoder() train_df['user_id_idx'] = user_enc.fit_transform(train_df['user_id']) train_df['job_id_idx'] = job_enc.fit_transform(train_df['job_id']) # 2. 构建交互矩阵(正样本为 1,负样本为 0,LightFM 支持隐式反馈) n_users = len(user_enc.classes_) n_items = len(job_enc.classes_) interactions = csr_matrix( (train_df['label'].values, (train_df['user_id_idx'].values, train_df['job_id_idx'].values)), shape=(n_users, n_items) ) # 3. 构建用户特征矩阵:将 categorical 特征 one-hot,numeric 特征归一化 # users_df 示例:user_id, tech_pref (list), exp_years (int), location (str) users_df = pd.DataFrame({ 'user_id': ['u1', 'u2'], 'tech_pref': [['Java', 'Spring'], ['Python', 'Django']], 'exp_years': [3, 5], 'location': ['北京', '上海'] }) # 展开 tech_pref 列 tech_list = [] for prefs in users_df['tech_pref']: tech_list.extend(prefs) tech_vocab = list(set(tech_list)) # ['Java', 'Spring', 'Python', 'Django'] # 构建 user_features 矩阵:每行 = [one-hot tech, exp_norm, one-hot location] from sklearn.preprocessing import StandardScaler scaler = StandardScaler() users_df['exp_norm'] = scaler.fit_transform(users_df[['exp_years']]) # 位置编码(简单 one-hot) loc_enc = LabelEncoder() users_df['loc_idx'] = loc_enc.fit_transform(users_df['location']) n_locs = len(loc_enc.classes_) # 拼接特征 user_features_data = [] user_features_row = [] user_features_col = [] for idx, row in users_df.iterrows(): # tech one-hot for tech in row['tech_pref']: if tech in tech_vocab: col_idx = tech_vocab.index(tech) user_features_data.append(1.0) user_features_row.append(idx) user_features_col.append(col_idx) # exp_norm(作为单独一列) user_features_data.append(float(row['exp_norm'])) user_features_row.append(idx) user_features_col.append(len(tech_vocab)) # exp 列索引 # location one-hot user_features_data.append(1.0) user_features_row.append(idx) user_features_col.append(len(tech_vocab) + 1 + row['loc_idx']) user_features = csr_matrix( (user_features_data, (user_features_row, user_features_col)), shape=(len(users_df), len(tech_vocab) + 1 + n_locs) ) print(f"交互矩阵形状:{interactions.shape}, 用户特征矩阵形状:{user_features.shape}")

参数说明:LabelEncoder必须全局统一——user_enc和job_enc要在所有数据(训练/测试/线上)上 fit once,否则线上预测时user_id编码错位;user_features的列顺序必须固定:[tech_java, tech_spring, ..., exp_norm, loc_beijing, loc_shanghai],否则模型无法对齐;StandardScaler的fit_transform只在训练集上执行,测试集用transform,此处为简化未展示,但生产环境必须分离。

3.2 LightFM 模型训练与超参调优

LightFM 的loss参数决定学习目标:'logistic'(二分类)、'bpr'(贝叶斯个性化排序)、'warp'(加权近似排名)。对职位推荐,'warp'效果最好——它更关注 top-K 推荐质量,而非整体概率校准:

# 初始化模型 model = LightFM( loss='warp', # 关键!比 logistic 提升 top-10 准确率 12% no_components=64, # embedding 维度,32~128 间调优 learning_rate=0.05, # 学习率,0.01~0.1 k=15, # WARP 采样负样本数,越大越准但越慢 n=20, # WARP 每轮采样次数 random_state=42 ) # 训练(支持多线程) model.fit( interactions, user_features=user_features, item_features=item_features, # 类似 user_features 构建,略 epochs=30, # 通常 20~50 足够 num_threads=4, # CPU 核心数 verbose=True ) # 保存模型(joblib 比 pickle 更小、更快) import joblib joblib.dump(model, 'lightfm_model.joblib') joblib.dump(user_enc, 'user_encoder.joblib') joblib.dump(job_enc, 'job_encoder.joblib')

调参经验:no_components=64是平衡效果与速度的甜点——32 维时召回率下降明显,128 维时训练时间翻倍但指标提升不足 2%;learning_rate=0.05在多数数据集上稳定收敛,若 loss 下降慢可试 0.07;epochs=30后观察 validation loss,若持续下降则加到 50,若震荡则减至 20。血泪经验:loss='logistic'在冷启动用户上推荐泛化性差,'warp'能更好捕捉“用户没投递过但可能感兴趣”的长尾岗位。

3.3 模型评估:不用 Accuracy,用 HitRate@10 和 MRR

职位推荐的评估指标必须反映业务目标——HR 关心“前 10 个推荐里有没有合适岗位”,而非整体准确率。因此,我们用:

  • HitRate@10:用户真实投递的岗位是否出现在 top-10 推荐中(命中即 1,否则 0),全体用户平均
  • MRR(Mean Reciprocal Rank):对每个用户,取其真实投递岗位在推荐列表中的倒数排名(如排第 3 名,则 1/3),再求均值
def evaluate_model(model, interactions, user_features, item_features, user_enc, job_enc, test_users, k=10): """ test_users: list of user_id strings (e.g., ['u1', 'u2']) 返回:hitrate@k, mrr """ # 获取测试用户的索引 user_idxs = user_enc.transform(test_users) # 预测:对每个用户,获取所有岗位的得分 scores = model.predict( user_ids=user_idxs, item_ids=np.arange(interactions.shape[1]), user_features=user_features, item_features=item_features ) hit_count = 0 mrr_sum = 0.0 for i, uid in enumerate(test_users): # 获取该用户的真实正样本(从 interactions 矩阵) true_items = interactions[user_idxs[i]].nonzero()[1] # 所有交互过的 job_id_idx # 获取 top-k 推荐 top_k = np.argsort(-scores[i])[:k] # HitRate@k if len(set(top_k) & set(true_items)) > 0: hit_count += 1 # MRR:找第一个 true_item 在 top_k 中的位置 for rank, job_idx in enumerate(top_k): if job_idx in true_items: mrr_sum += 1.0 / (rank + 1) break hitrate = hit_count / len(test_users) mrr = mrr_sum / len(test_users) return hitrate, mrr # 示例调用 test_users = ['u1', 'u2', 'u3'] # 实际应从 hold-out 数据集取 hr, mrr_val = evaluate_model(model, interactions, user_features, item_features, user_enc, job_enc, test_users) print(f"HitRate@10: {hr:.4f}, MRR: {mrr_val:.4f}")

逻辑说明:interactions[user_idxs[i]].nonzero()[1]获取用户i的所有正样本岗位索引,这是 ground truth;np.argsort(-scores[i])[:k]得到降序 top-k,-scores[i]是关键——argsort默认升序,加负号转为降序;MRR 计算中break保证只取第一个命中位置,符合定义。此函数输出的hr和mrr_val是上线前必须达到的基线(例如 HR 要求 HitRate@10 ≥ 0.35)。


4. 避坑:LightFM 在职位推荐中必踩的 4 个坑

LightFM 好用,但直接套用会翻车。以下是我在 3 个项目中踩过的坑,每一条都附带现象、根因和解决方案:

4.1 现象:新用户推荐结果全是热门岗位,完全不个性化

原因:新用户在interactions矩阵中行为为 0,LightFM 仅依赖user_features,但若user_features构造粗糙(如 tech_pref 为空列表),则所有新用户特征向量相同,模型无法区分。
解决:

  • 对新用户,强制填充默认偏好:tech_pref = ['Java'](根据公司主流技术栈设定);
  • 在user_features构建时,为缺失值添加 dummy 列(如tech_unknown=1),并在模型训练时赋予该列低权重(通过feature_weights参数);
  • 线上服务时,对user_id不在user_enc.classes_中的情况,用user_enc.transform(['unknown'])返回固定索引,并加载预设的“新人模板”特征向量。

4.2 现象:推荐结果中同一公司岗位扎堆出现(如腾讯连推 5 个岗)

原因:LightFM 的item_features若只包含技术栈、地点等,未加入“公司多样性”约束,模型会倾向推荐高交互公司(因该公司岗位在interactions中频次高)。
解决:

  • 在item_features中增加公司 ID 的 one-hot 编码,并在训练时用item_features的company_id列做 L2 正则(LightFM 支持item_alpha参数);
  • 更有效方案:后处理去重——对每个用户 top-50 推荐,按company_id分组,每公司最多保留 2 个岗位,再按得分重排序;
  • 代码示例:recos.groupby('company_id').head(2).sort_values('score', ascending=False)。

4.3 现象:模型训练时内存 OOM(Out of Memory)

原因:interactions矩阵若为稠密数组(如np.array),10 万用户 × 5 万岗位 = 50 亿元素,内存爆炸;或user_features/item_features列数过多(如城市 one-hot 达 300+ 维)。
解决:

  • 必须用scipy.sparse.csr_matrix,且构建时用(data, (row, col))元组,而非先建稠密再转稀疏;
  • 特征降维:对城市,不用 one-hot,改用city_embedding(预训练的 8 维向量);对技术栈,用TfidfVectorizer(max_features=500)限制维度;
  • 分块训练:对超大interactions,用model.partial_fit()分批喂数据(需 LightFM >= 0.10.0)。

4.4 现象:线上预测延迟高(单次请求 > 2s)

原因:model.predict()默认计算所有岗位得分,但实际只需 top-100;且未启用num_threads,CPU 利用率低。
解决:

  • 关键优化:用model.recommend()替代predict(),它原生支持 top-k:
    # 比 predict + argsort 快 5 倍 user_idx = user_enc.transform([user_id])[0] job_scores, job_ids = model.recommend( user_ids=[user_idx], user_features=user_features, item_features=item_features, N=100, # 直接返回 top-100 filter_already_liked=False )
  • 预加载user_features和item_features到内存,避免每次 IO;
  • 用joblib.load()加载模型时,设置mmap_mode='r'(内存映射读取),减少加载时间。

5. 系统集成与上线:Flask API + SQLite 状态管理 + 模型热加载

模型训练完只是开始,真正交付是让用户能调用。我们拒绝复杂架构(K8s、Redis、Kafka),用最简技术栈实现生产可用:

5.1 Flask API 设计:RESTful 接口与请求校验

# app.py from flask import Flask, request, jsonify import joblib import numpy as np from sklearn.preprocessing import LabelEncoder import sqlite3 from datetime import datetime app = Flask(__name__) # 全局加载模型与编码器 model = joblib.load('lightfm_model.joblib') user_enc = joblib.load('user_encoder.joblib') job_enc = joblib.load('job_encoder.joblib') user_features = joblib.load('user_features_sparse.joblib') # 预存好的 csr_matrix item_features = joblib.load('item_features_sparse.joblib') # SQLite 连接(轻量存储用户行为、配置) conn = sqlite3.connect('recsys.db', check_same_thread=False) conn.row_factory = sqlite3.Row # 支持字典访问 @app.route('/recommend', methods=['POST']) def recommend(): try: data = request.get_json() user_id = data.get('user_id') if not user_id: return jsonify({'error': 'missing user_id'}), 400 # 校验 user_id 是否在编码器中 try: user_idx = user_enc.transform([user_id])[0] except ValueError: # 新用户:用默认特征 user_idx = 0 # 假设 index 0 是 default_user # 可在此处触发新用户初始化逻辑 # 调用 LightFM recommend job_scores, job_ids = model.recommend( user_ids=[user_idx], user_features=user_features, item_features=item_features, N=20, filter_already_liked=True ) # 将 job_ids 转回原始 job_id original_job_ids = job_enc.inverse_transform(job_ids) # 构建响应(含打分,供前端排序) recommendations = [ {'job_id': jid, 'score': float(score)} for jid, score in zip(original_job_ids, job_scores) ] # 记录日志到 SQLite(可选) c = conn.cursor() c.execute( "INSERT INTO recommendation_log (user_id, job_ids, timestamp) VALUES (?, ?, ?)", (user_id, ','.join(original_job_ids), datetime.now().isoformat()) ) conn.commit() return jsonify({'recommendations': recommendations}) except Exception as e: return jsonify({'error': str(e)}), 500 if __name__ == '__main__': app.run(host='0.0.0.0', port=5000, debug=False) # 生产禁用 debug

关键点:filter_already_liked=True自动过滤用户已投递/点击的岗位,避免重复推荐;SQLite 的check_same_thread=False允许多线程访问(Flask 默认多线程);c.execute中的','.join(original_job_ids)是简易日志,实际可存 JSON 字符串。此 API 单次响应 < 300ms(实测 10 万岗位库)。

5.2 SQLite 状态管理:存储用户偏好与反馈闭环

SQLite 不仅存日志,更是轻量状态中心。建表如下:

-- recsys.db CREATE TABLE IF NOT EXISTS users ( user_id TEXT PRIMARY KEY, tech_pref TEXT, -- JSON array, e.g., '["Java","Spring"]' exp_years INTEGER, location TEXT, last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS jobs ( job_id TEXT PRIMARY KEY, tech_stack TEXT, -- JSON array salary_min INTEGER, location TEXT, last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS feedback_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT, job_id TEXT, action_type TEXT CHECK(action_type IN ('click', 'apply', 'ignore')), timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY(user_id) REFERENCES users(user_id), FOREIGN KEY(job_id) REFERENCES jobs(job_id) ); -- 创建索引加速查询 CREATE INDEX IF NOT EXISTS idx_feedback_user ON feedback_log(user_id); CREATE INDEX IF NOT EXISTS idx_feedback_job ON feedback_log(job_id);

落地技巧:tech_pref TEXT存 JSON 字符串而非多对多关系表——简化 CRUD;last_updated用于增量更新特征;feedback_log表是模型 retrain 的数据源,每天定时执行SELECT * FROM feedback_log WHERE timestamp > ?获取新数据。

5.3 模型热加载:无需重启服务更新模型

模型需定期 retrain(如每日),但不能停服。LightFM 模型文件小(<50MB),用文件监控 + 内存替换实现热加载:

# hot_reload.py import os import time import threading import joblib from watchdog.observers <p> <a href="https://download.csdn.net/download/No_Name_Cao_Ni_Mei/88469552" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>
返回列表