Python大数据实战(七):Scrapy爬虫+随机森林------旅游景点票价预测全流程实战
文章目录
- Python大数据实战(七):Scrapy爬虫+随机森林------旅游景点票价预测全流程实战
- 前言
- 一、项目全景概览
-
- [1.1 项目目标](#1.1 项目目标)
- [1.2 技术路线架构图](#1.2 技术路线架构图)
- [1.3 前置知识要求](#1.3 前置知识要求)
- 二、环境准备与项目创建
-
- [2.1 安装依赖](#2.1 安装依赖)
- [2.2 创建 Scrapy 项目](#2.2 创建 Scrapy 项目)
- [三、数据采集:Scrapy 爬虫实现](#三、数据采集:Scrapy 爬虫实现)
- 四、数据预处理
-
- [4.1 加载数据](#4.1 加载数据)
- [4.2 处理景区等级](#4.2 处理景区等级)
- [4.3 处理热度值](#4.3 处理热度值)
- [4.4 区域字段拆分](#4.4 区域字段拆分)
- [4.5 票价和销量处理](#4.5 票价和销量处理)
- 五、探索性数据分析(EDA)
-
- [5.1 销量最多的前10个景点](#5.1 销量最多的前10个景点)
- [5.2 景区评级与省份的关系](#5.2 景区评级与省份的关系)
- [5.3 人数最多的10个5A级景区](#5.3 人数最多的10个5A级景区)
- [5.4 数据分布分析](#5.4 数据分布分析)
- 六、建模前数据预处理
-
- [6.1 删除冗余列](#6.1 删除冗余列)
- [6.2 One-Hot 编码](#6.2 One-Hot 编码)
-
- [错误案例 2:One-Hot 编码后特征爆炸](#错误案例 2:One-Hot 编码后特征爆炸)
- 七、建模:随机森林回归预测票价
-
- [7.1 模型原理简介](#7.1 模型原理简介)
- [7.2 模型训练](#7.2 模型训练)
- [7.3 模型预测与评估](#7.3 模型预测与评估)
- [7.4 特征重要性分析](#7.4 特征重要性分析)
- 八、常见错误与解决方案汇总
-
- [错误案例 1:Scrapy 爬虫被反爬拦截](#错误案例 1:Scrapy 爬虫被反爬拦截)
- [错误案例 2:One-Hot 编码导致特征维度爆炸](#错误案例 2:One-Hot 编码导致特征维度爆炸)
- [错误案例 3:matplotlib 中文乱码/负号显示异常](#错误案例 3:matplotlib 中文乱码/负号显示异常)
- [错误案例 4:缺失值导致模型训练报错](#错误案例 4:缺失值导致模型训练报错)
- 九、模型优化建议
-
- [9.1 参数调优方向](#9.1 参数调优方向)
- [9.2 网格搜索示例](#9.2 网格搜索示例)
- 十、总结与展望
-
- [10.1 本文总结](#10.1 本文总结)
- [10.2 关键收获](#10.2 关键收获)
- [10.3 下一篇预告](#10.3 下一篇预告)
- 参考链接
前言
"五一想去哪里玩?"
每到节假日,这个问题总会成为朋友群里的热门话题。但随之而来的就是另一个灵魂拷问------"门票多少钱?"
旅游景点票价不仅影响着我们的出行决策,也反映了一个景区的综合实力------级别越高、热度越大,票价往往也越高。那么,能否用数据科学的方法,通过景区的各项特征来预测票价呢?
答案是肯定的。本文将带你完成一个完整的"爬虫+数据分析+机器学习"综合项目:使用 Scrapy 框架 爬取"去哪儿"网站的热门旅游景点数据,经过数据清洗和特征工程后,使用 随机森林回归模型(RandomForestRegressor) 对景点票价进行预测。全文超过 8000 字,包含完整的可运行代码和详细的踩坑记录。
一、项目全景概览
1.1 项目目标
| 阶段 | 任务 | 技术栈 |
|---|---|---|
| 数据采集 | 爬取去哪儿网热门城市景点信息 | Scrapy 框架 |
| 数据清洗 | 处理缺失值、异常值、编码转换 | Pandas + NumPy |
| 数据分析 | 景区等级分布、销量排行、地域分布 | Matplotlib + Seaborn |
| 特征工程 | One-Hot 编码、特征选择 | Scikit-learn |
| 建模预测 | 使用随机森林回归预测票价 | RandomForestRegressor |
| 模型评估 | MSE、MAE 指标评估 | Scikit-learn Metrics |
1.2 技术路线架构图
#mermaid-svg-MaNqt5zGuPRWL75v{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-MaNqt5zGuPRWL75v .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-MaNqt5zGuPRWL75v .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-MaNqt5zGuPRWL75v .error-icon{fill:#552222;}#mermaid-svg-MaNqt5zGuPRWL75v .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-MaNqt5zGuPRWL75v .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-MaNqt5zGuPRWL75v .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-MaNqt5zGuPRWL75v .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-MaNqt5zGuPRWL75v .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-MaNqt5zGuPRWL75v .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-MaNqt5zGuPRWL75v .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-MaNqt5zGuPRWL75v .marker{fill:#333333;stroke:#333333;}#mermaid-svg-MaNqt5zGuPRWL75v .marker.cross{stroke:#333333;}#mermaid-svg-MaNqt5zGuPRWL75v svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-MaNqt5zGuPRWL75v p{margin:0;}#mermaid-svg-MaNqt5zGuPRWL75v .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-MaNqt5zGuPRWL75v .cluster-label text{fill:#333;}#mermaid-svg-MaNqt5zGuPRWL75v .cluster-label span{color:#333;}#mermaid-svg-MaNqt5zGuPRWL75v .cluster-label span p{background-color:transparent;}#mermaid-svg-MaNqt5zGuPRWL75v .label text,#mermaid-svg-MaNqt5zGuPRWL75v span{fill:#333;color:#333;}#mermaid-svg-MaNqt5zGuPRWL75v .node rect,#mermaid-svg-MaNqt5zGuPRWL75v .node circle,#mermaid-svg-MaNqt5zGuPRWL75v .node ellipse,#mermaid-svg-MaNqt5zGuPRWL75v .node polygon,#mermaid-svg-MaNqt5zGuPRWL75v .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-MaNqt5zGuPRWL75v .rough-node .label text,#mermaid-svg-MaNqt5zGuPRWL75v .node .label text,#mermaid-svg-MaNqt5zGuPRWL75v .image-shape .label,#mermaid-svg-MaNqt5zGuPRWL75v .icon-shape .label{text-anchor:middle;}#mermaid-svg-MaNqt5zGuPRWL75v .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-MaNqt5zGuPRWL75v .rough-node .label,#mermaid-svg-MaNqt5zGuPRWL75v .node .label,#mermaid-svg-MaNqt5zGuPRWL75v .image-shape .label,#mermaid-svg-MaNqt5zGuPRWL75v .icon-shape .label{text-align:center;}#mermaid-svg-MaNqt5zGuPRWL75v .node.clickable{cursor:pointer;}#mermaid-svg-MaNqt5zGuPRWL75v .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-MaNqt5zGuPRWL75v .arrowheadPath{fill:#333333;}#mermaid-svg-MaNqt5zGuPRWL75v .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-MaNqt5zGuPRWL75v .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-MaNqt5zGuPRWL75v .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-MaNqt5zGuPRWL75v .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-MaNqt5zGuPRWL75v .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-MaNqt5zGuPRWL75v .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-MaNqt5zGuPRWL75v .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-MaNqt5zGuPRWL75v .cluster text{fill:#333;}#mermaid-svg-MaNqt5zGuPRWL75v .cluster span{color:#333;}#mermaid-svg-MaNqt5zGuPRWL75v div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-MaNqt5zGuPRWL75v .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-MaNqt5zGuPRWL75v rect.text{fill:none;stroke-width:0;}#mermaid-svg-MaNqt5zGuPRWL75v .icon-shape,#mermaid-svg-MaNqt5zGuPRWL75v .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-MaNqt5zGuPRWL75v .icon-shape p,#mermaid-svg-MaNqt5zGuPRWL75v .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-MaNqt5zGuPRWL75v .icon-shape rect,#mermaid-svg-MaNqt5zGuPRWL75v .image-shape rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-MaNqt5zGuPRWL75v .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-MaNqt5zGuPRWL75v .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-MaNqt5zGuPRWL75v :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 去哪儿网 piao.qunar.com
Scrapy 爬虫
travel 爬虫文件
QunarItem 数据模型
Pipeline 保存 CSV
原始数据 CSV
数据预处理
处理景区等级
处理热度值
区域字段拆分
缺失值处理
探索性数据分析 EDA
销量Top10景点
等级与省份关系
5A景区人气排行
数据分布可视化
特征工程
删除冗余列
One-Hot编码
特征合并
随机森林建模
训练集/测试集划分
RandomForestRegressor
MSE/MAE评估
票价预测结果
1.3 前置知识要求
| 知识领域 | 具体要求 | 重要程度 |
|---|---|---|
| Python基础 | 函数、类、列表推导式 | ⭐⭐⭐⭐⭐ |
| Scrapy框架 | Item、Spider、Pipeline | ⭐⭐⭐⭐ |
| Pandas | DataFrame操作、数据清洗 | ⭐⭐⭐⭐ |
| 机器学习基础 | 回归模型、训练/测试划分 | ⭐⭐⭐ |
| 数据可视化 | Matplotlib 基础绘图 | ⭐⭐⭐ |
二、环境准备与项目创建
2.1 安装依赖
bash
# 安装 Scrapy 爬虫框架
pip install scrapy
# 安装数据处理库
pip install pandas numpy matplotlib seaborn
# 安装机器学习库
pip install scikit-learn
2.2 创建 Scrapy 项目
bash
# 创建 Scrapy 项目
scrapy startproject qunar
# 进入项目根目录
cd qunar
# 创建爬虫文件,目标域名为 piao.qunar.com
scrapy genspider travel piao.qunar.com
执行完成后,项目目录结构如下:
qunar/
├── qunar/
│ ├── __init__.py
│ ├── items.py # 数据模型定义
│ ├── middlewares.py # 中间件
│ ├── pipelines.py # 数据管道(保存逻辑)
│ ├── settings.py # 项目配置
│ └── spiders/
│ ├── __init__.py
│ └── travel.py # 爬虫逻辑
└── scrapy.cfg
三、数据采集:Scrapy 爬虫实现
3.1 页面分析
在编写爬虫之前,我们先分析目标页面的数据结构。
目标网址
去哪儿门票搜索页:https://piao.qunar.com/
需要采集的字段
| 字段名 | 含义 | 示例 |
|---|---|---|
| name | 景点名称 | 故宫博物院 |
| level | 景区级别 | 5A |
| area | 所在区域 | 北京-东城区 |
| hot | 热度值 | 98 |
| price | 票价(元) | 60 |
| num | 月销量 | 15230 |
3.2 定义 Item 数据模型
在 items.py 中定义数据结构:
python
import scrapy
class QunarItem(scrapy.Item):
"""去哪儿景点数据模型"""
name = scrapy.Field() # 景点名称
level = scrapy.Field() # 景区级别(5A/4A/3A等)
area = scrapy.Field() # 所在区域(省-市-区)
hot = scrapy.Field() # 热度值
price = scrapy.Field() # 票价(元)
num = scrapy.Field() # 月销量
3.3 编写爬虫逻辑
在 spiders/travel.py 中实现爬虫:
python
import scrapy
from qunar.items import QunarItem
class TravelSpider(scrapy.Spider):
"""去哪儿旅游景点爬虫"""
name = 'travel'
allowed_domains = ['piao.qunar.com']
start_urls = ['https://piao.qunar.com/']
# 热门旅游城市列表
cities = ['北京', '上海', '广州', '深圳', '杭州',
'成都', '重庆', '西安', '南京', '武汉']
def parse(self, response):
"""
解析景点列表页面
对每个热门城市采集前5页数据
"""
for city in self.cities:
for page in range(1, 6): # 每个城市采集5页
# 构造搜索URL(实际项目中需根据网站结构调整参数)
url = f'https://piao.qunar.com/ticket/list.htm?keyword={city}&page={page}'
yield scrapy.Request(
url=url,
callback=self.parse_list,
meta={'city': city, 'page': page}
)
def parse_list(self, response):
"""解析景点列表,提取字段信息"""
city = response.meta['city']
page = response.meta['page']
# 定位景点列表项(选择器需根据实际页面结构调整)
sight_items = response.css('.sight_item') # 示例选择器
for item in sight_items:
qunar_item = QunarItem()
# 提取各字段(实际选择器需根据页面结构调整)
qunar_item['name'] = item.css('.sight_name::text').get()
qunar_item['level'] = item.css('.sight_level::text').get()
qunar_item['area'] = item.css('.sight_area::text').get()
qunar_item['hot'] = item.css('.sight_hot::text').get()
qunar_item['price'] = item.css('.sight_price::text').get()
qunar_item['num'] = item.css('.sight_num::text').get()
yield qunar_item
3.4 反爬虫策略:Cookie 设置
⚠️ 踩坑记录:去哪儿网有较强的反爬机制,如果不设置 Cookie,请求会被拦截返回验证页面。
错误案例 1:未设置 Cookie 导致爬取失败
python
# ❌ 错误做法:直接请求,不设置 Cookie
# 结果:返回 302 重定向到验证页面,无法获取数据
解决方案 :在 settings.py 中配置 Cookie 和请求头:
python
# settings.py
# 关闭 Scrapy 默认的 Cookies 中间件
COOKIES_ENABLED = False
# 手动设置请求头,包含浏览器 Cookie
DEFAULT_REQUEST_HEADERS = {
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'cookie': '你的浏览器Cookie字符串' # 从浏览器开发者工具中复制
}
# 设置下载延迟,避免请求过快被封
DOWNLOAD_DELAY = 2 # 每次请求间隔2秒
# 启用随机 User-Agent(可选)
# DOWNLOADER_MIDDLEWARES = {
# 'scrapy.downloadermiddlewares.useragent.UserAgentMiddleware': None,
# 'scrapy_fake_useragent.middleware.RandomUserAgentMiddleware': 400,
# }
3.5 数据保存:Pipeline
在 pipelines.py 中实现数据保存逻辑:
python
import csv
import os
class QunarPipeline:
"""将爬取的数据保存到 CSV 文件"""
def __init__(self):
self.file = None
self.writer = None
def open_spider(self, spider):
"""爬虫启动时创建 CSV 文件并写入表头"""
output_dir = 'output'
if not os.path.exists(output_dir):
os.makedirs(output_dir)
self.file = open(f'{output_dir}/data.csv', 'w',
newline='', encoding='utf-8-sig')
self.writer = csv.writer(self.file)
# 写入表头
self.writer.writerow(['name', 'level', 'area', 'hot', 'price', 'num'])
def process_item(self, item, spider):
"""处理每条数据,写入 CSV"""
self.writer.writerow([
item.get('name', ''),
item.get('level', ''),
item.get('area', ''),
item.get('hot', ''),
item.get('price', ''),
item.get('num', '')
])
return item
def close_spider(self, spider):
"""爬虫关闭时关闭文件"""
if self.file:
self.file.close()
别忘了在 settings.py 中启用 Pipeline:
python
ITEM_PIPELINES = {
'qunar.pipelines.QunarPipeline': 300,
}
3.6 启动爬虫
bash
# 在 qunar 项目根目录下执行
scrapy crawl travel
四、数据预处理
爬取完成后,我们得到一个 data.csv 文件。但原始数据通常存在各种问题,需要进行清洗。
4.1 加载数据
python
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
# 设置中文字体,避免图表中文乱码
plt.rcParams['font.sans-serif'] = ['SimHei', 'DejaVu Sans']
plt.rcParams['axes.unicode_minus'] = False # 解决负号显示问题
# 加载数据
df = pd.read_csv('output/data.csv')
print(f"数据集大小: {df.shape}")
print(df.head())
print(df.info())
4.2 处理景区等级
景区等级字段可能包含空值或非标准格式,需要统一处理:
python
# 查看等级分布
print("等级分布:")
print(df['level'].value_counts(dropna=False))
# 处理等级字段
def clean_level(level_str):
"""
标准化景区等级
将 '5A级景区'、'AAAAA' 等统一为 '5A'
"""
if pd.isna(level_str) or level_str == '':
return '未知'
level_str = str(level_str).strip()
# 匹配 A 级景区
if '5A' in level_str or 'AAAAA' in level_str:
return '5A'
elif '4A' in level_str or 'AAAA' in level_str:
return '4A'
elif '3A' in level_str or 'AAA' in level_str:
return '3A'
elif '2A' in level_str or 'AA' in level_str:
return '2A'
elif '1A' in level_str or 'A' in level_str:
return '1A'
else:
return '其他'
df['level_clean'] = df['level'].apply(clean_level)
print("\n清洗后等级分布:")
print(df['level_clean'].value_counts())
4.3 处理热度值
热度字段可能包含非数字字符(如"98分"、"热门"等),需要提取数值:
python
# 处理热度字段
def clean_hot(hot_str):
"""
提取热度数值
处理 '98分'、'热门' 等非标准格式
"""
if pd.isna(hot_str):
return np.nan
hot_str = str(hot_str).strip()
# 尝试提取数字
import re
match = re.search(r'(\d+\.?\d*)', hot_str)
if match:
return float(match.group(1))
# 处理文字描述
if '热门' in hot_str:
return 90.0
elif '较热' in hot_str:
return 70.0
return np.nan
df['hot_clean'] = df['hot'].apply(clean_hot)
print(f"热度缺失值数量: {df['hot_clean'].isna().sum()}")
4.4 区域字段拆分
区域字段通常格式为"省-市-区",需要拆分为独立字段:
python
# 拆分区域字段
def split_area(area_str):
"""
将 '北京-东城区' 拆分为省份、城市、区县
"""
if pd.isna(area_str):
return pd.Series([np.nan, np.nan, np.nan])
parts = str(area_str).strip().split('-')
if len(parts) >= 3:
return pd.Series([parts[0], parts[1], parts[2]])
elif len(parts) == 2:
return pd.Series([parts[0], parts[1], np.nan])
else:
return pd.Series([parts[0], np.nan, np.nan])
df[['province', 'city', 'mini_city']] = df['area'].apply(split_area)
print("\n区域拆分结果:")
print(df[['area', 'province', 'city', 'mini_city']].head(10))
4.5 票价和销量处理
python
# 票价处理:去除 '¥'、'元' 等符号
df['price_clean'] = df['price'].astype(str).str.replace('¥', '').str.replace('元', '')
df['price_clean'] = pd.to_numeric(df['price_clean'], errors='coerce')
# 销量处理:去除 '月销'、'笔' 等文字
df['num_clean'] = df['num'].astype(str).str.replace('月销', '').str.replace('笔', '')
df['num_clean'] = pd.to_numeric(df['num_clean'], errors='coerce')
# 查看缺失值情况
print("\n缺失值统计:")
print(df[['price_clean', 'num_clean', 'hot_clean']].isna().sum())
# 用中位数填充缺失值
df['price_clean'].fillna(df['price_clean'].median(), inplace=True)
df['num_clean'].fillna(df['num_clean'].median(), inplace=True)
df['hot_clean'].fillna(df['hot_clean'].median(), inplace=True)
五、探索性数据分析(EDA)
5.1 销量最多的前10个景点
python
# 按销量排序,取前10
top10_sales = df.nlargest(10, 'num_clean')[
['name', 'level_clean', 'province', 'num_clean', 'price_clean']
]
print("📊 月销量 Top 10 景点:")
print(top10_sales.to_string(index=False))
# 可视化
plt.figure(figsize=(12, 6))
colors = plt.cm.viridis(np.linspace(0.2, 0.8, 10))
bars = plt.barh(top10_sales['name'], top10_sales['num_clean'], color=colors)
plt.xlabel('月销量')
plt.title('月销量 Top 10 景点')
plt.gca().invert_yaxis()
plt.tight_layout()
plt.show()
5.2 景区评级与省份的关系
python
# 交叉表:省份 × 等级
cross_table = pd.crosstab(df['province'], df['level_clean'])
print("📊 各省份景区等级分布:")
print(cross_table)
# 可视化热力图
plt.figure(figsize=(14, 8))
sns.heatmap(cross_table, annot=True, fmt='d', cmap='YlOrRd',
linewidths=0.5, cbar_kws={'label': '景区数量'})
plt.title('各省份景区等级分布热力图')
plt.xlabel('景区等级')
plt.ylabel('省份')
plt.tight_layout()
plt.show()
5.3 人数最多的10个5A级景区
python
# 筛选5A景区,按销量排序
top5a = df[df['level_clean'] == '5A'].nlargest(10, 'num_clean')[
['name', 'province', 'num_clean', 'price_clean', 'hot_clean']
]
print("📊 5A 景区人气 Top 10:")
print(top5a.to_string(index=False))
# 可视化
fig, ax1 = plt.subplots(figsize=(12, 6))
x = range(len(top5a))
bars = ax1.bar(x, top5a['num_clean'], color='steelblue', alpha=0.7, label='月销量')
ax1.set_xlabel('景点')
ax1.set_ylabel('月销量', color='steelblue')
ax1.set_xticks(x)
ax1.set_xticklabels(top5a['name'], rotation=45, ha='right')
ax2 = ax1.twinx()
ax2.plot(x, top5a['price_clean'], 'ro-', linewidth=2, label='票价')
ax2.set_ylabel('票价(元)', color='red')
plt.title('5A 景区人气 Top 10:销量 vs 票价')
fig.legend(loc='upper right', bbox_to_anchor=(0.9, 0.9))
plt.tight_layout()
plt.show()
5.4 数据分布分析
python
# 创建 2×2 子图
fig, axes = plt.subplots(2, 2, figsize=(14, 10))
# 景区等级分布
level_counts = df['level_clean'].value_counts()
axes[0, 0].pie(level_counts.values, labels=level_counts.index,
autopct='%1.1f%%', startangle=90)
axes[0, 0].set_title('景区等级分布')
# 热度分布
axes[0, 1].hist(df['hot_clean'], bins=30, color='coral', edgecolor='white', alpha=0.7)
axes[0, 1].set_xlabel('热度值')
axes[0, 1].set_ylabel('频数')
axes[0, 1].set_title('热度分布直方图')
axes[0, 1].axvline(df['hot_clean'].median(), color='red',
linestyle='--', label=f"中位数: {df['hot_clean'].median():.1f}")
axes[0, 1].legend()
# 价格分布
axes[1, 0].hist(df['price_clean'], bins=40, color='steelblue', edgecolor='white', alpha=0.7)
axes[1, 0].set_xlabel('票价(元)')
axes[1, 0].set_ylabel('频数')
axes[1, 0].set_title('票价分布直方图')
axes[1, 0].axvline(df['price_clean'].median(), color='red',
linestyle='--', label=f"中位数: {df['price_clean'].median():.1f}")
# 销量分布(对数坐标)
axes[1, 1].hist(df['num_clean'], bins=40, color='green', edgecolor='white', alpha=0.7)
axes[1, 1].set_xlabel('月销量')
axes[1, 1].set_ylabel('频数')
axes[1, 1].set_title('月销量分布直方图')
axes[1, 1].set_xscale('log') # 对数坐标,因为销量差异大
plt.tight_layout()
plt.show()
六、建模前数据预处理
在进入建模阶段之前,需要对数据进行最终的特征工程处理。
6.1 删除冗余列
python
# 删除建模不需要的列
# 'level_sum' 和 'num_cut' 是数据探索阶段生成的中间列
cols_to_drop = ['level', 'area', 'hot', 'price', 'num',
'name', 'level_sum', 'num_cut']
existing_cols = [c for c in cols_to_drop if c in df.columns]
df_model = df.drop(columns=existing_cols, errors='ignore')
print(f"删除冗余列后数据形状: {df_model.shape}")
print(df_model.columns.tolist())
6.2 One-Hot 编码
对分类变量(省份、城市、区县)进行 One-Hot 编码:
python
# 对分类变量进行 One-Hot 编码
categorical_cols = ['province', 'city', 'mini_city', 'level_clean']
existing_cat_cols = [c for c in categorical_cols if c in df_model.columns]
df_encoded = pd.get_dummies(
df_model,
columns=existing_cat_cols,
drop_first=True, # 避免虚拟变量陷阱
dtype=int
)
print(f"One-Hot 编码后数据形状: {df_encoded.shape}")
print(f"特征数量: {df_encoded.shape[1] - 1}") # 减去目标列
错误案例 2:One-Hot 编码后特征爆炸
python
# ❌ 错误做法:不设置 drop_first=True
# 结果:特征数量 = 原始特征 + 所有类别数
# 例如省份有30个 → 增加30列,导致维度灾难
# ✅ 正确做法:设置 drop_first=True
# 结果:特征数量 = 原始特征 + (类别数 - 1)
# 例如省份有30个 → 增加29列,避免多重共线性
df_encoded = pd.get_dummies(df_model, columns=categorical_cols,
drop_first=True, dtype=int)
七、建模:随机森林回归预测票价
7.1 模型原理简介
随机森林(Random Forest) 是一种集成学习算法,通过构建多棵决策树并取平均结果来进行预测。它的核心优势:
| 特性 | 说明 |
|---|---|
| 抗过拟合 | 多棵树投票/平均,降低单棵树的过拟合风险 |
| 处理高维数据 | 随机选择特征子集,适合 One-Hot 编码后的高维数据 |
| 特征重要性 | 可输出每个特征对预测的贡献度 |
| 无需特征缩放 | 基于决策树,不受量纲影响 |
7.2 模型训练
python
from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestRegressor
from sklearn.metrics import mean_squared_error, mean_absolute_error, r2_score
# 分离特征和标签
# 特征:除 price_clean 外的所有列
feature_cols = df_encoded.columns.difference(['price_clean'])
X = df_encoded[feature_cols].values
y = df_encoded['price_clean'].values
print(f"特征矩阵形状: {X.shape}")
print(f"标签向量形状: {y.shape}")
# 划分训练集和测试集(7:3)
X_train, X_test, y_train, y_test = train_test_split(
X, y,
test_size=0.3, # 30% 作为测试集
random_state=666 # 固定随机种子,保证结果可复现
)
print(f"训练集大小: {X_train.shape[0]}")
print(f"测试集大小: {X_test.shape[0]}")
# 创建随机森林回归模型
rf = RandomForestRegressor(
n_estimators=20, # 决策树数量
max_depth=7, # 最大深度,防止过拟合
random_state=666,
n_jobs=-1 # 使用所有 CPU 核心
)
# 训练模型
rf.fit(X_train, y_train)
print("✅ 模型训练完成!")
7.3 模型预测与评估
python
# 在测试集上进行预测
y_pred = rf.predict(X_test)
# 计算评估指标
mse = mean_squared_error(y_test, y_pred)
mae = mean_absolute_error(y_test, y_pred)
rmse = np.sqrt(mse)
r2 = r2_score(y_test, y_pred)
print("=" * 50)
print("📊 模型评估结果")
print("=" * 50)
print(f"MSE (均方误差): {mse:.2f}")
print(f"RMSE (均方根误差): {rmse:.2f}")
print(f"MAE (平均绝对误差): {mae:.2f}")
print(f"R² (决定系数): {r2:.4f}")
print("=" * 50)
# 预测值 vs 真实值可视化
plt.figure(figsize=(10, 8))
plt.scatter(y_test, y_pred, alpha=0.6, edgecolors='white', linewidth=0.5)
plt.plot([y_test.min(), y_test.max()],
[y_test.min(), y_test.max()],
'r--', linewidth=2, label='完美预测线')
plt.xlabel('真实票价(元)')
plt.ylabel('预测票价(元)')
plt.title(f'随机森林回归:预测值 vs 真实值\nR² = {r2:.4f}, MAE = {mae:.2f}')
plt.legend()
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
7.4 特征重要性分析
python
# 获取特征重要性
feature_importance = pd.DataFrame({
'feature': feature_cols,
'importance': rf.feature_importances_
}).sort_values('importance', ascending=False)
# 显示 Top 15 重要特征
print("📊 Top 15 重要特征:")
print(feature_importance.head(15).to_string(index=False))
# 可视化
plt.figure(figsize=(10, 8))
top_features = feature_importance.head(15)
plt.barh(range(len(top_features)), top_features['importance'], color='teal')
plt.yticks(range(len(top_features)), top_features['feature'])
plt.xlabel('特征重要性')
plt.title('随机森林特征重要性 Top 15')
plt.gca().invert_yaxis()
plt.tight_layout()
plt.show()
八、常见错误与解决方案汇总
错误案例 1:Scrapy 爬虫被反爬拦截
现象:运行爬虫后,返回的不是景点数据,而是验证码页面或空数据。
原因:去哪儿网检测到非浏览器请求,触发了反爬机制。
解决方案:
python
# 1. 在 settings.py 中设置 Cookie
COOKIES_ENABLED = False
DEFAULT_REQUEST_HEADERS = {
'cookie': '从浏览器复制的Cookie字符串'
}
# 2. 设置下载延迟
DOWNLOAD_DELAY = 2 # 2秒间隔
# 3. 使用随机 User-Agent
# pip install scrapy-fake-useragent
DOWNLOADER_MIDDLEWARES = {
'scrapy.downloadermiddlewares.useragent.UserAgentMiddleware': None,
'scrapy_fake_useragent.middleware.RandomUserAgentMiddleware': 400,
}
错误案例 2:One-Hot 编码导致特征维度爆炸
现象:编码后特征从几十列变成几百列,模型训练极慢。
原因 :未设置 drop_first=True,每个分类变量都保留了全部类别。
解决方案:
python
# ✅ 正确:drop_first=True 避免虚拟变量陷阱
df_encoded = pd.get_dummies(df, columns=categorical_cols,
drop_first=True, dtype=int)
错误案例 3:matplotlib 中文乱码/负号显示异常
现象:图表中的中文显示为方框,负号显示为乱码。
原因:matplotlib 默认字体不支持中文,且负号处理有兼容问题。
解决方案:
python
# 设置中文字体
plt.rcParams['font.sans-serif'] = ['SimHei', 'DejaVu Sans']
# 解决负号显示问题
plt.rcParams['axes.unicode_minus'] = False
错误案例 4:缺失值导致模型训练报错
现象 :ValueError: Input contains NaN, infinity or a value too large for dtype('float64')
原因:数据中存在 NaN 或无穷值,sklearn 无法处理。
解决方案:
python
# 检查缺失值
print(df.isna().sum())
# 方法1:中位数填充
df.fillna(df.median(), inplace=True)
# 方法2:删除含缺失值的行
df.dropna(inplace=True)
# 方法3:使用 SimpleImputer
from sklearn.impute import SimpleImputer
imputer = SimpleImputer(strategy='median')
X = imputer.fit_transform(X)
九、模型优化建议
9.1 参数调优方向
| 参数 | 当前值 | 调优建议 |
|---|---|---|
| n_estimators | 20 | 增加到 100-200,通常能提升性能 |
| max_depth | 7 | 使用 GridSearchCV 搜索最优值(5-20) |
| min_samples_split | 默认2 | 增加到 5-10,防止过拟合 |
| min_samples_leaf | 默认1 | 增加到 2-5,平滑预测 |
9.2 网格搜索示例
python
from sklearn.model_selection import GridSearchCV
# 定义参数网格
param_grid = {
'n_estimators': [50, 100, 200],
'max_depth': [5, 7, 10, 15],
'min_samples_split': [2, 5, 10],
'min_samples_leaf': [1, 2, 4]
}
# 网格搜索
grid_search = GridSearchCV(
RandomForestRegressor(random_state=666),
param_grid,
cv=5, # 5折交叉验证
scoring='neg_mean_absolute_error',
n_jobs=-1,
verbose=1
)
grid_search.fit(X_train, y_train)
print(f"最佳参数: {grid_search.best_params_}")
print(f"最佳 MAE: {-grid_search.best_score_:.2f}")
十、总结与展望
10.1 本文总结
本文完成了一个完整的"爬虫+数据分析+机器学习"综合项目:
- 数据采集:使用 Scrapy 框架爬取去哪儿网旅游景点数据,涵盖名称、等级、区域、热度、票价、销量六大字段
- 数据清洗:处理了等级标准化、热度数值提取、区域拆分、缺失值填充等常见问题
- 数据分析:从销量排行、等级分布、地域分布、数据分布四个维度进行了可视化分析
- 特征工程:通过 One-Hot 编码将分类变量转化为模型可用的数值特征
- 建模预测:使用随机森林回归模型预测景点票价,并进行了全面的模型评估
10.2 关键收获
- Scrapy 爬虫的完整开发流程:Item → Spider → Pipeline → Settings
- 反爬虫策略:Cookie 设置、下载延迟、User-Agent 轮换
- 数据预处理的标准流程:缺失值 → 格式统一 → 编码转换
- 随机森林回归的调参与评估方法
10.3 下一篇预告
下一篇文章我们将进入气象数据分析项目,学习如何处理时间序列数据、气象可视化以及天气趋势预测。敬请期待!
参考链接
- Scrapy 官方文档 --- Scrapy 爬虫框架的完整使用指南
- Scikit-learn RandomForestRegressor --- 随机森林回归器官方文档
- 去哪儿网门票频道 --- 本项目数据来源
- Pandas 官方文档 - 数据清洗 --- 缺失值处理最佳实践
- Matplotlib 中文显示问题解决方案 --- 中文字体配置指南
作者 :阿虎哥
系列 :Python大数据实战(七)
日期 :2026-06-28
标签:#Python #Scrapy #随机森林 #数据爬虫 #机器学习 #旅游数据分析