一套代码拿下三个技术栈考研院校推荐与分数线预测系统的完整落地复盘做计算机毕业设计最怕的不是题目难而是技术栈堆了一堆最后做出来却像个玩具。今天这个项目刚好是反例Hadoop、PySpark、Scrapy、推荐系统、分数线预测五大关键词全占但每一个模块都落到了实处不是光写个“集成”两个字糊弄过去。我基于一套真实的毕设方案把整体架构、采集逻辑、数据清洗、预测模型、推荐算法、部署排坑全部串一遍希望能给正在选型或者已经开题的同学一个可以直接参考的蓝本。这套系统能做什么一句话总结用Scrapy爬取考研相关数据院校信息、历年分数线、招生人数、报录比等存入Hadoop HDFS做持久化再用PySpark做ETL清洗和特征加工最后用协同过滤做院校推荐、用回归类模型做分数线预测。产物包括完整源码、毕业论文文档、答辩PPT以及一套演示讲解流程。适合正在准备毕业设计、需要完整技术栈演示、或者想快速上手大数据推荐系统组合的读者参考。先说明一点下文尽量围绕“如何从零复现这套系统”来写所有步骤都是基于实际可运行的方式展开。1. 项目整体设计与技术选型思路1.1 为什么是HadoopPySparkScrapy这套组合答辩时最容易被问的一个问题就是为什么要用这么多东西是不是为了凑技术栈这个问题答不好前面所有工作量都会打折扣。所以选型理由必须提前想清楚。先说Scrapy。考研数据分散在学校官网、研招网、各类考研论坛和第三方聚合站点天然适合用爬虫采集。Scrapy框架本身是异步的单机就能跑出不错的并发量内置的Item Pipeline、Middleware机制也方便做数据清洗和反爬控制比requestsBeautifulSoup手写一套要规范得多。而且后续如果要扩展到分布式爬虫Scrapy配合Scrapy-Redis也能比较平滑地升级。然后是Hadoop。这套项目里的场景是爬虫产出的原始数据量不大但格式极其混乱有的是JSON接口返回有的是HTML表格有的直接是PDF附件。这种多源异构数据需要一个统一存储层HDFS正好承担这个角色——把不同来源、不同格式的原始文件原样丢进去后续处理时再统一解析提供NameNode的目录隔离和多副本冗余机制。更重要的是毕业设计中引入HDFS可以在存储层面体现“大数据”的系统分层也让后面的PySpark分析有数据源可读。最后是PySpark。推荐系统和分数线预测的核心工作都在特征工程和模型训练而特征工程的对象恰好是已经落到HDFS上的结构化数据通过PySpark读取再做分布式转换、聚合和样本集生成是最顺理成章的路径。同时PySpark自带MLlib线性回归、随机森林、协同过滤ALS都有现成实现不用自己手写算法省了大量时间和调试成本。一句话概括这套选型的逻辑Scrapy负责多源采集HDFS负责统一的分布式存储底座PySpark则在存储之上做批处理分析、特征工程和模型训练三者各有分工没有一个是多余的。1.2 模块划分与数据流走向整个系统按数据流向可以拆成五个模块爬虫采集模块Scrapy负责从数据源抓取原始数据产出标准化JSON或CSV文件。数据上传与存储模块HDFS Shell命令或Python hdfs客户端把采集结果上传到HDFS指定目录。数据清洗与分析模块PySpark读取HDFS上的原始数据完成去重、缺失值填充、字段类型转换等处理。推荐与预测模块PySpark MLlib、或自研Python算法基于清洗后的数据构建推荐模型和预测模型。可视化与展示模块Flask/FastAPI ECharts提供查询页面、院校推荐结果展示和分数线预测结果展示。实际开发时建议按“先打通单机流程再容器化部署”的顺序来做。核心流程打通之后再逐步加入Docker、定时调度比如Azkaban或简单的Crontab脚本等内容提升项目的完整度。2. 数据采集层Scrapy爬虫的架构设计与反爬策略2.1 目标数据源与字段设计考研数据采集合不合适直接决定后面推荐和预测的效果。我建议至少包含以下几类数据院校基础信息学校名称、所在省份、城市、院校类型985/211/双一流、是否自划线。招生专业信息专业代码、专业名称、学院名称、学位类型学硕/专硕、拟招生人数、考试科目。历年复试分数线年份、专业代码、单科线政治/外语/业务课一/业务课二、总分线。报名录取数据报考人数、录取人数、报录比。这些数据在目标网站上通常分布在不同的页面结构里所以Scrapy里的Item设计成下面这样会比较直观# items.py import scrapy class UniversityItem(scrapy.Item): school_name scrapy.Field() # 学校名称 province scrapy.Field() # 省份 city scrapy.Field() # 城市 is_985 scrapy.Field() # 是否985 is_211 scrapy.Field() # 是否211 is_double_first_class scrapy.Field() # 是否双一流 is_autonomous scrapy.Field() # 是否自划线 class MajorScoreItem(scrapy.Item): school_name scrapy.Field() major_code scrapy.Field() major_name scrapy.Field() year scrapy.Field() total_score scrapy.Field() # 总分线 politics_score scrapy.Field() # 政治单科线 english_score scrapy.Field() # 外语单科线 subject1_score scrapy.Field() # 业务课一 subject2_score scrapy.Field() # 业务课二 enroll_count scrapy.Field() # 拟招生人数字段命名统一用英文字母下划线后续写Spark SQL时会省很多事。2.2 动态页面和反爬的几种应对方式实际爬取中最大的坑是数据根本不在静态HTML里。有些研招官网的分数线和专业目录是JavaScript异步加载的直接用Scrapy的Request拿不到内容这时候有同学就卡住了。我的做法分三步先用Chrome DevTools的Network面板找真实的XHR接口地址。很多所谓的动态加载其实都是JSON接口直接请求接口比渲染页面要容易得多。如果接口有加密参数再考虑使用Selenium或Playwright配合Scrapy进行动态渲染抓取。但这种方法非常耗资源建议只用在关键页面上不要全站启用。遇到响应内容里正文被转义的情况一定要在Pipeline里做HTML实体反转义和标签清理避免把nbsp;这种字符直接存进数据库。反爬方面基本的User-Agent轮换、代理IP池、请求频率限制、RetryMiddleware这些都得做。此外还有一个特别容易被忽略的点请求头里不带Referer容易被某些网站的WAF直接拒绝尤其是一些数据服务类站点。建议在Scrapy的默认Headers里统一加上。# settings.py DEFAULT_REQUEST_HEADERS { Accept: text/html,application/json,application/xhtmlxml,*/*, Accept-Language: zh-CN,zh;q0.9, Referer: https://yz.chsi.com.cn/, User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 } DOWNLOAD_DELAY 1.2 # 全局请求间隔防止被封 CONCURRENT_REQUESTS 8 # 并发数不宜过高2.3 Scrapy Item Pipeline的清洗逻辑Pipeline不是只用来存数据的它是一个非常适合做“数据入库前清洗”的中间层。每个Item在进入Pipeline时可完成去空白字符和HTML标签如果用了Selector的xpath/extract通常不会有html标签但要注意getall()后join的换行符。统一数值字段格式比如把“50人”转成整数50“—”、“暂无”统一替换为None。将没用的Unicode字符全角空格、零宽空格等全部strip掉这种问题在复制网页内容时特别常见。# pipelines.py import re class CleanDataPipeline: def process_item(self, item, spider): # 清洗字符串字段去空格、去全角空格、去换行 for field in item.fields: if item.get(field) is None: continue if isinstance(item[field], str): item[field] re.sub(r[\s\u3000], , item[field]) item[field] item[field].replace(—, ).replace(暂无, ) if item[field] : item[field] None # 数值字段转int for field in [total_score, politics_score, english_score, subject1_score, subject2_score, enroll_count]: try: if item.get(field) is not None: item[field] int(float(item[field])) except (ValueError, TypeError): item[field] None return item清洗完之后每个Item就是一个干净的字典对象序列化成JSON行存成文件最后再统一批量上传到HDFS会比每条数据都调一次HDFS客户端高效得多。3. 数据存储与分析层Hadoop集群搭建与PySpark清洗3.1 Hadoop环境搭建到底要不要上集群很多同学一看到“分布式”就想着搞三台虚拟机内存8G都不到还要开三个节点结果卡到怀疑人生。实际做毕业设计我的建议是伪分布式完全够用。伪分布式模式就是在一台机器上同时启动NameNode、DataNode、ResourceManager、NodeManager进程完整走一遍HDFS上传、下载、目录操作的流程也支持后续PySpark读取HDFS文件。配置非常简单需要改的文件就三个# 1. core-site.xml configuration property namefs.defaultFS/name valuehdfs://localhost:9000/value /property /configuration # 2. hdfs-site.xml configuration property namedfs.replication/name value1/value /property property namedfs.namenode.name.dir/name value/usr/local/hadoop/data/namenode/value /property property namedfs.datanode.data.dir/name value/usr/local/hadoop/data/datanode/value /property /configuration # 3. yarn-site.xml configuration property nameyarn.nodemanager.aux-services/name valuemapreduce_shuffle/value /property /configuration配置完成后先格式化NameNode再启动服务hdfs namenode -format start-dfs.sh start-yarn.sh jps看到NameNode、DataNode、ResourceManager、NodeManager四个进程都活着就算伪分布式搭建成功。用hdfs dfs -mkdir -p /user/kaoyan/rawdata建好目录把爬虫产出的JSON文件上传上去即可。还有一种更省事的方案用Docker起单节点的Hadoop镜像。社区里有很多现成的hadoop镜像比如bde2020/hadoop-namenode配合bde2020/hadoop-datanodedocker-compose直接拉起一个单节点集群比自己装要快很多后续清理环境也方便。适合只想快速跑通全流程的同学。3.2 PySpark读取HDFS与数据清洗流程PySpark读取HDFS上的JSON数据有两种方式。如果每个JSON文件里是一个数组可以用spark.read.json如果每行是一个独立JSON对象可以用spark.read.text配合from_json。我比较建议爬虫端直接写出JSON Line格式每行一个JSON对象这样Spark读取最方便# etl_clean.py from pyspark.sql import SparkSession from pyspark.sql.functions import col, when, isnan, isnull, udf from pyspark.sql.types import StringType, IntegerType, DoubleType spark SparkSession.builder \ .appName(KaoyanDataClean) \ .master(local[*]) \ .getOrCreate() # 读取HDFS上的原始数据 df spark.read.json(hdfs://localhost:9000/user/kaoyan/rawdata/*.json) print(原始数据量:, df.count()) # 查看数据质量 df.select([count(when(isnull(c) | isnan(c), c)).alias(c) for c in df.columns]).show()清洗流程一般包括删除全字段重复的行使用dropDuplicates()。统一学校名称比如“北京大学”、“北大”要统一成同一个标准名否则后续聚合会出大问题。过滤无效记录总分线为空且单科线全为空的行建议直接删掉对预测和推荐没有价值。标准化学历类型把“学硕”、“学术型硕士”、“学术学位”统一映射成学术型。清洗完成后的DataFrame可以直接注册成临时表用Spark SQL跑一些核心指标比如“2023年各省份平均分数线”、“985高校各专业平均报录比”把这些聚合结果输出成CSV供后续模型使用同时也可以作为毕设里分析模块的展示数据。# 示例按省份和年份统计平均总分线 df.createOrReplaceTempView(score_info) agg_df spark.sql( SELECT province, year, AVG(total_score) AS avg_total_score FROM score_info WHERE total_score IS NOT NULL GROUP BY province, year ORDER BY province, year ) agg_df.show(20)3.3 一个容易被忽略的坑整数与空值爬虫清洗环节最容易踩的一个坑是分数线上传后Spark读进来发现字段类型变成了字符串因为原始JSON里可能是空字符串和数字混存。建议在Spark侧用cast()显式指定类型同时把空字符串统一转成Nonefrom pyspark.sql.types import IntegerType df df.withColumn(total_score, when(col(total_score) , None) .otherwise(col(total_score).cast(IntegerType())))这一步做完后续算平均值、训练模型时才不会出现类型报错或者自动把空值当0算的问题。4. 核心算法考研分数线预测模型的实现4.1 分数线预测的目标定义与特征选择分数线预测的核心目标是什么一句话给定院校、专业、年份预测该专业复试总分线。这里涉及两类问题有足够历史数据时可以用监督学习线性回归、随机森林、XGBoost。历史数据稀疏时可以用时间序列外推直接取近三年均值再加一点浮动权重。考虑到毕设周期建议以监督学习为主时间序列作为对照和补充。特征设计是模型的灵魂不是把年份、学校名称丢进去就完了。我整理了一份可直接用的特征清单院校类型是否985、是否211、是否双一流用0/1编码。省份特征该省份近五年平均分数线需要按目标年份之前的数据聚合避免数据泄漏。专业热度该专业近三年报名人数增长率、录取人数。学校办学层次头部高校分数线通常在基准线上有一定溢价。年份序号把年份转成序号比如2020→0, 2021→1…让模型能够学到时间趋势。4.2 用PySpark MLlib训练回归模型PySpark的MLlib里提供了RandomForestRegressor和LinearRegression的实现训练流程很标准# train_model.py from pyspark.ml.feature import VectorAssembler, StringIndexer, OneHotEncoder from pyspark.ml.regression import RandomForestRegressor from pyspark.ml.evaluation import RegressionEvaluator from pyspark.ml import Pipeline # 准备特征列 feature_cols [is_985, is_211, is_double_first_class, province_avg_score, apply_growth_rate, enroll_count, year_index] assembler VectorAssembler(inputColsfeature_cols, outputColfeatures) rf RandomForestRegressor( featuresColfeatures, labelColtotal_score, numTrees100, maxDepth10, seed42 ) pipeline Pipeline(stages[assembler, rf]) # 划分训练集和测试集 train_df, test_df df.randomSplit([0.8, 0.2], seed42) # 训练 model pipeline.fit(train_df) # 预测 predictions model.transform(test_df) # 评估 evaluator RegressionEvaluator(labelColtotal_score, predictionColprediction, metricNamermse) rmse evaluator.evaluate(predictions) print(fRMSE: {rmse:.2f})平均绝对误差MAE也可以作为第二个指标。考研总分线一般在250~400之间波动RMSE如果能控制在10分以内就说明模型有实际参考价值如果RMSE超过25分基本只能算玩具模型答辩时老师一眼就能看出问题。4.3 数据泄漏问题务必小心特征工程里最容易犯的错就是数据泄漏——把预测目标当年的信息混进了特征。比如预测2023年的分数线就不能用2023年的报考人数、2023年的平均分去构造“省份平均分特征”否则训练时模型表现很好对未来的预测却完全失效。解决办法在构造“历史统计类特征”时一定要用目标年份之前的数据。比如预测2023年分数只能用2022年及以前的数据去计算省份平均分、专业热度等特征。可以封装成时间窗口函数def build_history_features(df, target_year): # 只取 target_year 之前的数据 history df.filter(col(year) target_year) avg_by_province history.groupBy(province).avg(total_score).withColumnRenamed( avg(total_score), province_avg_score) return avg_by_province这一步在代码上不算复杂但值得在论文里单独写一小节体现你对统计建模的理解深度。5. 推荐系统的实现路径协同过滤和基于特征的回退策略5.1 数据稀疏下的协同过滤方案考研院校推荐系统和电商推荐不同用户没有丰富的“点击、购买、评分”行为数据。那协同过滤用什么算答案是把用户咨询/收藏的院校列表、搜索记录、筛选条件转化成隐式反馈评分。举例用户A收藏了“浙江大学 计算机技术”、“南京大学 软件工程”搜索过“杭州 计算机”那么系统可以构造一个用户-院校评分矩阵行为“1”没有历史行为的院校填“0”。再用Spark MLlib的ALS做协同过滤# recommend.py from pyspark.ml.recommendation import ALS from pyspark.ml.evaluation import RegressionEvaluator als ALS( userColuser_id, itemColschool_id, ratingColrating, rank10, maxIter15, regParam0.1, coldStartStrategydrop ) model als.fit(train_ratings) # 为每个user推荐top10院校 user_recs model.recommendForAllUsers(10) user_recs.show()这里有一个重要配置coldStartStrategydrop否则预测新用户或者新院校时会产生NaN导致下游任务直接报错。5.2 冷启动和业务规则兜底大部分访问系统的用户都没有历史行为纯协同过滤就是空转。我的建议是增加一个基于规则的推荐兜底用户选择了省份优先推荐该省份内排名最高的院校。用户输入了目标专业推荐该专业招生人数最多、报录比最友好的院校。用户没有输入任何条件走热门推荐展示近三年报考热度最高的10所院校。把“规则兜底”的结果和“协同过滤”的结果做简单的加权融合比如规则兜底占据70%权重、协同过滤结果占据30%这样系统一开始就有推荐内容可展示而不是空白。为了在毕设里体现出推荐系统的“个性”可以加一个模拟用户画像的模块用问卷让用户选择偏好的省份、院校类型985/211/双一流、专业方向和就业城市偏好然后把问卷结果转成筛选条件和初始评分再进入推荐流程。这个设计在论文里很好写答辩现场演示的效果也直观。5.3 Web端展示如何和推荐结果打通推荐系统的展示层建议用Flask写页面不要搞得太复杂一个查询页、一个推荐结果页、一个分数线预测页、一个数据统计大屏即可。PySpark模型是训练好的Web应用启动时加载模型文件和院校特征表当用户提交请求时后端先在内存里完成特征拼接和规则匹配再调用模型生成预测结果。这里要注意PySpark的PipelineModel可以用model.write().save(hdfs://...)保存Web服务里用PipelineModel.load()加载不依赖SparkSession重启。6. 常见问题与排查技巧实录6.1 PySpark启动报错“cannot run program python3 error13”这个问题在所有PySpark环境配置中极其高频。核心原因是Spark找不到合法的Python解释器error13通常是权限不足或文件不存在。排查步骤确认本机Python3路径which python3。确认有执行权限ls -l $(which python3)。在Spark配置环境变量里显式指定。export PYSPARK_PYTHON/usr/bin/python3 export PYSPARK_DRIVER_PYTHON/usr/bin/python3 source ~/.bashrcmacOS用户还有一种情况系统自带的python3是stub文件实际解释器在/Library/Frameworks/Python.framework/Versions/...里建议用python3 -c import sys; print(sys.executable)拿到真实路径再配置。6.2 Scrapy项目运行后只显示Finished不输出任何Item这是爬虫新手最常遇到的情况。代码没报错终端最后一行是INFO: Closing spider (finished)但Item数量为0。可能的原因和排查思路网站的页面结构和预期不符Xpath选择器什么都没匹配到。建议先在Scrapy Shell里手动测试。被反爬拦截返回了验证页或空页面。建议在爬虫里加一个日志输出打印response.status和response.text[:500]肉眼判断是否拿到真实数据。Item Pipeline里主动丢掉了所有数据比如清洗逻辑把字段全置空后直接return None。建议临时注释掉Pipeline的丢弃逻辑先确认Item能否顺利到达Pipeline。6.3 Hadoop集群NameNode启动失败或者DataNode起不来伪分布式环境下最常见的报错是“Incompatible clusterIDs”或“NameNode is not formatted”。解决方式很简单把hdfs-site.xml里配的namenode和datanode数据目录清空重新执行hdfs namenode -format再启动。注意format操作会清空所有HDFS数据模拟数据无所谓但如果里面有重要数据先备份。6.4 推荐系统结果为空的排查清单推荐结果为空通常是三个原因评分表为空检查ALS的输入DataFrame是否有至少一行rating0的数据。用户ID和院校ID不在同一个连续区间ALS对ID不敏感但如果训练集的itemCol只有10个推荐10个时可能出现结果不足调整recommendForAllUsers的数值。模型保存与加载过程中Schema不一致建议用model.write().overwrite().save()覆盖保存并确认加载时feature列名没变。6.5 大数据任务在本地跑得动一到服务器就OOM本地开发时Master一般设为local[*]数据量小内存压力可忽略。部署到服务器如果数据量增大OOM频发可以从这几个方向调优减少Spark任务的并行度spark.sql.shuffle.partitions默认200本地跑小数据建议调成10。增加Driver和Executor内存--driver-memory 2g --executor-memory 2g。控制采集文件大小爬虫端按天、按站点分割文件不要让单个JSON文件过大。7. 项目验收与答辩展示经验7.1 代码结构怎么组织才显得专业答辩时老师最先看的是项目目录一个清晰的结构能加分不少。我的建议是分以下模块kaoyan_system/ ├── docs/ # 毕业论文文档、开题报告 ├── ppt/ # 答辩PPT ├── crawler/ # Scrapy爬虫项目 ├── hadoop_conf/ # Hadoop配置文件 ├── etl/ # PySpark清洗脚本 ├── algorithm/ # 预测和推荐模型脚本 ├── web/ # Flask Web应用 └── README.md # 项目说明重点写清楚运行步骤7.2 答辩现场最容易加分又可能翻车的场景加分点现场演示爬虫运行一次展示爬下来的数据实时写入HDFS再跑一次PySpark脚本显示清洗前后的数据量对比。这两步比任何PPT都更有说服力。翻车点现场网络不好爬虫死活不出数据。一定要准备一份本地mock数据和离线运行脚本确保即使断网也能完成全流程演示。7.3 想扩充成亮点还能加什么如果时间充裕强烈建议在现有基础上加一个“实时分数线看板”用Flask的WebSocket或简单的Ajax轮询去读HDFS上的最新数据前端用ECharts画一个折线图展示目标院校近五年分数趋势。这一块工作量不大但技术栈覆盖到了数据可视化完整度直接上一个档次。8. 写在最后的一些实操心得这个项目我前前后后跑了一个多月最大的体会是不要追求一步到位先把最小闭环跑通。也就是说先抓500条数据倒入HDFS用PySpark清洗简单训练一个回归模型看预测效果等这条路走通了再去扩充数据量、优化模型参数、增加页面功能。很多同学一上来就想抓几十万条数据结果爬虫被封、存储混乱、模型训练跑不动最后连演示都做不了。另外一个心得是在论文里别写太多虚的比如“本系统采用分布式大数据架构具有高可扩展性和高可靠性”这种话几乎每个答辩老师都会追问“具体高在哪里”。不如直接写清楚数据量、耗时、模型误差、推荐覆盖了多少院校用数字说话。如果你打算在此基础上做二次开发我建议优先把“分数线预测”模块做得更精细加入更多特征比如国家线趋势、学科门类、地区经济指数或者对比线性回归、随机森林、XGBoost三种模型的效果。这个方向既好写论文又容易出成果。最后祝各位顺利通过毕设答辩时不仅能讲清楚技术还能讲清楚为什么要选这套技术那才是真正的加分项。