Python大数据实战(七):Scrapy爬虫+随机森林——旅游景点票价预测全流程实战

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 爬虫实现)
    • [3.1 页面分析](#3.1 页面分析)
    • [3.2 定义 Item 数据模型](#3.2 定义 Item 数据模型)
    • [3.3 编写爬虫逻辑](#3.3 编写爬虫逻辑)
    • [3.4 反爬虫策略:Cookie 设置](#3.4 反爬虫策略:Cookie 设置)
      • [错误案例 1:未设置 Cookie 导致爬取失败](#错误案例 1:未设置 Cookie 导致爬取失败)
    • [3.5 数据保存:Pipeline](#3.5 数据保存:Pipeline)
    • [3.6 启动爬虫](#3.6 启动爬虫)
  • 四、数据预处理
    • [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,请求会被拦截返回验证页面。

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 本文总结

本文完成了一个完整的"爬虫+数据分析+机器学习"综合项目:

  1. 数据采集:使用 Scrapy 框架爬取去哪儿网旅游景点数据,涵盖名称、等级、区域、热度、票价、销量六大字段
  2. 数据清洗:处理了等级标准化、热度数值提取、区域拆分、缺失值填充等常见问题
  3. 数据分析:从销量排行、等级分布、地域分布、数据分布四个维度进行了可视化分析
  4. 特征工程:通过 One-Hot 编码将分类变量转化为模型可用的数值特征
  5. 建模预测:使用随机森林回归模型预测景点票价,并进行了全面的模型评估

10.2 关键收获

  • Scrapy 爬虫的完整开发流程:Item → Spider → Pipeline → Settings
  • 反爬虫策略:Cookie 设置、下载延迟、User-Agent 轮换
  • 数据预处理的标准流程:缺失值 → 格式统一 → 编码转换
  • 随机森林回归的调参与评估方法

10.3 下一篇预告

下一篇文章我们将进入气象数据分析项目,学习如何处理时间序列数据、气象可视化以及天气趋势预测。敬请期待!


参考链接

  1. Scrapy 官方文档 --- Scrapy 爬虫框架的完整使用指南
  2. Scikit-learn RandomForestRegressor --- 随机森林回归器官方文档
  3. 去哪儿网门票频道 --- 本项目数据来源
  4. Pandas 官方文档 - 数据清洗 --- 缺失值处理最佳实践
  5. Matplotlib 中文显示问题解决方案 --- 中文字体配置指南

作者 :阿虎哥

系列 :Python大数据实战(七)

日期 :2026-06-28

标签:#Python #Scrapy #随机森林 #数据爬虫 #机器学习 #旅游数据分析