赛题背景
Task1:比赛报名与数据读取
- 比赛报名
- 数据读取
- 数据查看
- Task2:比赛数据分析
- 预处理
- 数据浏览
- 用户点击日志文件
- 新闻文章信息表
- 新闻文章embedding向量表示表
- 数据分析
- 用户国家和地区分布
- 用户点击文章数
- 用户重复点击
- 用户点击环境变化
- 用户点击文章的次数
- 用户点击新闻类型偏好
- 用户点击文章的长度
- 新闻文章点击的次数
- 用户行为时间戳分析
- 新闻共现频次
- 新闻文章信息
- 文章嵌入向量
- 总结
赛题背景
赛题地址:https://tianchi.aliyun.com/competition/entrance/531842/information 赛题以预测用户未来点击新闻文章为任务,数据集报名后可见并可下载,该数据来自某新闻APP平台的用户交互数据,包括30万用户,近300万次点击,共36万多篇不同的新闻文章,同时每篇新闻文章有对应的embedding向量表示。为了保证比赛的公平性,将会从中抽取20万用户的点击日志数据作为训练集,5万用户的点击日志数据作为测试集A,5万用户的点击日志数据作为测试集B。
Task1:比赛报名与数据读取
比赛报名
数据读取
# 原始读入时间
time_start = time.time()
articles = pd.read_csv('articles.csv')
articles_emb = pd.read_csv('articles_emb.csv')
train_click = pd.read_csv('train_click_log.csv')
test_click = pd.read_csv('testA_click_log.csv')
print('load time: ',time.time() - time_start)
由于数据量比较大,读取占用内存和时间都比较多,考虑对其进行优化。
优化代码:
# 优化内存
def reduce_mem(df):
start_mem = df.memory_usage().sum() / 1024 ** 2
print('Memory usage of dataframe is {:.2f} MB'.format(start_mem))
## Reference from: https://www.kaggle.com/arjanso/reducing-dataframe-memory-size-by-65
for col in df.columns:
col_type = df[col].dtype
if col_type != object:
c_min = df[col].min()
c_max = df[col].max()
if str(col_type)[:3] == 'int':
if c_min > np.iinfo(np.int8).min and c_max < np.iinfo(np.int8).max:
df[col] = df[col].astype(np.int8)
elif c_min > np.iinfo(np.int16).min and c_max < np.iinfo(np.int16).max:
df[col] = df[col].astype(np.int16)
elif c_min > np.iinfo(np.int32).min and c_max < np.iinfo(np.int32).max:
df[col] = df[col].astype(np.int32)
elif c_min > np.iinfo(np.int64).min and c_max < np.iinfo(np.int64).max:
df[col] = df[col].astype(np.int64)
else:
if c_min > np.finfo(np.float16).min and c_max < np.finfo(np.float16).max:
df[col] = df[col].astype(np.float16)
elif c_min > np.finfo(np.float32).min and c_max < np.finfo(np.float32).max:
df[col] = df[col].astype(np.float32)
else:
df[col] = df[col].astype(np.float64)
else:
df[col] = df[col].astype('category')
end_mem = df.memory_usage().sum() / 1024 ** 2
print('Memory usage after optimization is: {:.2f} MB'.format(end_mem))
print('Decreased by {:.1f}%'.format(100 * (start_mem - end_mem) / start_mem))
return df
看下优化后内存占用:
articles = reduce_mem(articles)
articles_emb = reduce_mem(articles_emb)
train_click = reduce_mem(train_click)
test_click = reduce_mem(test_click)
可见节省了很多内存。我们把它存储下来以便以后使用,这里使用hdf5存储。
# 保存数据以免每次都重新读取
data_store_1 = pd.HDFStore('articles.h5')
# Store object in HDFStore
data_store_1.put('preprocessed_df', articles, format='table')
data_store_1.close()
data_store_2 = pd.HDFStore('articles_emb.h5')
# Store object in HDFStore
data_store_2.put('preprocessed_df', articles_emb, format='table')
data_store_2.close()
data_store_3 = pd.HDFStore('train_click.h5')
# Store object in HDFStore
data_store_3.put('preprocessed_df', train_click, format='table')
data_store_3.close()
data_store_4 = pd.HDFStore('test_click.h5')
# Store object in HDFStore
data_store_4.put('preprocessed_df', test_click, format='table')
data_store_4.close()
测试一下读取优化后数据的速度:
# 读取优化数据时间
time_start = time.time()
store_data = pd.HDFStore('articles.h5')
# 通过key获取数据
articles = store_data['preprocessed_df']
store_data.close()
store_data = pd.HDFStore('articles_emb.h5')
# 通过key获取数据
articles_emb = store_data['preprocessed_df']
store_data.close()
store_data = pd.HDFStore('train_click.h5')
# 通过key获取数据
train_click = store_data['preprocessed_df']
store_data.close()
store_data = pd.HDFStore('test_click.h5')
# 通过key获取数据
test_click = store_data['preprocessed_df']
store_data.close()
print('load time:',time.time() - time_start)
比原来快了很多。
数据查看
articles.describe()
articles_emb.describe()
train_click.describe()
test_click.describe()
Task2:比赛数据分析
参考来源: https://tianchi.aliyun.com/notebook/144451
预处理
计算用户点击rank和点击次数。
# 对每个用户的点击时间戳进行排序
train_click['rank'] = train_click.groupby(['user_id'])['click_timestamp'].rank(ascending=False).astype(int)
test_click['rank'] = test_click.groupby(['user_id'])['click_timestamp'].rank(ascending=False).astype(int)
#计算用户点击文章的次数,并添加新的一列count
train_click['click_cnts'] = train_click.groupby(['user_id'])['click_timestamp'].transform('count')
test_click['click_cnts'] = test_click.groupby(['user_id'])['click_timestamp'].transform('count')
将用户点击日志表和文章内容表做一个合并。
articles = articles.rename(columns={'article_id': 'click_article_id'}) #重命名,方便后续match
train_click = train_click.merge(articles, how='left', on=['click_article_id'])
train_click.head(3)
test_click = test_click.merge(articles, how='left', on=['click_article_id'])
test_click.head(3)
使train_click表中的点击文章id和articles表中的文章id一一对应。
数据浏览
用户点击日志文件
train_click.info()
test_click.info()
每个用户id唯一且不重复,所以我们通过查看user_id来判断用户数量。unique()方法返回的是不重复值,nunique()方法返回不同值的个数。
# user_id
train_click['user_id'].nunique(), test_click['user_id'].nunique()
可以看到训练集中有200000个用户,测试集中有50000个用户。
然后来看一下训练集和测试集中用户是否发生交叉,set()函数返回不重复元素集:
set(train_click['user_id']) & set(test_click['user_id'])
说明训练集中的用户不会在测试集中出现。因此,也就是我们在训练时,需要把测试集的数据也包括在内,称为全量数据。
画直方图大体看一下基本的属性分布.
plt.figure()
plt.figure(figsize=(15, 20))
i = 1
for col in ['click_article_id', 'click_timestamp', 'click_environment', 'click_deviceGroup', 'click_os', 'click_country',
'click_region', 'click_referrer_type', 'rank', 'click_cnts']:
plot_envs = plt.subplot(5, 2, i)
i += 1
v = train_click[col].value_counts().reset_index()[:10]
fig = sns.barplot(x=v['index'], y=v[col])
for item in fig.get_xticklabels():
item.set_rotation(90)
plt.title(col)
plt.tight_layout()
plt.show()
新闻文章信息表
articles.info()
articles.head().append(articles.tail())
文章字数统计:
articles['words_count'].value_counts().reset_index()
文章主题数:
print(articles['category_id'].nunique())
articles['category_id'].hist()
新闻文章embedding向量表示表
articles_emb.head()
数据分析
用户国家和地区分布
# click country
train_click['click_country'].nunique(), test_click['click_country'].nunique()
训练集和测试集的用户国家都有11个。查看具体分布:
plt.figure(figsize=(10,5), dpi=80)
plt.subplot(121)
train_click['click_country'].value_counts().sort_index().plot(kind='bar')
plt.xlabel('click_country')
plt.subplot(122)
test_click['click_country'].value_counts().sort_index().plot(kind='bar')
plt.tight_layout()
plt.xlabel('click_country')
可以看到训练集和测试集的用户国家分布一致,都是1、10、11,集中在1。
# click region
train_click['click_region'].nunique(), test_click['click_region'].nunique()
训练集和测试集的用户地区都是28个,查看具体分布:
plt.figure(figsize=(10,5),dpi=100)
plt.subplot(121)
train_click['click_region'].value_counts().sort_index().plot(kind='bar')
plt.xlabel('click_region')
plt.subplot(122)
test_click['click_region'].value_counts().sort_index().plot(kind='bar')
plt.xlabel('click_region')
可以看到训练集和测试集的用户地区分布基本一致。
用户点击文章数
查看用户点击文章的数目,groupby函数用来对用户分组。
plt.figure(figsize=(10,5),dpi=120)
plt.subplot(121)
train_click.groupby('user_id')['click_article_id'].nunique().plot(kind='box')
plt.subplot(122)
test_click.groupby('user_id')['click_article_id'].nunique().plot(kind='box')
可以看到训练集和测试集分布基本一致,用户点击文章数多在100以内。
# 查看每个用户最少点击文章
train_click.groupby('user_id')['click_article_id'].count().min(), test_click.groupby('user_id')['click_article_id'].count().min()
训练集中每个用户最少点击两篇文章,测试集中每个用户最少点击一篇文章。
用户重复点击
合并训练集和测试集:
# 合并训练集和测试集
user_click_merge = train_click.append(test_click)
# 用户重复点击
user_click_count = user_click_merge.groupby(['user_id', 'click_article_id'])['click_timestamp'].agg({'count'}).reset_index()
user_click_count[:10]
重复点击超过7的用户和文章:
user_click_count[user_click_count['count']>7]
重复点击次数:
user_click_count['count'].unique()
user_click_count['count'].value_counts()
可以看出有1605541(约占99.2%)的用户未重复阅读过文章,仅有极少数用户重复点击过某篇文章。
用户点击环境变化
# 用户点击环境变化
def plot_envs(df, cols, r, c):
plt.figure()
plt.figure(figsize=(10, 5))
i = 1
for col in cols:
plt.subplot(r, c, i)
i += 1
v = df[col].value_counts().reset_index()
fig = sns.barplot(x=v['index'], y=v[col])
for item in fig.get_xticklabels():
item.set_rotation(90)
plt.title(col)
plt.tight_layout()
plt.show()
# 分析用户点击环境变化是否明显,这里随机采样10个用户分析这些用户的点击环境分布
sample_user_ids = np.random.choice(test_click['user_id'].unique(), size=10, replace=False)
sample_users = user_click_merge[user_click_merge['user_id'].isin(sample_user_ids)]
cols = ['click_environment','click_deviceGroup', 'click_os', 'click_country', 'click_region','click_referrer_type']
for _, user_df in sample_users.groupby('user_id'):
plot_envs(user_df, cols, 2, 3)
可以看出绝大多数数的用户的点击环境是比较固定的,因此可以基于这些环境的统计特征来代表该用户本身的属性。
用户点击文章的次数
user_click_item_count = sorted(user_click_merge.groupby('user_id')['click_article_id'].count(), reverse=True)
plt.plot(user_click_item_count)
可以根据用户的点击文章次数看出用户的活跃度。
#点击次数在前50的用户
plt.plot(user_click_item_count[:50])
可以看出点击次数排前50的用户的点击次数都在100次以上。由此有一个判断用户活跃度的思路:我们可以定义点击次数大于等于100次的用户为活跃用户。这是一种简单的处理思路,更加全面的是结合上点击时间。
#点击次数排名在[150000:250000]之间
plt.plot(user_click_item_count[150000:250000])
可以看出点击次数小于等于两次的用户非常的多,这些用户可以认为是非活跃用户。
查看对每一篇文章不同来源下用户对应的点击次数。
click_referrer_type_count = pd.pivot_table(train_click,
index='click_article_id',
columns='click_referrer_type',
values='user_id',
aggfunc='nunique',
fill_value=0)
click_referrer_type_count
查看每一个来源的点击平均次数。
click_referrer_type_count.mean(0).plot(kind='bar')
可以看到来源1、2、5的文章点击次数比较高。
查看文章来源之间的相关性:
click_referrer_type_count.corr()
观察发现来源1和来源7,来源2和来源7都是相关性比较大的。
用户点击新闻类型偏好
此特征可以用于度量用户的兴趣是否广泛。
plt.plot(sorted(user_click_merge.groupby('user_id')['category_id'].nunique(), reverse=True))
可以看出有一小部分用户阅读类型是极其广泛的,大部分人都处在20个新闻类型以下。
用户点击文章的长度
通过统计不同用户点击新闻的平均字数,这个可以反映用户是对长文更感兴趣还是对短文更感兴趣。
plt.plot(sorted(user_click_merge.groupby('user_id')['words_count'].mean(), reverse=True))
可以发现有一小部分人看的文章平均词数非常高,也有一小部分人看的非常低。大多数人偏好于阅读字数在200-400字之间的新闻。
#挑出大多数人的区间仔细看看
plt.plot(sorted(user_click_merge.groupby('user_id')['words_count'].mean(), reverse=True)[1000:45000])
可以发现大多数人都是看250字以下的文章。
#更加详细的参数
user_click_merge.groupby('user_id')['words_count'].mean().reset_index().describe()
新闻文章点击的次数
item_click_count = sorted(user_click_merge.groupby('click_article_id')['user_id'].count(), reverse=True)
plt.plot(item_click_count)
点击次数最多的前100篇新闻:
plt.plot(item_click_count[:100])
可以看出点击次数最多的前100篇新闻,点击次数大于1000次。
plt.plot(item_click_count[:30])
点击次数最多的前20篇新闻,点击次数大于8000。简单思路:可以定义这些新闻为热门新闻。
plt.plot(item_click_count[3500:])
可以发现很多新闻只被点击过一两次。思路:可以定义这些新闻是冷门新闻。
统计文章点击人数和文章单词数是否存在关系:
plt.scatter(
train_click.groupby(['click_article_id'])['user_id'].nunique(),
train_click.groupby(['click_article_id'])['words_count'].mean()
)
大部分点击人数比较多的文章单词数都在2000以下。
对文章的点击时间和创建时间做一个差值,统计差值与点击人数的关系:
train_click['click_ts2created'] = train_click['click_timestamp'] - train_click['created_at_ts']
plt.scatter(
train_click.groupby('click_article_id')['user_id'].nunique(),
train_click.groupby('click_article_id')['click_ts2created'].mean()
)
可以发现文章创建的时间越短,被点击的概率越大。
用户行为时间戳分析
对每一个用户点击的时间戳前后做一个差值,相同的舍弃:
clicks_ts_diff = train_click.groupby('user_id')['click_timestamp'].diff(1).dropna()
clicks_ts_diff
对差值做一个统计:
clicks_ts_diff.describe().astype(int)
从平均值看出大部分点击时间差都在一小时以内。
筛选user_id为199999的用户信息:
train_click[train_click['user_id']==199999]
发现文章id为161191和42223的时间戳间隔很近,应该有某种关系。看一下它们的信息:
articles[articles['article_id'].isin([161191, 42223])]
可以看到这两篇文章的创建时间也是很接近的。
具体而言,它们相隔时间在一小时以内。
(1507646579000 - 1507648195000) /1000 /3600
#为了更好的可视化,这里把时间进行归一化操作
from sklearn.preprocessing import MinMaxScaler
mm = MinMaxScaler()
user_click_merge['click_timestamp'] = mm.fit_transform(user_click_merge[['click_timestamp']])
user_click_merge['created_at_ts'] = mm.fit_transform(user_click_merge[['created_at_ts']])
user_click_merge = user_click_merge.sort_values('click_timestamp')
user_click_merge.head()
def mean_diff_time_func(df, col):
df = pd.DataFrame(df, columns={col})
df['time_shift1'] = df[col].shift(1).fillna(0)
df['diff_time'] = abs(df[col] - df['time_shift1'])
return df['diff_time'].mean()
# 点击时间差的平均值
mean_diff_click_time = user_click_merge.groupby('user_id')['click_timestamp', 'created_at_ts'].apply(lambda x: mean_diff_time_func(x, 'click_timestamp'))
plt.plot(sorted(mean_diff_click_time.values, reverse=True))
可以发现不同用户点击文章的时间差是有差异的。
# 前后点击文章的创建时间差的平均值
mean_diff_created_time = user_click_merge.groupby('user_id')['click_timestamp', 'created_at_ts'].apply(lambda x: mean_diff_time_func(x, 'created_at_ts'))
plt.plot(sorted(mean_diff_created_time.values, reverse=True))
可以发现用户点击文章的创建时间也是有差异的。
新闻共现频次
新闻共现频次就是两篇新闻连续出现的次数。
tmp = user_click_merge.sort_values('click_timestamp')
tmp['next_item'] = tmp.groupby(['user_id'])['click_article_id'].transform(lambda x:x.shift(-1))
union_item = tmp.groupby(['click_article_id','next_item'])['click_timestamp'].agg({'count'}).reset_index().sort_values('count', ascending=False)
union_item[['count']].describe()
由统计数据可以看出,平均共现次数3.18,最高为2202。说明用户看的文章相关性是比较强的。
画图直观看:
x = union_item['click_article_id']
y = union_item['count']
plt.scatter(x, y)
plt.plot(union_item['count'].values[40000:])
大概有75000个pair至少共现一次。
新闻文章信息
#不同类型的新闻出现的次数
plt.plot(user_click_merge['category_id'].value_counts().values)
#出现次数比较少的新闻类型, 有些新闻类型,基本上就出现过几次
plt.plot(user_click_merge['category_id'].value_counts().values[150:])
# 新闻字数统计
plt.plot(user_click_merge['words_count'].values)
文章嵌入向量
将文章嵌入向量进行降维可视化。
from sklearn.decomposition import PCA
pca = PCA(n_components=2)
articles_emb_2d = pca.fit_transform(articles_emb.values[:, 1:])
plt.scatter(articles_emb_2d[:,0], articles_emb_2d[:, 1],
s = 1, c=articles['category_id'].iloc[:], alpha=0.7)
from gensim.models import Word2Vec
import logging, pickle
# 需要注意这里模型只迭代了一次
def trian_item_word2vec(click_df, embed_size=16, save_name='item_w2v_emb.pkl', split_char=' '):
click_df = click_df.sort_values('click_timestamp')
# 只有转换成字符串才可以进行训练
click_df['click_article_id'] = click_df['click_article_id'].astype(str)
# 转换成句子的形式
docs = click_df.groupby(['user_id'])['click_article_id'].apply(lambda x: list(x)).reset_index()
docs = docs['click_article_id'].values.tolist()
# 为了方便查看训练的进度,这里设定一个log信息
logging.basicConfig(format='%(asctime)s:%(levelname)s:%(message)s', level=logging.INFO)
# 这里的参数对训练得到的向量影响也很大,默认负采样为5
w2v = Word2Vec(docs, vector_size=16, sg=1, window=5, seed=2020, workers=24, min_count=1, epochs=10)
# 保存成字典的形式
item_w2v_emb_dict = {k: w2v.wv[k] for k in click_df['click_article_id']}
return item_w2v_emb_dict
item_w2v_emb_dict = trian_item_word2vec(user_click_merge)
# 随机选择5个用户,查看这些用户前后查看文章的相似性
sub_user_ids = np.random.choice(user_click_merge.user_id.unique(), size=5, replace=False)
sub_user_info = user_click_merge[user_click_merge['user_id'].isin(sub_user_ids)]
sub_user_info.head()
def get_item_sim_list(df):
sim_list = []
item_list = df['click_article_id'].values
for i in range(0, len(item_list)-1):
emb1 = item_w2v_emb_dict[str(item_list[i])] # 需要注意的是word2vec训练时候使用的是str类型的数据
emb2 = item_w2v_emb_dict[str(item_list[i+1])]
sim_list.append(np.dot(emb1,emb2)/(np.linalg.norm(emb1)*(np.linalg.norm(emb2))))
sim_list.append(0)
return sim_list
for _, user_df in sub_user_info.groupby('user_id'):
item_sim_list = get_item_sim_list(user_df)
plt.plot(item_sim_list)
总结
通过数据分析的过程, 我们目前可以得到以下几点重要的信息, 这个对于我们进行后面的特征制作和分析非常有帮助:
- 训练集和测试集的用户id没有重复,也就是测试集里面的用户模型是没有见过的
- 训练集中用户最少的点击文章数是2, 而测试集里面用户最少的点击文章数是1
- 用户对于文章存在重复点击的情况, 但这个都存在于训练集里面
- 同一用户的点击环境存在不唯一的情况,后面做这部分特征的时候可以采用统计特征
- 用户点击文章的次数有很大的区分度,后面可以根据这个制作衡量用户活跃度的特征
- 文章被用户点击的次数也有很大的区分度,后面可以根据这个制作衡量文章热度的特征
- 用户看的新闻,相关性是比较强的,所以往往我们判断用户是否对某篇文章感兴趣的时候, 在很大程度上会和他历史点击过的文章有关
- 用户点击的文章字数有比较大的区别, 这个可以反映用户对于文章字数的区别
- 用户点击过的文章主题也有很大的区别, 这个可以反映用户的主题偏好
- 不同用户点击文章的时间差也会有所区别, 这个可以反映用户对于文章时效性的偏好 所以根据上面的一些分析,可以更好的帮助我们后面做好特征工程, 充分挖掘数据的隐含信息。