查看全课大纲(第 33 / 35 节)

数据预处理和特征工程(一)

约 21 分钟

小象实战讲义 · 特征工程精讲与实践

在上一章,我们系统学习了特征监控的理论与工具。本章我们将进入一个完整的实战项目——Kaggle上的“Predict Future Sales”电商销量预测比赛。本节是项目实战的第一部分,我们将聚焦于数据预处理和基础特征工程的完整流程。学完本节,你将掌握如何从一个原始、多文件的电商数据集出发,通过数据清洗、异常值处理、类别特征编码、时间特征构建以及统计特征衍生,为后续的建模准备好高质量的输入特征。

💡 核心导读

  • 项目背景与数据概览:了解Kaggle销量预测比赛的任务目标,认识原始数据(商品、类别、销售、商店信息)的结构与挑战。
  • 数据清洗与预处理:针对销售数据,进行基于业务规则的异常值剔除、重复ID规整,并过滤出测试集相关的商品与商店。
  • 类别特征编码与信息抽取:对商品类别、名称等字符串字段进行拆分、清洗,并使用标签编码(LabelEncoder)将其转换为模型可处理的数值形式。
  • 时间特征与基础统计特征构建:从销售日期中提取星期、月份、年份等信息,并构建日期块编号;同时进行初步的聚合统计,如商店-星期销量占比、月度销售聚合等。

项目背景与数据加载

本项目基于Kaggle的“Predict Future Sales”比赛。任务目标是:利用历史销售数据,预测俄罗斯一家大型软件公司旗下商店在未来一个月(2015年11月)的商品月销量(item_cnt_month)。原始数据为俄文,我们使用一份英文翻译版本进行讲解。

首先,我们导入必要的工具库,并进行pandas的基础显示设置。

import pandas as pd
import numpy as np
from itertools import product
from sklearn.cluster import KMeans
from sklearn.preprocessing import LabelEncoder
import warnings

# pandas显示设置
pd.set_option('display.max_rows', 400)
pd.set_option('display.max_columns', 160)
pd.set_option('display.max_colwidth', 40)
warnings.filterwarnings("ignore")
print("工具库导入与设置完成。")
工具库导入与设置完成。

接下来,我们加载核心数据文件。为了演示清晰,我们使用构造的小样本数据来模拟原始数据(原始测试集约3MB,销售训练集约90MB)。

import pandas as pd
import numpy as np

# 1. 加载测试集(构造样例)
test_data = {
    'ID': [0, 1, 2, 3, 4],
    'shop_id': [5, 5, 9, 9, 5],
    'item_id': [5037, 5320, 5233, 6001, 6002]
}
test = pd.DataFrame(test_data)
print("测试集数据(样例):")
print(test)
print(f"\n测试集形状: {test.shape}")
print("任务:预测每个 (shop_id, item_id) 组合在未来一个月的 item_cnt_month(月销量)。")

# 2. 加载商品类别数据(构造样例)
categories_data = {
    'category_id': [40, 55, 16, 71, 83],
    'category_name': ['PC - Headsets / Headphones', 'Games - PS4', 'Cinema - Blu-ray', 'Gifts - Figures', 'Music - MP3']
}
categories = pd.DataFrame(categories_data)
print("\n商品类别数据(样例):")
print(categories)
print("\n观察:category_name 字段包含主类目和子类目,由连接符 '-' 分隔。")
测试集数据(样例):
   ID  shop_id  item_id
0   0        5     5037
1   1        5     5320
2   2        9     5233
3   3        9     6001
4   4        5     6002

测试集形状: (5, 3)
任务:预测每个 (shop_id, item_id) 组合在未来一个月的 item_cnt_month(月销量)。

商品类别数据(样例):
   category_id               category_name
0           40  PC - Headsets / Headphones
1           55                 Games - PS4
2           16            Cinema - Blu-ray
3           71             Gifts - Figures
4           83                 Music - MP3

观察:category_name 字段包含主类目和子类目,由连接符 '-' 分隔。

类别特征的处理与编码

在动手之前,先回答一个关键问题:为什么这里用标签编码而不是独热编码?因为我们最终选用的模型是树模型——树在做分裂时只关心「是否小于某个阈值」,序号编码的大小关系不会对它产生负面影响,可以直接使用。如果改用线性模型(注意本任务是回归问题,不能用逻辑回归,而是线性回归、多项式回归这类),通常需要独热编码;但独热的代价是把类别平铺成大量 0/1 列,数据维度急剧升高且非常稀疏。另外,LightGBM 等工具虽然支持直接指定类别型列,但当类别数非常多时,直接指定的建模效率可能很低、甚至训练很慢,所以实践中更常见的做法还是自己先做序号编码。

拆分与编码商品类别

观察到category_name字段的结构后,我们将其拆分为group_name(主类目)和子类目(此处保留原始完整名称),并对两者进行标签编码。

import pandas as pd

# 商品类别数据(与本节前文一致)
categories_data = {
    'category_id': [40, 55, 16, 71, 83],
    'category_name': ['PC - Headsets / Headphones', 'Games - PS4', 'Cinema - Blu-ray', 'Gifts - Figures', 'Music - MP3']
}
categories = pd.DataFrame(categories_data)

import pandas as pd
from sklearn.preprocessing import LabelEncoder

# 使用上一步构造的 categories DataFrame
# 拆分主类目(group_name)
categories['group_name'] = categories['category_name'].str.split(' - ').str[0]
print("拆分出主类目:")
print(categories[['category_id', 'category_name', 'group_name']])

# 初始化标签编码器
le = LabelEncoder()
# 对主类目进行编码
categories['group_id'] = le.fit_transform(categories['group_name'].values)
print("\n对主类目进行标签编码后:")
print(categories[['category_id', 'group_name', 'group_id']])
拆分出主类目:
   category_id               category_name group_name
0           40  PC - Headsets / Headphones         PC
1           55                 Games - PS4      Games
2           16            Cinema - Blu-ray     Cinema
3           71             Gifts - Figures      Gifts
4           83                 Music - MP3      Music

对主类目进行标签编码后:
   category_id group_name  group_id
0           40         PC         4
1           55      Games         1
2           16     Cinema         0
3           71      Gifts         2
4           83      Music         3

商品信息清洗与特征抽取

加载商品信息表,对商品名称进行清洗(小写化、去除标点、停用词),并抽取名称的前N个字符作为可能代表商品系列的前缀特征,同样进行编码。

import pandas as pd

# 商品类别数据(与本节前文一致)
categories_data = {
    'category_id': [40, 55, 16, 71, 83],
    'category_name': ['PC - Headsets / Headphones', 'Games - PS4', 'Cinema - Blu-ray', 'Gifts - Figures', 'Music - MP3']
}
categories = pd.DataFrame(categories_data)
from sklearn.preprocessing import LabelEncoder
categories['group_name'] = categories['category_name'].str.split(' - ').str[0]
categories['group_id'] = LabelEncoder().fit_transform(categories['group_name'].values)

import pandas as pd
from sklearn.preprocessing import LabelEncoder

# 构造商品信息样例数据
items_data = {
    'item_id': [2173, 2174, 3470, 3471, 3472],
    'item_name': [
        'Grand Theft Auto V (PS4)',
        'Grand Theft Auto V (XBOX ONE)',
        'AKG K 44 Headphones',
        'Sony MDR-ZX110 Headphones',
        'Star Wars Battlefront (PS4)'
    ],
    'category_id': [55, 55, 40, 40, 55]
}
items = pd.DataFrame(items_data)
print("原始商品信息:")
print(items)

# 商品名称清洗
items['item_name_clean'] = items['item_name'].str.lower()
items['item_name_clean'] = items['item_name_clean'].str.replace(r'[^\w\s]', ' ', regex=True) # 去标点
# 简单模拟去除常见停用词
stop_words = ['the', 'a', 'an', 'and', 'or', 'for', 'of', 'on', 'in', 'to', 'with']
for w in stop_words:
    items['item_name_clean'] = items['item_name_clean'].str.replace(rf'\b{w}\b', ' ', regex=True)
items['item_name_clean'] = items['item_name_clean'].str.replace(r'\s+', ' ', regex=True).str.strip()

print("\n清洗后的商品名称:")
print(items[['item_id', 'item_name', 'item_name_clean']])

# 抽取名称前缀(去除空格)
items['item_name_no_space'] = items['item_name_clean'].str.replace(' ', '')
items['item_name_first4'] = items['item_name_no_space'].str[:4]
items['item_name_first6'] = items['item_name_no_space'].str[:6]

# 对前缀特征进行标签编码
le = LabelEncoder()
items['item_name_first4_enc'] = le.fit_transform(items['item_name_first4'].values)
items['item_name_first6_enc'] = le.fit_transform(items['item_name_first6'].values)

print("\n抽取前缀并编码后:")
print(items[['item_id', 'item_name_first4', 'item_name_first4_enc', 'item_name_first6', 'item_name_first6_enc']])

# 将类别信息合并到商品表 (模拟join操作)
# 这里我们简单地将之前 categories 表的 group_id 根据 category_id 合并过来
items = pd.merge(items, categories[['category_id', 'group_id']], on='category_id', how='left')
print("\n合并类别信息后的商品表(部分列):")
print(items[['item_id', 'item_name', 'category_id', 'group_id']])
原始商品信息:
   item_id                      item_name  category_id
0     2173       Grand Theft Auto V (PS4)           55
1     2174  Grand Theft Auto V (XBOX ONE)           55
2     3470            AKG K 44 Headphones           40
3     3471      Sony MDR-ZX110 Headphones           40
4     3472    Star Wars Battlefront (PS4)           55

清洗后的商品名称:
   item_id                      item_name              item_name_clean
0     2173       Grand Theft Auto V (PS4)       grand theft auto v ps4
1     2174  Grand Theft Auto V (XBOX ONE)  grand theft auto v xbox one
2     3470            AKG K 44 Headphones          akg k 44 headphones
3     3471      Sony MDR-ZX110 Headphones    sony mdr zx110 headphones
4     3472    Star Wars Battlefront (PS4)    star wars battlefront ps4

抽取前缀并编码后:
   item_id item_name_first4  item_name_first4_enc item_name_first6  \
0     2173             gran                     1           grandt
1     2174             gran                     1           grandt
2     3470             akgk                     0           akgk44
3     3471             sony                     2           sonymd
4     3472             star                     3           starwa

   item_name_first6_enc
0                     1
1                     1
2                     0
3                     2
4                     3

合并类别信息后的商品表(部分列):
   item_id                      item_name  category_id  group_id
0     2173       Grand Theft Auto V (PS4)           55         1
1     2174  Grand Theft Auto V (XBOX ONE)           55         1
2     3470            AKG K 44 Headphones           40         4
3     3471      Sony MDR-ZX110 Headphones           40         4
4     3472    Star Wars Battlefront (PS4)           55         1

销售数据清洗与预处理

销售数据是核心,但往往包含异常值。我们首先进行基于业务规则的清洗。

import pandas as pd

# 构造销售训练数据样例
sales_raw_data = {
    'date': ['02.01.2013', '03.01.2013', '04.01.2013', '05.01.2013', '10.01.2013'],
    'date_block_num': [0, 0, 0, 0, 0],
    'shop_id': [25, 25, 25, 25, 25],
    'item_id': [2173, 2174, 3470, 3471, 9999], # 9999为测试集未出现的商品
    'item_price': [1499.0, 1499.0, 799.0, 399.0, -100.0], # 包含一个异常价格
    'item_cnt_day': [1, 2, 1, 5, 2000] # 包含一个异常销量
}
sales_raw = pd.DataFrame(sales_raw_data)
print("原始销售数据(样例,含异常值):")
print(sales_raw)

# 方法1:使用 .query() 进行异常值过滤(原代码方法)
sales_clean = sales_raw.query('0 < item_price < 50000 and 0 < item_cnt_day < 1001')
print("\n使用.query()过滤异常值后:")
print(sales_clean)

# 方法2:使用布尔索引实现相同的过滤(等效方法)
sales_bool_index = sales_raw[
    (sales_raw['item_price'] > 0) &
    (sales_raw['item_price'] < 50000) &
    (sales_raw['item_cnt_day'] > 0) &
    (sales_raw['item_cnt_day'] < 1001)
]
print("\n使用布尔索引过滤后的数据:")
print(sales_bool_index)
print("两种方法结果一致:", sales_clean.equals(sales_bool_index))

# 过滤掉测试集中未出现的商店(本例中shop_id 25在测试集样例中未出现,仅为演示逻辑)
test_shop_ids = [5, 9] # 来自之前构造的测试集
sales_clean = sales_clean[sales_clean['shop_id'].isin(test_shop_ids)]
print("\n过滤掉测试集未出现的商店后(本例中shop_id 25被过滤):")
print(sales_clean)

补充说明两点数据规整的口径(本样例数据量太小,不再单独演示):

  1. 重复记录去重(原课执行的规整操作):原始商品表中存在商品名称与商品 ID 重复出现的记录,用 pandas 的 duplicated() 做去重规整。
  2. 商品维度过滤(原课未采用的可选简化):如果只想保留测试集中出现过的商品,可以类比商店过滤用 sales['item_id'].isin(test_item_ids) 滤掉非测试集商品。但原课并未这样做——全部商品的历史销量被保留,用于后续月度网格构建与 lag 类特征的计算,这样新品/测试商品的滞后特征才有历史可循;是否按商品过滤属于工程取舍,需结合内存与建模目标决定。
原始销售数据(样例,含异常值):
         date  date_block_num  shop_id  item_id  item_price  item_cnt_day
0  02.01.2013               0       25     2173      1499.0             1
1  03.01.2013               0       25     2174      1499.0             2
2  04.01.2013               0       25     3470       799.0             1
3  05.01.2013               0       25     3471       399.0             5
4  10.01.2013               0       25     9999      -100.0          2000

使用.query()过滤异常值后:
         date  date_block_num  shop_id  item_id  item_price  item_cnt_day
0  02.01.2013               0       25     2173      1499.0             1
1  03.01.2013               0       25     2174      1499.0             2
2  04.01.2013               0       25     3470       799.0             1
3  05.01.2013               0       25     3471       399.0             5

使用布尔索引过滤后的数据:
         date  date_block_num  shop_id  item_id  item_price  item_cnt_day
0  02.01.2013               0       25     2173      1499.0             1
1  03.01.2013               0       25     2174      1499.0             2
2  04.01.2013               0       25     3470       799.0             1
3  05.01.2013               0       25     3471       399.0             5
两种方法结果一致: True

过滤掉测试集未出现的商店后(本例中shop_id 25被过滤):
Empty DataFrame
Columns: [date, date_block_num, shop_id, item_id, item_price, item_cnt_day]
Index: []

时间特征与基础统计特征构建

清洗后的销售数据需要转换日期格式,并从中提取丰富的特征。

import pandas as pd
import numpy as np

# 重新构造一份清洗后的销售数据用于特征构建演示
sales_data = {
    'date': ['02.01.2013', '03.01.2013', '04.01.2013', '05.01.2013', '02.02.2013', '03.02.2013'],
    'date_block_num': [0, 0, 0, 0, 1, 1],
    'shop_id': [5, 5, 5, 9, 5, 9],
    'item_id': [5037, 5320, 5037, 5233, 5320, 6001],
    'item_price': [1499.0, 899.0, 1499.0, 1999.0, 899.0, 2499.0],
    'item_cnt_day': [1, 2, 1, 1, 3, 2]
}
sales = pd.DataFrame(sales_data)
print("用于特征构建的销售数据(清洗后样例):")
print(sales)

# 1. 日期转换与基本时间特征提取
sales['date'] = pd.to_datetime(sales['date'], format='%d.%m.%Y')
sales['weekday'] = sales['date'].dt.dayofweek  # 星期几 (0=周一)
sales['month'] = sales['date'].dt.month
sales['year'] = sales['date'].dt.year - 2013  # 相对于2013年的年份偏移
print("\n添加基本时间特征后:")
print(sales[['date', 'weekday', 'month', 'year']].head())

# 2. 计算每日收入
sales['revenue'] = sales['item_cnt_day'] * sales['item_price']
print("\n计算每日收入后:")
print(sales[['item_cnt_day', 'item_price', 'revenue']].head())

# 3. 构建“日期块编号” - 这是本项目的核心时间索引
# date_block_num = (year * 12) + month - 1
# 我们的数据中已提供此列,此处验证其计算逻辑
sales['date_block_num_calc'] = sales['year'] * 12 + sales['month'] - 1
print("\n验证 date_block_num 计算逻辑(应与原列一致):")
print(sales[['date_block_num', 'year', 'month', 'date_block_num_calc']].head())

# 4. 聚合统计:按商店和星期几统计销量占比
# 计算每个商店每个星期几的总销量
shop_weekday_sales = sales.groupby(['shop_id', 'weekday'], as_index=False)['item_cnt_day'].sum()
shop_total_sales = sales.groupby('shop_id', as_index=False)['item_cnt_day'].sum().rename(columns={'item_cnt_day': 'shop_total'})
# 合并计算占比
weekday_ratio = pd.merge(shop_weekday_sales, shop_total_sales, on='shop_id', how='left')
weekday_ratio['day_quality'] = weekday_ratio['item_cnt_day'] / weekday_ratio['shop_total']
print("\n商店-星期销量占比特征:")
print(weekday_ratio)

# 5. 核心聚合:按 date_block_num, shop_id, item_id 聚合为月度数据
sales_monthly = sales.groupby(['date_block_num', 'shop_id', 'item_id'], as_index=False).agg({
    'item_cnt_day': 'sum',
    'revenue': 'sum'
}).rename(columns={'item_cnt_day': 'item_cnt_month'})
print("\n聚合后的月度销售数据(核心训练单元):")
print(sales_monthly)
用于特征构建的销售数据(清洗后样例):
         date  date_block_num  shop_id  item_id  item_price  item_cnt_day
0  02.01.2013               0        5     5037      1499.0             1
1  03.01.2013               0        5     5320       899.0             2
2  04.01.2013               0        5     5037      1499.0             1
3  05.01.2013               0        9     5233      1999.0             1
4  02.02.2013               1        5     5320       899.0             3
5  03.02.2013               1        9     6001      2499.0             2

添加基本时间特征后:
        date  weekday  month  year
0 2013-01-02        2      1     0
1 2013-01-03        3      1     0
2 2013-01-04        4      1     0
3 2013-01-05        5      1     0
4 2013-02-02        5      2     0

计算每日收入后:
   item_cnt_day  item_price  revenue
0             1      1499.0   1499.0
1             2       899.0   1798.0
2             1      1499.0   1499.0
3             1      1999.0   1999.0
4             3       899.0   2697.0

验证 date_block_num 计算逻辑(应与原列一致):
   date_block_num  year  month  date_block_num_calc
0               0     0      1                    0
1               0     0      1                    0
2               0     0      1                    0
3               0     0      1                    0
4               1     0      2                    1

商店-星期销量占比特征:
   shop_id  weekday  item_cnt_day  shop_total  day_quality
0        5        2             1           7     0.142857
1        5        3             2           7     0.285714
2        5        4             1           7     0.142857
3        5        5             3           7     0.428571
4        9        5             1           3     0.333333
5        9        6             2           3     0.666667

聚合后的月度销售数据(核心训练单元):
   date_block_num  shop_id  item_id  item_cnt_month  revenue
0               0        5     5037               2   2998.0
1               0        5     5320               2   1798.0
2               0        9     5233               1   1999.0
3               1        5     5320               3   2697.0
4               1        9     6001               2   4998.0

📝 动手练一练

  1. 概念辨析:在清洗销售数据时,我们使用了 .query('0 < item_price < 50000 and 0 < item_cnt_day < 1001') 来过滤异常值。请简述这种基于业务规则设定阈值的方法,与基于统计方法(如3σ原则)或模型方法(如孤立森林)进行异常值检测的优缺点分别是什么?
👉 点击查看参考答案
  • 业务规则法(本节课所用):
    • 优点:直观、可解释性强,紧密结合业务常识(如价格不可能为负或天价)。计算简单高效。
    • 缺点:阈值依赖人工经验,可能不够精确;对于隐藏的、不符合常识但分布异常的“软”异常点可能失效。
  • 统计方法(如3σ原则):
    • 优点:基于数据自身分布,无需过多先验知识;对服从近似正态分布的数据效果较好。
    • 缺点:对非正态分布数据效果差;对多变量间的复杂异常关系捕捉能力弱;同样可能误杀正常点。
  • 模型方法(如孤立森林):
    • 优点:无需假设数据分布,能捕捉多变量间复杂的异常模式;适用于高维数据。
    • 缺点:计算成本较高;模型本身有参数需要调整;可解释性相对较差。

在实际工业项目中,通常建议结合使用:先用业务规则剔除“硬”异常(明显错误),再使用统计或模型方法挖掘“软”异常。

  1. 动手实践:在“时间特征与基础统计特征构建”部分的代码中,我们计算了 weekday_ratio(商店-星期销量占比)。请尝试修改代码,计算每个商店(shop_id)在所有日期内的总销售额(revenue总和),并计算每个商品(item_id)在该商店总销售额中的贡献占比。
👉 点击查看参考答案
import pandas as pd

# 商品类别数据(与本节前文一致)
categories_data = {
    'category_id': [40, 55, 16, 71, 83],
    'category_name': ['PC - Headsets / Headphones', 'Games - PS4', 'Cinema - Blu-ray', 'Gifts - Figures', 'Music - MP3']
}
categories = pd.DataFrame(categories_data)

import pandas as pd

# 清洗后的销售数据(与上一块一致,含已计算的 revenue 列)
sales_data = {
    'date': ['02.01.2013', '03.01.2013', '04.01.2013', '05.01.2013', '02.02.2013', '03.02.2013'],
    'date_block_num': [0, 0, 0, 0, 1, 1],
    'shop_id': [5, 5, 5, 9, 5, 9],
    'item_id': [5037, 5320, 5037, 5233, 5320, 6001],
    'item_price': [1499.0, 899.0, 1499.0, 1999.0, 899.0, 2499.0],
    'item_cnt_day': [1, 2, 1, 1, 3, 2]
}
sales = pd.DataFrame(sales_data)
sales['revenue'] = sales['item_cnt_day'] * sales['item_price']

import pandas as pd

# 使用之前构造的 sales DataFrame(包含 revenue 列)
# 计算每个商店的总销售额
shop_total_revenue = sales.groupby('shop_id', as_index=False)['revenue'].sum().rename(columns={'revenue': 'shop_total_rev'})

# 计算每个商店-商品组合的销售额
shop_item_revenue = sales.groupby(['shop_id', 'item_id'], as_index=False)['revenue'].sum().rename(columns={'revenue': 'item_rev_in_shop'})

# 合并计算贡献占比
contribution = pd.merge(shop_item_revenue, shop_total_revenue, on='shop_id', how='left')
contribution['rev_contribution_ratio'] = contribution['item_rev_in_shop'] / contribution['shop_total_rev']

print("商品在商店销售额中的贡献占比:")
print(contribution.sort_values(['shop_id', 'rev_contribution_ratio'], ascending=[True, False]))

运行结果示例(取决于具体数据):

商品在商店销售额中的贡献占比:
   shop_id  item_id  item_rev_in_shop  shop_total_rev  rev_contribution_ratio
1        5     5320            4495.0          7493.0                0.599893
0        5     5037            2998.0          7493.0                0.400107
3        9     6001            4998.0          6997.0                0.714306
2        9     5233            1999.0          6997.0                0.285694

此特征可以反映商品在特定商店中的重要性,是一个有价值的衍生特征。

本章小结

本节我们正式开启了电商销量预测项目的实战。我们从理解比赛任务和数据文件开始,逐步完成了数据预处理和基础特征工程的关键步骤:

  1. 数据加载与观察:认识了测试集、商品类别、商品信息和销售数据等多表结构,明确了预测目标。
  2. 类别特征处理:对结构化的类别名称进行拆分,对非结构化的商品名称进行清洗和前缀抽取,并统一使用LabelEncoder转换为数值型特征,为树模型做好准备。
  3. 销售数据清洗:基于业务规则(价格、销量范围)过滤异常值,并筛选出与测试集相关的商店,聚焦核心预测目标。
  4. 时间与统计特征构建:从日期字段中提取了星期、月份、年份等基础时间特征,构建了核心的date_block_num(月份块编号),并进行了初步的聚合统计,生成了商店-星期销量占比、月度销量与收入等特征。

这些步骤构成了特征工程工作流的坚实起点。处理后的数据已经从一个原始的、多文件的、包含异常和字符串的状态,转变为一个更干净、更具信息量的结构化表格。

📋 行动清单

  • 在你的环境中,尝试运行本节提供的自包含代码块,理解每一步数据转换的输出结果。
  • 思考:如果商品名称不是英文,而是中文或其他语言,清洗和抽取前缀的策略需要如何调整?
  • 预习:在下一节,我们将学习如何构建更复杂的滞后特征(lag features)、价格相关特征以及基于聚类的商店特征。

—— 小象教研组