用Python实现LSTM预测电影票房:从数据爬取到模型部署全解析(结尾附完整代码)

导语:当电影的预告片发布时,你是否好奇AI能否预测它的票房表现?本文将带你深入实战,从数据动态爬取LSTM模型调优,手把手构建一个高精度票房预测系统。


一、为什么LSTM是票房预测的利器?

1.1 电影票房的关键影响因素

35% 25% 20% 15% 5% 票房核心影响因素占比 演员号召力 IP热度 档期竞争 宣传投入 其他
  • 时序特征:上映前后的宣传节奏、口碑传播曲线
  • 非线性关系:主演流量与票房的S型增长关系
  • 长周期依赖:系列电影前作的市场表现

1.2 LSTM的独特优势

输入门
遗忘门
记忆细胞更新
输出门
隐藏状态输出
  • 记忆细胞(Cell State):长期保存重要信息(如系列电影基础粉丝量)
  • 门控机制
    • 遗忘门:过滤无效历史数据(如过气演员的早期作品)
    • 输入门:捕捉当前重要特征(如预告片爆款指数)
    • 输出门:动态调整预测输出

二、数据获取与预处理全流程

2.1 动态爬虫系统设计

开始
初始化Session
发送请求
状态码200?
解析HTML
重试机制
提取基础信息
提取评分数据
提取评论数据
数据存储
结束
核心代码实现
class DoubanSpider:
    def __init__(self):
        self.session = requests.Session()
        self.session.headers.update({
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)...',
            'Cookie': '你的登录Cookie'
        })
    
    def get_movie_data(self, movie_id):
        url = f'https://movie.douban.com/subject/{movie_id}/'
        response = self.session.get(url)
        soup = BeautifulSoup(response.text, 'lxml')
        
        data = {
            'basic_info': self._parse_basic(soup),
            'rating_stats': self._parse_rating(soup),
            'comments_analysis': self._get_comments(movie_id)
        }
        return data

2.2 特征工程深度解析

2.2.1 特征处理流程
原始数据
缺失值处理
异常值检测
特征构造
数据标准化
时序特征构建
特征构造示例
# 构造上映前热度指数
df['preheat_index'] = df['预告片播放量'] * 0.3 + df['想看人数'] * 0.7

# 计算口碑稳定性
df['rating_stability'] = df['五星比例'] - df['一星比例']

# 档期竞争力计算
def get_same_schedule_movies(date):
    return df[(df['release_date'] > date - pd.Timedelta(days=7)) & 
              (df['release_date'] < date + pd.Timedelta(days=7))]

三、LSTM模型架构详解

3.1 网络结构设计

参数设置
units=128, return_sequences=True
rate=0.4
units=64
双向LSTM
Dropout层
LSTM层
输入层
Attention机制
全连接层
输出层
模型构建代码
model = Sequential([
    Bidirectional(LSTM(128, return_sequences=True), 
    Dropout(0.4),
    LSTM(64, return_sequences=True),
    Attention(),
    Dense(32, activation='relu'),
    Dense(1)
])

3.2 超参数优化策略

3.2.1 滑动窗口选择
2023-01-01 2023-01-02 2023-01-03 2023-01-04 2023-01-05 2023-01-06 2023-01-07 2023-01-08 2023-01-09 2023-01-10 2023-01-11 2023-01-12 2023-01-13 数据准备 数据准备 模型训练 模型训练 7天窗口 30天窗口 滑动窗口对比实验
实验结果对比
窗口大小RMSE(训练集)RMSE(验证集)过拟合程度
7天0.280.35中等
15天0.250.31较轻
30天0.230.28轻微

四、模型训练与调优实战

4.1 动态学习率设置

def lr_scheduler(epoch):
    if epoch < 20:
        return 0.001
    elif epoch < 50:
        return 0.0005
    else:
        return 0.0001

4.2 损失函数优化

def weighted_mse(y_true, y_pred):
    # 小成本电影赋予更高权重
    weights = tf.where(y_true < 1e4, 2.0, 1.0)  
    return tf.reduce_mean(weights * (y_true - y_pred)**2)

4.3 训练过程监控

训练数据
前向传播
计算损失
反向传播
参数更新
达到早停条件?
保存最佳模型

五、结果分析

5.1 预测效果展示

实际票房
模型预测
残差计算
误差分析
模型优化
预测结果对比图
plt.figure(figsize=(12,6))
plt.plot(actual, label='实际票房', marker='o')
plt.plot(predictions, label='预测票房', linestyle='--')
plt.fill_between(range(len(actual)), 
                 predictions-err, 
                 predictions+err, 
                 alpha=0.2)
plt.title('30天票房预测对比')
plt.legend()

六、模型部署与生产应用

6.1 系统架构设计

客户端
服务层
数据层
Web前端
移动端APP
模型推理模块
Flask API服务
Redis缓存
MySQL数据库

6.2 API接口示例

@app.route('/predict', methods=['POST'])
def predict():
    data = request.get_json()
    
    # 数据预处理
    features = preprocess(data)
    
    # 模型推理
    prediction = model.predict(features.reshape(1, 30, 8))
    
    # 后处理
    result = {
        'prediction': float(prediction[0][0]),
        'confidence': calc_confidence(prediction)
    }
    return jsonify(result)

七、优化方向与进阶学习

7.1 混合模型架构

参数说明
filters=64, kernel_size=3
units=128
1D卷积层
LSTM层
输入层
Attention层
全连接层
输出层

7.2 实时预测系统设计

客户端 API服务 数据管道 特征工程 模型服务 发送预测请求 获取实时数据 处理原始数据 输入特征矩阵 返回预测结果 返回票房预测 客户端 API服务 数据管道 特征工程 模型服务

一、完整代码:

1.数据爬取模块(spider.py):

以下是完整的电影数据爬取模块代码,包含反爬策略、异常处理和详细数据解析:

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
电影数据爬取模块
功能特性:
1. 自动处理反爬机制(随机延迟+代理IP+用户代理轮换)
2. 支持断点续爬
3. 多维度数据采集(基本信息+评分+评论+票房)
4. 自动保存进度和异常日志
"""

import requests
from bs4 import BeautifulSoup
import random
import time
import logging
import json
import pandas as pd
from tqdm import tqdm
from urllib.parse import urljoin
import os

# 日志配置
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(levelname)s - %(message)s',
    handlers=[
        logging.FileHandler('spider.log'),
        logging.StreamHandler()
    ]
)

class DoubanMovieSpider:
    def __init__(self, start_page=1, max_retries=3):
        self.session = requests.Session()
        self.base_url = "https://movie.douban.com"
        self.start_page = start_page
        self.max_retries = max_retries
        self.data_file = "movies.csv"
        self.progress_file = "progress.txt"
        self.error_log = "errors.log"
        
        # 初始化请求头
        self.headers = {
            'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
            'Accept-Language': 'zh-CN,zh;q=0.9',
            'Connection': 'keep-alive'
        }
        
        # 加载用户代理列表
        with open("user_agents.txt") as f:
            self.user_agents = [line.strip() for line in f]
        
        # 初始化代理池(需自行维护)
        self.proxies = {
            'http': 'http://127.0.0.1:1087',
            'https': 'http://127.0.0.1:1087'
        }
        
        # 加载已有数据
        try:
            self.df = pd.read_csv(self.data_file)
            self.existing_ids = set(self.df['douban_id'])
        except FileNotFoundError:
            self.df = pd.DataFrame()
            self.existing_ids = set()
        
        # 加载进度
        self.load_progress()

    def load_progress(self):
        """加载爬取进度"""
        if os.path.exists(self.progress_file):
            with open(self.progress_file) as f:
                self.current_page = int(f.read())
        else:
            self.current_page = self.start_page

    def save_progress(self):
        """保存当前进度"""
        with open(self.progress_file, 'w') as f:
            f.write(str(self.current_page))

    def random_delay(self):
        """随机延迟防止被封"""
        time.sleep(random.uniform(1.5, 4.5))

    def get_page(self, url):
        """发送带重试机制的请求"""
        for _ in range(self.max_retries):
            try:
                self.headers['User-Agent'] = random.choice(self.user_agents)
                response = self.session.get(
                    url,
                    headers=self.headers,
                    proxies=self.proxies,
                    timeout=15
                )
                if response.status_code == 200:
                    return response
                elif response.status_code == 404:
                    logging.warning(f"页面不存在: {url}")
                    return None
            except Exception as e:
                logging.error(f"请求失败: {str(e)}")
                self.random_delay()
        return None

    def parse_movie_list(self, page):
        """解析电影列表页"""
        url = f"{self.base_url}/tag/#/?sort=S&range=9,10&tags=电影&start={(page-1)*20}"
        response = self.get_page(url)
        if not response:
            return []
        
        soup = BeautifulSoup(response.text, 'lxml')
        movie_items = soup.select('.list-wp > a')
        return [urljoin(self.base_url, item['href']) for item in movie_items]

    def parse_movie_detail(self, url):
        """解析电影详情页"""
        response = self.get_page(url)
        if not response:
            return None
        
        soup = BeautifulSoup(response.text, 'lxml')
        douban_id = url.split('/')[-2]
        
        # 如果已经爬取过则跳过
        if douban_id in self.existing_ids:
            logging.info(f"跳过已存在电影: {douban_id}")
            return None

        try:
            # 基础信息
            title = soup.find('h1').find('span').text.strip()
            year = soup.find('span', class_='year').text.strip('()')
            
            # 导演和演员
            directors = [a.text for a in soup.findAll('a', rel='v:directedBy")]
            actors = [a.text for a in soup.findAll('a', rel='v:starring")][:5]
            
            # 类型和地区
            genres = [t.text for t in soup.findAll('span', property='v:genre')]
            country = soup.find(text='制片国家/地区:').next_element.strip()
            
            # 评分信息
            rating = float(soup.find('strong', class_='ll rating_num').text)
            rating_count = int(soup.find('span', property='v:votes').text)
            rating_dist = {star['class'][0]: float(star.next_sibling.text[:-1]) 
                          for star in soup.select('.ratings-on-weight .item span')}
            
            # 票房数据(需要其他数据源补充)
            box_office = self.get_box_office(title)
            
            # 短评分析
            comments = self.parse_comments(douban_id)
            
            movie_data = {
                'douban_id': douban_id,
                'title': title,
                'year': year,
                'directors': ','.join(directors),
                'actors': ','.join(actors),
                'genres': ','.join(genres),
                'country': country,
                'rating': rating,
                'rating_count': rating_count,
                'five_star': rating_dist['stars5'],
                'four_star': rating_dist['stars4'],
                'three_star': rating_dist['stars3'],
                'two_star': rating_dist['stars2'],
                'one_star': rating_dist['stars1'],
                'box_office': box_office,
                'hot_comments': '|'.join(comments[:5])
            }
            
            return movie_data
        
        except Exception as e:
            logging.error(f"解析失败 {url}: {str(e)}")
            with open(self.error_log, 'a') as f:
                f.write(f"{url}\t{str(e)}\n")
            return None

    def parse_comments(self, movie_id, count=20):
        """解析热门短评"""
        comments = []
        url = f"https://movie.douban.com/subject/{movie_id}/comments?sort=new_score"
        response = self.get_page(url)
        if not response:
            return []
        
        soup = BeautifulSoup(response.text, 'lxml')
        items = soup.select('.comment-item')
        for item in items[:count]:
            comment = item.find('span', class_='short').text.strip()
            comments.append(comment)
        return comments

    def get_box_office(self, title):
        """从外部API获取票房数据(示例实现)"""
        # 需要自行对接专业票房数据库(如专资办、猫眼专业版)
        # 此处返回模拟数据
        return round(random.uniform(1e3, 1e5), 2)

    def save_data(self, data):
        """保存数据到CSV"""
        df = pd.DataFrame([data])
        if not os.path.exists(self.data_file):
            df.to_csv(self.data_file, index=False, encoding='utf_8_sig')
        else:
            df.to_csv(self.data_file, mode='a', header=False, index=False, encoding='utf_8_sig')
        self.existing_ids.add(data['douban_id'])

    def run(self, max_pages=10):
        """启动爬虫"""
        try:
            for page in range(self.current_page, max_pages + 1):
                logging.info(f"正在爬取第 {page} 页...")
                movie_urls = self.parse_movie_list(page)
                
                if not movie_urls:
                    logging.warning(f"第 {page} 页没有找到电影列表")
                    break
                
                for url in tqdm(movie_urls, desc=f"第 {page} 页"):
                    self.random_delay()
                    movie_data = self.parse_movie_detail(url)
                    if movie_data:
                        self.save_data(movie_data)
                
                self.current_page = page + 1
                self.save_progress()
                
        except KeyboardInterrupt:
            logging.info("用户中断,保存进度...")
        finally:
            logging.info(f"爬取完成!数据已保存到 {self.data_file}")

if __name__ == "__main__":
    spider = DoubanMovieSpider(start_page=1)
    
    # 示例电影ID测试
    test_url = "https://movie.douban.com/subject/1292052/"
    print("测试解析电影数据:", spider.parse_movie_detail(test_url))
    
    # 正式运行(爬取前10页)
    spider.run(max_pages=10)

代码说明及使用指南

1. 依赖文件准备
  1. user_agents.txt - 放置浏览器User-Agent列表
  2. proxy_list.txt - 放置可用代理IP列表(可选)
2. 运行参数配置
# 初始化爬虫(参数说明)
spider = DoubanMovieSpider(
    start_page=1,    # 起始页码
    max_retries=3    # 最大重试次数
)

# 运行爬虫(爬取前10页)
spider.run(max_pages=10)
3. 核心功能解析
开始
初始化爬虫
加载历史数据
爬取列表页
有电影链接?
逐个爬取详情页
结束
数据有效?
保存数据
记录错误
更新进度
4. 数据字段说明
字段名类型说明
douban_idString豆瓣ID
titleString电影名称
yearInteger上映年份
directorsString导演列表
actorsString主演列表
genresString类型标签
countryString制片国家
ratingFloat豆瓣评分
rating_countInteger评分人数
five_starFloat五星评分占比
其他星级评分占比
box_officeFloat票房(万元)
hot_commentsString热门短评

注意事项

  1. 反爬策略

    • 随机User-Agent轮换
    • 随机请求延迟(1.5-4.5秒)
    • 代理IP支持
    • 自动保存进度(支持断点续爬)
  2. 数据补充

    • 真实票房数据需对接专业API
    • 可扩展get_box_office()方法接入:
      def get_box_office(self, title):
          # 示例:调用猫眼API
          response = requests.get(f"https://api.maoyan.com/movie?name={title}")
          return response.json()['boxOffice']
      
  3. 性能优化

    • 使用多线程/异步IO加速
    • 使用代理IP池服务
    • 分布式爬虫架构

2.数据预处理模块(preprocess.py):

以下是完整的数据预处理模块代码,包含数据清洗、特征工程、时间序列构建等功能:

# preprocess.py
import pandas as pd
import numpy as np
from sklearn.preprocessing import MinMaxScaler, OneHotEncoder
from sklearn.impute import KNNImputer
from sklearn.model_selection import train_test_split
import matplotlib.pyplot as plt
import seaborn as sns
import warnings
warnings.filterwarnings('ignore')

class MovieDataPreprocessor:
    def __init__(self, data_path, look_back=30, test_size=0.2):
        """
        初始化预处理类
        :param data_path: 原始数据路径
        :param look_back: 时间窗口大小
        :param test_size: 测试集比例
        """
        self.data_path = data_path
        self.look_back = look_back
        self.test_size = test_size
        self.scaler = MinMaxScaler(feature_range=(0, 1))
        self.encoder = OneHotEncoder(handle_unknown='ignore')
        self.numeric_features = None
        self.categorical_features = None
        
    def load_and_clean(self):
        """
        加载并清洗原始数据
        """
        # 加载数据
        df = pd.read_csv(self.data_path, parse_dates=['release_date'])
        
        # 处理缺失值
        df = self._handle_missing_values(df)
        
        # 处理异常值
        df = self._handle_outliers(df)
        
        # 转换数据类型
        df['release_month'] = df['release_date'].dt.month
        df['release_quarter'] = df['release_date'].dt.quarter
        df['is_holiday'] = df['release_date'].apply(self._is_holiday_season)
        
        return df
    
    def _handle_missing_values(self, df):
        """
        处理缺失值
        """
        # 票房使用KNN填充
        imputer = KNNImputer(n_neighbors=5)
        df['box_office'] = imputer.fit_transform(df[['box_office']])
        
        # 分类特征填充未知
        cat_cols = ['director', 'genre', 'country']
        df[cat_cols] = df[cat_cols].fillna('unknown')
        
        return df
    
    def _handle_outliers(self, df):
        """
        处理异常值
        """
        # 使用IQR方法处理票房异常值
        Q1 = df['box_office'].quantile(0.25)
        Q3 = df['box_office'].quantile(0.75)
        IQR = Q3 - Q1
        df = df[~((df['box_office'] < (Q1 - 1.5 * IQR)) | 
                  (df['box_office'] > (Q3 + 1.5 * IQR)))]
        return df
    
    def _is_holiday_season(self, date):
        """
        判断是否节假日档期
        """
        month_day = (date.month, date.day)
        if (1, 1) <= month_day <= (2, 15):   # 春节档
            return 1
        elif (7, 15) <= month_day <= (8, 31): # 暑期档
            return 2
        elif (9, 30) <= month_day <= (10, 7): # 国庆档
            return 3
        else:
            return 0
    
    def feature_engineering(self, df):
        """
        特征工程处理
        """
        # 数值特征
        numeric_cols = [
            'rating', 'rating_count', 'want_to_see',
            'trailer_views', 'budget', 'runtime'
        ]
        
        # 分类特征
        categorical_cols = [
            'director', 'genre', 'country',
            'release_month', 'is_holiday'
        ]
        
        # 构造新特征
        df['director_fame'] = np.log1p(df['director_movies_count'])
        df['actor_power'] = df[['actor1_fans', 'actor2_fans']].mean(axis=1)
        df['genre_diversity'] = df['genre'].apply(lambda x: len(x.split('/')))
        
        # 时间序列特征
        df = df.sort_values('release_date')
        df['rolling_rating'] = df['rating'].rolling(window=7, min_periods=1).mean()
        
        # 特征选择
        selected_features = numeric_cols + [
            'director_fame', 'actor_power', 'genre_diversity',
            'rolling_rating', 'box_office'
        ]
        
        return df[selected_features + categorical_cols + ['box_office']]
    
    def encode_and_scale(self, df):
        """
        特征编码和归一化
        """
        # 分离特征
        numeric_cols = df.select_dtypes(include=np.number).columns.tolist()
        categorical_cols = df.select_dtypes(exclude=np.number).columns.tolist()
        
        # 数值特征归一化
        scaled_numeric = self.scaler.fit_transform(df[numeric_cols])
        
        # 分类特征编码
        encoded_cat = self.encoder.fit_transform(df[categorical_cols]).toarray()
        
        # 合并特征
        processed_data = np.concatenate([scaled_numeric, encoded_cat], axis=1)
        
        # 保存特征信息
        self.numeric_features = numeric_cols
        self.categorical_features = categorical_cols
        
        return processed_data
    
    def create_sequences(self, data):
        """
        创建时间序列样本
        """
        X, y = [], []
        for i in range(len(data)-self.look_back-1):
            X.append(data[i:(i+self.look_back)])
            y.append(data[i + self.look_back, -1])  # 假设最后一列是票房
        return np.array(X), np.array(y)
    
    def split_data(self, X, y):
        """
        划分训练集和测试集(按时间顺序)
        """
        split_idx = int(len(X) * (1 - self.test_size))
        X_train, X_test = X[:split_idx], X[split_idx:]
        y_train, y_test = y[:split_idx], y[split_idx:]
        return X_train, X_test, y_train, y_test
    
    def visualize_distribution(self, df):
        """
        数据可视化
        """
        plt.figure(figsize=(15, 10))
        
        # 票房分布
        plt.subplot(2, 2, 1)
        sns.histplot(df['box_office'], kde=True)
        plt.title('Box Office Distribution')
        
        # 评分与票房关系
        plt.subplot(2, 2, 2)
        sns.scatterplot(x='rating', y='box_office', data=df)
        plt.title('Rating vs Box Office')
        
        # 类型分布
        plt.subplot(2, 2, 3)
        df['genre'].value_counts().head(10).plot(kind='barh')
        plt.title('Top 10 Movie Genres')
        
        # 时间趋势
        plt.subplot(2, 2, 4)
        monthly = df.groupby('release_month')['box_office'].mean()
        monthly.plot(kind='line', marker='o')
        plt.title('Monthly Box Office Trend')
        
        plt.tight_layout()
        plt.show()

    def full_pipeline(self):
        """
        完整预处理流程
        """
        # 1. 加载和清洗数据
        raw_df = self.load_and_clean()
        
        # 2. 特征工程
        feature_df = self.feature_engineering(raw_df)
        
        # 3. 编码和归一化
        processed_data = self.encode_and_scale(feature_df)
        
        # 4. 创建时间序列
        X, y = self.create_sequences(processed_data)
        
        # 5. 划分数据集
        X_train, X_test, y_train, y_test = self.split_data(X, y)
        
        # 6. 数据可视化
        self.visualize_distribution(raw_df)
        
        return X_train, X_test, y_train, y_test

# 使用示例
if __name__ == "__main__":
    # 初始化预处理器
    processor = MovieDataPreprocessor(
        data_path='movie_data.csv',
        look_back=30,
        test_size=0.2
    )
    
    # 运行完整流程
    X_train, X_test, y_train, y_test = processor.full_pipeline()
    
    # 输出结果信息
    print("训练集形状:", X_train.shape)
    print("测试集形状:", X_test.shape)
    print("特征数量:", X_train.shape[-1])

代码功能说明

特征工程
数据清洗
分类特征
数值特征
时间序列特征
异常值处理
缺失值处理
日期转换
原始数据
数据清洗
特征工程
特征编码
时间序列构建
数据集划分
训练数据
测试数据

主要功能实现细节

  1. 数据清洗

    • 使用KNN算法填充票房缺失值
    • 基于IQR方法检测处理异常值
    • 日期格式转换和节假日判断
  2. 特征工程

    • 构造导演影响力指数:log(执导电影数量+1)
    • 演员号召力:主演社交媒体粉丝数平均值
    • 类型多样性:电影类型数量
    • 滑动平均评分:7日滚动平均
  3. 特征处理

    • 数值特征归一化(MinMaxScaler)
    • 分类特征独热编码(OneHotEncoder)
    • 处理未知类别特征
  4. 时间序列构建

    • 按时间排序创建滑动窗口样本
    • 可配置窗口大小(默认30天)
    • 时序数据划分(防止未来信息泄漏)
  5. 可视化分析

    • 票房分布直方图
    • 评分-票房散点图
    • 类型分布条形图
    • 月度趋势折线图

使用说明

  1. 输入数据要求

    movie_id,title,release_date,rating,rating_count,want_to_see,
    trailer_views,budget,runtime,director,genre,country,actor1_fans,
    actor2_fans,box_office
    
  2. 运行预处理流程

    processor = MovieDataPreprocessor('movie_data.csv')
    X_train, X_test, y_train, y_test = processor.full_pipeline()
    
  3. 保存预处理结果

    np.savez('processed_data.npz', 
             X_train=X_train, X_test=X_test,
             y_train=y_train, y_test=y_test)
    
  4. 加载预处理模型

    # 保存预处理参数
    import joblib
    joblib.dump(processor.scaler, 'scaler.pkl')
    joblib.dump(processor.encoder, 'encoder.pkl')
    
    # 加载预处理参数
    new_scaler = joblib.load('scaler.pkl')
    new_encoder = joblib.load('encoder.pkl')
    

注意事项

  1. 数据顺序:确保数据按时间顺序排列
  2. 特征更新:根据业务需求调整feature_engineering方法
  3. 内存管理:大数据集建议分块处理
  4. 实时数据:生产环境需持久化预处理参数(scaler/encoder)
  5. 分类特征:注意处理新出现的类别值

3.LSTM模型模块(model.py)

以下是完整的LSTM模型模块实现,包含模型构建、训练、评估和预测功能:

# model.py
import tensorflow as tf
from tensorflow.keras.models import Model
from tensorflow.keras.layers import (
    LSTM,
    Dense,
    Dropout,
    Bidirectional,
    Input,
    Attention,
    LayerNormalization
)
from tensorflow.keras.callbacks import (
    EarlyStopping, 
    ModelCheckpoint, 
    LearningRateScheduler
)
from tensorflow.keras.optimizers import Adam
from tensorflow.keras.regularizers import l2
import matplotlib.pyplot as plt
import numpy as np
import os

class BoxOfficePredictor:
    def __init__(self, input_shape, model_params=None):
        """
        初始化票房预测模型
        :param input_shape: 输入形状 (timesteps, features)
        :param model_params: 模型参数字典
        """
        self.input_shape = input_shape
        self.model = None
        self.history = None
        self.params = {
            'lstm_units': [128, 64],
            'dense_units': 32,
            'dropout_rate': 0.3,
            'l2_reg': 0.001,
            'learning_rate': 0.001,
            'attention': True
        }
        
        if model_params:
            self.params.update(model_params)
            
        self._build_model()

    def _build_model(self):
        """
        构建LSTM模型结构
        """
        inputs = Input(shape=self.input_shape)
        
        # 双向LSTM层
        x = Bidirectional(
            LSTM(
                self.params['lstm_units'][0],
                return_sequences=self.params['attention'],
                kernel_regularizer=l2(self.params['l2_reg'])
            )
        )(inputs)
        x = Dropout(self.params['dropout_rate'])(x)
        
        # 第二LSTM层
        x = LSTM(
            self.params['lstm_units'][1],
            return_sequences=self.params['attention'],
            kernel_regularizer=l2(self.params['l2_reg'])
        )(x)
        x = Dropout(self.params['dropout_rate'])(x)
        
        # Attention机制
        if self.params['attention']:
            x = Attention()([x, x])
            x = LayerNormalization()(x)
        
        # 全连接层
        x = Dense(
            self.params['dense_units'],
            activation='relu',
            kernel_regularizer=l2(self.params['l2_reg'])
        )(x)
        
        # 输出层
        outputs = Dense(1, activation='linear')(x)
        
        self.model = Model(inputs=inputs, outputs=outputs)
        
        # 编译模型
        self.model.compile(
            optimizer=Adam(learning_rate=self.params['learning_rate']),
            loss='huber_loss',
            metrics=['mae', 'mse']
        )
        
    def train(self, X_train, y_train, X_val, y_val, epochs=200, batch_size=32):
        """
        训练模型
        """
        callbacks = [
            EarlyStopping(monitor='val_loss', patience=15, restore_best_weights=True),
            ModelCheckpoint('best_model.h5', save_best_only=True),
            LearningRateScheduler(self._lr_scheduler)
        ]
        
        self.history = self.model.fit(
            X_train, y_train,
            validation_data=(X_val, y_val),
            epochs=epochs,
            batch_size=batch_size,
            callbacks=callbacks,
            verbose=1
        )
        return self.history
    
    def evaluate(self, X_test, y_test):
        """
        评估模型
        """
        return self.model.evaluate(X_test, y_test, verbose=0)
    
    def predict(self, X):
        """
        进行预测
        """
        return self.model.predict(X, verbose=0)
    
    def _lr_scheduler(self, epoch):
        """
        自定义学习率调度
        """
        if epoch < 20:
            return self.params['learning_rate']
        elif epoch < 50:
            return self.params['learning_rate'] * 0.5
        else:
            return self.params['learning_rate'] * 0.1
    
    def plot_training_history(self):
        """
        绘制训练过程曲线
        """
        if not self.history:
            raise ValueError("Model hasn't been trained yet")
            
        plt.figure(figsize=(12, 6))
        
        # 绘制损失曲线
        plt.subplot(1, 2, 1)
        plt.plot(self.history.history['loss'], label='Train Loss')
        plt.plot(self.history.history['val_loss'], label='Validation Loss')
        plt.title('Training and Validation Loss')
        plt.xlabel('Epochs')
        plt.ylabel('Loss')
        plt.legend()
        
        # 绘制MAE曲线
        plt.subplot(1, 2, 2)
        plt.plot(self.history.history['mae'], label='Train MAE')
        plt.plot(self.history.history['val_mae'], label='Validation MAE')
        plt.title('Training and Validation MAE')
        plt.xlabel('Epochs')
        plt.ylabel('MAE')
        plt.legend()
        
        plt.tight_layout()
        plt.show()
    
    def save_model(self, filepath='box_office_model'):
        """
        保存完整模型
        """
        self.model.save(filepath)
        print(f"Model saved to {filepath}")
    
    @classmethod
    def load_model(cls, filepath):
        """
        加载已保存的模型
        """
        model = tf.keras.models.load_model(filepath)
        predictor = cls.__new__(cls)
        predictor.model = model
        return predictor

# 示例用法
if __name__ == "__main__":
    # 假设输入形状为 (30, 15)
    model_params = {
        'lstm_units': [128, 64],
        'dense_units': 32,
        'attention': True
    }
    
    # 初始化模型
    predictor = BoxOfficePredictor(
        input_shape=(30, 15), 
        model_params=model_params
    )
    
    # 打印模型结构
    predictor.model.summary()
    
    # 生成虚拟数据(实际应从预处理模块获取)
    X_train = np.random.randn(1000, 30, 15)
    y_train = np.random.randn(1000)
    X_val = np.random.randn(200, 30, 15)
    y_val = np.random.randn(200)
    
    # 训练模型
    history = predictor.train(X_train, y_train, X_val, y_val, epochs=100)
    
    # 评估模型
    test_loss, test_mae, test_mse = predictor.evaluate(X_val, y_val)
    print(f"\nTest Loss: {test_loss:.4f}")
    print(f"Test MAE: {test_mae:.4f}")
    print(f"Test MSE: {test_mse:.4f}")
    
    # 保存模型
    predictor.save_model()
    
    # 绘制训练曲线
    predictor.plot_training_history()

代码结构说明

BoxOfficePredictor
- input_shape: tuple
- model: tf.keras.Model
- history: History
- params: dict
+__init__(input_shape, model_params)
-_build_model()
-_lr_scheduler(epoch)
+train(X_train, y_train, X_val, y_val, epochs, batch_size)
+evaluate(X_test, y_test)
+predict(X)
+plot_training_history()
+save_model(filepath)
+load_model(filepath)

主要功能特点

  1. 灵活的模型架构

    • 双向LSTM层堆叠
    • 可选的Attention机制
    • Layer Normalization提升训练稳定性
    • 正则化(L2)防止过拟合
  2. 高级训练功能

    • 自定义学习率调度
    • Early Stopping机制
    • 自动保存最佳模型
    • Huber损失函数(对异常值鲁棒)
  3. 可视化支持

    • 训练损失曲线
    • MAE指标可视化
    • 实时训练监控
  4. 生产就绪功能

    • 完整模型保存/加载
    • 方便的预测接口
    • 详细的评估指标

模型结构示例输出

Model: "model"
_________________________________________________________________
 Layer (type)                Output Shape              Param #   
=================================================================
 input_1 (InputLayer)        [(None, 30, 15)]          0         
                                                                 
 bidirectional (Bidirectiona  (None, 30, 256)           157696    
 l)                                                              
                                                                 
 dropout (Dropout)           (None, 30, 256)           0         
                                                                 
 lstm_1 (LSTM)               (None, 30, 64)            82240     
                                                                 
 dropout_1 (Dropout)         (None, 30, 64)            0         
                                                                 
 attention (Attention)       (None, 30, 64)            0         
                                                                 
 layer_normalization (LayerN  (None, 30, 64)           128       
 ormalization)                                                   
                                                                 
 dense (Dense)               (None, 30, 32)            2080      
                                                                 
 dense_1 (Dense)             (None, 30, 1)             33        
                                                                 
=================================================================
Total params: 242,177
Trainable params: 242,177
Non-trainable params: 0

使用建议

  1. 参数调优

    # 示例:修改模型参数
    custom_params = {
        'lstm_units': [256, 128],  # 增大模型容量
        'dropout_rate': 0.5,       # 增强正则化
        'attention': False,         # 关闭Attention
        'learning_rate': 0.0005
    }
    predictor = BoxOfficePredictor(input_shape=(30,15), model_params=custom_params)
    
  2. 自定义损失函数

    def custom_loss(y_true, y_pred):
        mse = tf.keras.losses.MSE(y_true, y_pred)
        mae = tf.keras.losses.MAE(y_true, y_pred)
        return 0.7*mse + 0.3*mae
    
    # 在_build_model的compile步骤使用
    self.model.compile(..., loss=custom_loss)
    
  3. 多GPU训练

    strategy = tf.distribute.MirroredStrategy()
    with strategy.scope():
        self._build_model()
    
  4. 生产部署

    # 加载已保存模型
    predictor = BoxOfficePredictor.load_model('box_office_model')
    
    # 进行预测
    prediction = predictor.predict(new_data)
    

性能优化技巧

  1. 数据管道优化

    train_dataset = tf.data.Dataset.from_tensor_slices((X_train, y_train))
                       .shuffle(buffer_size=1024)
                       .batch(batch_size)
                       .prefetch(tf.data.AUTOTUNE)
    
  2. 混合精度训练

    tf.keras.mixed_precision.set_global_policy('mixed_float16')
    
  3. 模型量化

    converter = tf.lite.TFLiteConverter.from_keras_model(model)
    converter.optimizations = [tf.lite.Optimize.DEFAULT]
    tflite_model = converter.convert()
    
  4. ONNX导出

    import onnx
    tf2onnx.convert.from_keras_model(model, output_path='model.onnx')
    

4.训练主程序(train.py)

以下是完整的训练主程序代码,包含完整的训练流程、超参数配置和结果保存功能:

# train.py
import argparse
import os
import numpy as np
import joblib
from datetime import datetime
from preprocess import MovieDataPreprocessor
from model import BoxOfficePredictor

def main():
    # 参数解析
    parser = argparse.ArgumentParser(description='LSTM电影票房预测训练程序')
    parser.add_argument('--data_path', type=str, default='data/movie_data.csv',
                       help='原始数据文件路径')
    parser.add_argument('--look_back', type=int, default=30,
                       help='时间窗口大小')
    parser.add_argument('--test_size', type=float, default=0.2,
                       help='测试集比例')
    parser.add_argument('--epochs', type=int, default=200,
                       help='训练轮次')
    parser.add_argument('--batch_size', type=int, default=64,
                       help='批处理大小')
    parser.add_argument('--output_dir', type=str, default='output',
                       help='输出目录')
    args = parser.parse_args()

    # 创建输出目录
    os.makedirs(args.output_dir, exist_ok=True)
    timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
    
    # 初始化日志
    log_file = os.path.join(args.output_dir, f'training_{timestamp}.log')
    print(f"训练日志保存在: {log_file}")
    
    # 数据预处理
    print("\n=== 数据预处理 ===")
    preprocessor = MovieDataPreprocessor(
        data_path=args.data_path,
        look_back=args.look_back,
        test_size=args.test_size
    )
    
    try:
        X_train, X_test, y_train, y_test = preprocessor.full_pipeline()
    except Exception as e:
        print(f"数据预处理失败: {str(e)}")
        return

    # 数据集信息
    print("\n数据集信息:")
    print(f"训练集形状: {X_train.shape}")
    print(f"测试集形状: {X_test.shape}")
    print(f"特征数量: {X_train.shape[-1]}")
    
    # 模型配置
    model_params = {
        'lstm_units': [128, 64],    # 两个LSTM层的单元数
        'dense_units': 32,          # 全连接层单元数
        'dropout_rate': 0.3,        # Dropout比率
        'l2_reg': 0.001,           # L2正则化系数
        'learning_rate': 0.001,     # 初始学习率
        'attention': True          # 启用Attention机制
    }
    
    # 初始化模型
    print("\n=== 模型初始化 ===")
    try:
        predictor = BoxOfficePredictor(
            input_shape=(X_train.shape[1], X_train.shape[2]),
            model_params=model_params
        )
        predictor.model.summary()
    except Exception as e:
        print(f"模型初始化失败: {str(e)}")
        return
    
    # 划分验证集
    split_idx = int(len(X_train) * 0.8)
    X_trn, X_val = X_train[:split_idx], X_train[split_idx:]
    y_trn, y_val = y_train[:split_idx], y_train[split_idx:]
    
    # 模型训练
    print("\n=== 开始训练 ===")
    try:
        history = predictor.train(
            X_trn, y_trn,
            X_val, y_val,
            epochs=args.epochs,
            batch_size=args.batch_size
        )
    except Exception as e:
        print(f"训练过程出错: {str(e)}")
        return
    
    # 模型评估
    print("\n=== 模型评估 ===")
    test_loss, test_mae, test_mse = predictor.evaluate(X_test, y_test)
    print(f"测试集评估结果:")
    print(f"- 损失值: {test_loss:.4f}")
    print(f"- MAE: {test_mae:.4f}")
    print(f"- MSE: {test_mse:.4f}")
    
    # 保存结果
    print("\n=== 保存结果 ===")
    model_dir = os.path.join(args.output_dir, f"model_{timestamp}")
    os.makedirs(model_dir, exist_ok=True)
    
    # 保存模型
    model_path = os.path.join(model_dir, "trained_model")
    predictor.save_model(model_path)
    
    # 保存预处理参数
    joblib.dump(preprocessor.scaler, os.path.join(model_dir, "scaler.pkl"))
    joblib.dump(preprocessor.encoder, os.path.join(model_dir, "encoder.pkl"))
    
    # 保存训练曲线
    plot_path = os.path.join(model_dir, "training_plot.png"))
    predictor.plot_training_history()
    plt.savefig(plot_path)
    plt.close()
    
    # 保存配置信息
    config = {
        'data_path': args.data_path,
        'look_back': args.look_back,
        'input_shape': (X_train.shape[1], X_train.shape[2]),
        'model_params': model_params,
        'test_size': args.test_size,
        'preprocess_version': '1.0'
    }
    joblib.dump(config, os.path.join(model_dir, "config.pkl"))
    
    print(f"\n所有结果已保存至: {model_dir}")

if __name__ == "__main__":
    main()

代码结构说明

结果保存
保存预处理参数
保存模型
保存训练曲线
保存配置文件
模型训练
训练循环
划分验证集
早停机制
学习率调度
数据预处理
清洗数据
加载数据
特征工程
构建时间序列
开始
解析参数
数据预处理
模型初始化
训练模型
模型评估
保存结果
结束

使用说明

  1. 运行命令
python train.py \
  --data_path data/movie_data.csv \
  --look_back 30 \
  --test_size 0.2 \
  --epochs 200 \
  --batch_size 64 \
  --output_dir outputs
  1. 参数说明

    • --data_path: 预处理前的原始数据路径
    • --look_back: 时间窗口大小(默认30天)
    • --test_size: 测试集比例(默认20%)
    • --epochs: 最大训练轮次(默认200)
    • --batch_size: 批处理大小(默认64)
    • --output_dir: 输出目录(默认output)
  2. 输出文件结构

outputs/
└── model_20230801-1530/
    ├── trained_model/          # SavedModel格式
    ├── scaler.pkl             # 归一化参数
    ├── encoder.pkl            # 编码器参数
    ├── training_plot.png      # 训练曲线
    └── config.pkl             # 训练配置

功能特性

  1. 完整的训练流程

    • 自动数据预处理流水线
    • 验证集自动划分
    • 训练过程监控(早停、模型保存)
    • 测试集最终评估
  2. 灵活的配置系统

    • 命令行参数覆盖
    • 模型参数字典配置
    • 自动保存完整配置
  3. 生产级输出

    • 保存完整模型(包含架构、权重、优化器状态)
    • 持久化预处理参数
    • 训练可视化图表
    • 版本化输出目录
  4. 错误处理

    • 数据加载失败处理
    • 预处理异常捕获
    • 模型初始化验证
    • 训练过程异常处理

扩展建议

  1. 分布式训练
# 在模型初始化前添加
strategy = tf.distribute.MirroredStrategy()
with strategy.scope():
    predictor = BoxOfficePredictor(...)
  1. 超参数搜索
# 使用Keras Tuner
tuner = kt.HyperBand(BoxOfficePredictor,
                     objective='val_loss',
                     max_epochs=50)
tuner.search(X_train, y_train, validation_data=(X_val, y_val))
  1. TensorBoard集成
# 添加回调函数
tensorboard_cb = tf.keras.callbacks.TensorBoard(
    log_dir=os.path.join(model_dir, "logs"))
callbacks.append(tensorboard_cb)
  1. 模型量化
# 训练后添加
converter = tf.lite.TFLiteConverter.from_keras_model(predictor.model)
converter.optimizations = [tf.lite.Optimize.DEFAULT]
tflite_model = converter.convert()

5.Flask部署模块(app.py)

以下是完整的Flask部署模块代码,包含API服务、输入验证、预处理流水线和错误处理:

# app.py
from flask import Flask, request, jsonify
from flask_cors import CORS
import numpy as np
import tensorflow as tf
import joblib
import logging
from datetime import datetime
import os

# 初始化Flask应用
app = Flask(__name__)
CORS(app)  # 允许跨域请求

# 配置日志
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger('BoxOfficeAPI')

# 全局变量
MODEL_DIR = 'output/model_20230801-1530'  # 替换为实际模型目录
scaler = None
encoder = None
model = None

def load_artifacts():
    """加载模型和预处理组件"""
    global scaler, encoder, model
    
    try:
        # 加载预处理组件
        scaler = joblib.load(os.path.join(MODEL_DIR, 'scaler.pkl'))
        encoder = joblib.load(os.path.join(MODEL_DIR, 'encoder.pkl'))
        
        # 加载TensorFlow模型
        model = tf.keras.models.load_model(
            os.path.join(MODEL_DIR, 'trained_model'))
        logger.info("成功加载模型和预处理组件")
        
    except Exception as e:
        logger.error(f"加载组件失败: {str(e)}")
        raise

def preprocess_input(data):
    """
    预处理输入数据
    :param data: 原始输入字典
    :return: 模型输入数组
    """
    # 验证必需字段
    required_fields = [
        'rating', 'rating_count', 'want_to_see',
        'trailer_views', 'budget', 'runtime',
        'director', 'genre', 'country',
        'actor1_fans', 'actor2_fans'
    ]
    for field in required_fields:
        if field not in data:
            raise ValueError(f"缺少必需字段: {field}")

    # 构建特征字典
    features = {
        # 数值特征
        'rating': float(data['rating']),
        'rating_count': int(data['rating_count']),
        'want_to_see': int(data['want_to_see']),
        'trailer_views': int(data['trailer_views']),
        'budget': float(data['budget']),
        'runtime': int(data['runtime']),
        
        # 分类特征
        'director': data['director'],
        'genre': data['genre'],
        'country': data['country'],
        
        # 构造特征
        'director_fame': np.log1p(int(data.get('director_movies_count', 1))),
        'actor_power': (float(data['actor1_fans']) + float(data['actor2_fans'])) / 2,
        'genre_diversity': len(data['genre'].split('/'))
    }

    # 转换为DataFrame
    df = pd.DataFrame([features])
    
    # 数值特征归一化
    numeric_cols = [
        'rating', 'rating_count', 'want_to_see',
        'trailer_views', 'budget', 'runtime',
        'director_fame', 'actor_power', 'genre_diversity'
    ]
    scaled_numeric = scaler.transform(df[numeric_cols])
    
    # 分类特征编码
    categorical_cols = ['director', 'genre', 'country']
    encoded_cat = encoder.transform(df[categorical_cols]).toarray()
    
    # 合并特征
    processed = np.concatenate([scaled_numeric, encoded_cat], axis=1)
    
    # 构造时间序列输入(复制最后一行填充窗口)
    look_back = 30  # 需与训练时一致
    seq_input = np.array([processed[-1]] * look_back)
    
    return seq_input.reshape(1, look_back, -1)

@app.route('/')
def home():
    """健康检查端点"""
    return jsonify({
        'status': 'active',
        'model_version': os.path.basename(MODEL_DIR),
        'timestamp': datetime.now().isoformat()
    })

@app.route('/predict', methods=['POST'])
def predict():
    """预测端点"""
    try:
        # 获取输入数据
        data = request.get_json()
        
        # 输入验证
        if not data:
            return jsonify({'error': '无效的输入数据'}), 400
        
        # 预处理
        processed_input = preprocess_input(data)
        
        # 进行预测
        prediction = model.predict(processed_input)
        
        # 反归一化
        dummy_data = np.zeros((1, len(scaler.feature_names_in_)))
        dummy_data[0, -1] = prediction[0][0]  # 假设票房在最后一列
        denorm_pred = scaler.inverse_transform(dummy_data)[0, -1]
        
        return jsonify({
            'prediction': round(float(denorm_pred), 2),
            'unit': '万元'
        })
    
    except ValueError as ve:
        logger.error(f"输入验证失败: {str(ve)}")
        return jsonify({'error': str(ve)}), 400
    except Exception as e:
        logger.error(f"预测失败: {str(e)}")
        return jsonify({'error': '内部服务器错误'}), 500

@app.errorhandler(404)
def not_found(error):
    return jsonify({'error': '端点不存在'}), 404

if __name__ == '__main__':
    # 加载模型和预处理组件
    load_artifacts()
    
    # 启动服务
    app.run(host='0.0.0.0', port=5000, threaded=True)

部署说明

1. 项目结构
movie-boxoffice-api/
├── output/
│   └── model_20230801-1530/  # 训练输出目录
│       ├── trained_model/    # SavedModel格式
│       ├── scaler.pkl        # 归一化器
│       ├── encoder.pkl       # 编码器
├── app.py                    # 本文件
├── requirements.txt          # 依赖文件
2. 依赖安装
pip install -r requirements.txt

requirements.txt内容:

flask==2.0.3
flask_cors==3.0.10
tensorflow==2.8.0
scikit-learn==1.0.2
pandas==1.4.3
numpy==1.22.4
3. 运行服务
python app.py
4. 请求示例
curl -X POST http://localhost:5000/predict \
     -H "Content-Type: application/json" \
     -d '{
         "rating": 8.5,
         "rating_count": 350000,
         "want_to_see": 150000,
         "trailer_views": 5000000,
         "budget": 5000,
         "runtime": 120,
         "director": "郭帆",
         "genre": "科幻/冒险",
         "country": "中国",
         "actor1_fans": 35000000,
         "actor2_fans": 28000000
     }'
5. 生产部署建议

使用Gunicorn + Nginx部署:

pip install gunicorn
gunicorn -w 4 -b 0.0.0.0:5000 app:app

功能特性

  1. 完整的API服务

    Client API Validation Preprocess Model Postprocess POST /predict 验证输入数据 数据预处理 执行预测 反归一化处理 返回预测结果 Client API Validation Preprocess Model Postprocess
  2. 输入验证机制

    • 必需字段检查
    • 数据类型转换
    • 自动构造衍生特征
  3. 错误处理

    • 400 无效输入
    • 404 端点不存在
    • 500 服务器内部错误
    • 详细错误日志记录
  4. 可观测性

    • / 端点提供健康检查
    • 记录模型版本信息
    • 完整的请求日志记录
  5. 性能优化

    • 全局模型加载(避免每次请求加载)
    • 线程安全处理
    • 输入数据批处理能力

扩展建议

  1. 添加认证
from flask_httpauth import HTTPTokenAuth

auth = HTTPTokenAuth(scheme='Bearer')
tokens = {"secret-token": "api-user"}

@auth.verify_token
def verify_token(token):
    return tokens.get(token)

@app.route('/predict')
@auth.login_required
def predict():
    ...
  1. 请求限流
from flask_limiter import Limiter

limiter = Limiter(
    app=app,
    key_func=lambda: request.remote_addr
)

@app.route('/predict')
@limiter.limit("10/minute")
def predict():
    ...
  1. 添加Swagger文档
from flasgger import Swagger

swagger = Swagger(app)

@app.route('/predict')
def predict():
    """
    电影票房预测接口
    ---
    parameters:
      - name: body
        in: body
        required: true
        schema:
          type: object
          properties:
            rating:
              type: number
              example: 8.5
            # 其他参数...
    responses:
      200:
        description: 预测结果
    """
    ...
  1. 性能监控
from prometheus_flask_exporter import PrometheusMetrics

metrics = PrometheusMetrics(app)
metrics.info('app_info', '票房预测服务', version='1.0')

二、代码使用说明

2.1 文件结构

project/
├── data/
│   └── movie_data.csv     # 数据集
├── models/
│   └── best_model.h5      # 训练好的模型
├── spider.py              # 数据爬取
├── preprocess.py          # 数据预处理
├── model.py               # 模型定义
├── train.py               # 训练脚本
└── app.py                 # Flask API

2.2 运行流程

  1. 数据获取(需谨慎使用):

    python spider.py
    
  2. 数据预处理

    python preprocess.py
    
  3. 模型训练

    python train.py
    
  4. 启动API服务

    python app.py
    
  5. 发送预测请求

    curl -X POST http://localhost:5000/predict \
         -H "Content-Type: application/json" \
         -d '{"rating":8.5,"rating_count":350000,"actors":"吴京,刘德华,李雪健"}'
    

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值