前言
- 相信已经暑假一个月的大家肯定并不陌生上面这个学习软件(),面对琳琅满目的游戏总是让人不知道挑选什么,这时候一个游戏的评分往往便成为了一个玩家选择下载的原因,那么今天我们就来研究研究,
steam
上一个游戏的种种数据,是如何影响到其最终评分。 - 本文将使用pytorch对
steam_datasets
数据集进行线性回归分析,并在文章结尾尝试加入非线性优化模型。 - 本文仅提供处理此问题的一种思路,并
不是最优
,如有错误,请多多指正。 - 数据集链接
问题阐述
-
以下是数据集
steam_datasets.csv
的一小部分截图,它包含以下几个信息
Column1
:行数game
:游戏名release
:发行日期(1)peak_players
:巅峰玩家数 (2)positive_reviews
:好评(3)negative_reviews
:差评(4)total_reviews
: 总评价数primary_genre
: 游戏分类 (5)detected_technologies
游戏开发平台(6)review_percentage
:玩家对游戏的总体满意程度(7)players_right_now
:当前玩家数(8)24_hour_peak
:24小时玩家巅峰数(9)all_time_peak
:总玩家巅峰数(10)rating
:游戏打分(输出)
-
要求根据上述提供的数据集完成下述要求:
- 标签选取:以上述10个标签作为线性回归的输入,尝试对
rating
进行线性回归预测 - 读取数据集:自行从csv数据集挑选合适数量的数据集(请不要全拿进去)作为训练集(注意数据预处理和错误数据的剔除)
- 选择合适的模型及其优化算法对模型进行训练
- 要求使用
pytorch
实现上述代码
- 标签选取:以上述10个标签作为线性回归的输入,尝试对
数据预处理
数据解码
- 在拿到数据集
steam_datasets.csv
并对其进行回归处理之前,我们首先需要对数据进行读取,在对数据进行读取之前,我们可以使用chardet
库(这是一个用于检测文本数据的字符编码的库),对数据集进行编码检测,从而在使用pandas
对数据集读取时指定正确的编码格式。
import chardet
with open('./steam_datasets.csv', 'rb') as f:
result = chardet.detect(f.read()) # 编码检测
print(result['encoding']) # 输出编码格式
- 此外,
GB2312
,GBK
,GB18030
,着三个编码格式是兼容的,包含的字符个数:GB2312
<GBK
<GB18030
,这里chardet
库给出的结果是GB2312
,但是使用这个编码格式进行读取会遇到报错,故我们往上选择GBK
进行数据解码。
数据读取
Pandas
是一个开放源码、BSD 许可的库,提供高性能、易于使用的数据结构和数据分析工具。这里我们使用Pandas
对csv
数据集进行读取,并指定编码格式。
import pandas as pd
raw_data=pd.read_csv('./steam_datasets.csv',encoding="GBK")
print(raw_data)
异常数据清理
- 在完成数据转换之后,我们需要进行数据清洗,这包括处理缺失值、异常值和不必要的列。
- 处理缺失值: 对于缺失值,可以选择填充、删除含有缺失值的行或者使用模型预测缺失值。这里我们直接丢失。
raw_data = raw_data.dropna()
- 删除不必要的列:对于不是10标签输入的列,我们选择删除。
raw_data = raw_data.drop(['game','Column1','total_reviews'], axis=1)
raw_data = raw_data.dropna()
- 剔除非数值数据:对于标签
peak_players
,positive_reviews
,negative_reviews
,review_percentage
,players_right_now
,24_hour_peak
,all_time_peak
,rating
这8列都应该是数据类型,我们观察csv
文件可以发现在这些标签中,混杂着非数值类型的日期和字符串,为此我们需要进行删除。- 如下出现了
rating
和all_time_peak
出现非法值的情况
- 如下出现了
def to_numeric_if_possible(x):
try:
return pd.to_numeric(x)
except (ValueError, TypeError):
return pd.NA
check_labels=['peak_players', 'positive_reviews', 'negative_reviews', 'review_percentage', 'players_right_now', '24_hour_peak', 'all_time_peak', 'rating']
# 指定列名并应用自定义函数
raw_data[check_labels] = raw_data[check_labels].applymap(to_numeric_if_possible)
raw_data = raw_data.dropna()
数据分析–非数据类型标签转换
- 通过观察上述10标签中,我们注意到
release
,primary_genre
,detected_technologies
着三个标签属于非数值,对于非数值类型的标签,我们需要转换为数值类型的标签来进行线性回归。
日期类数据处理
- 观察
release
是2023/1/26
,属于日期类数据类型,我们可以将日期转换为距离某个特定日期的天数,这样可以作为一个连续变量用于回归。 - 这里需要注意数据集中包含无法解析的日期类型,需要当作异常数据剔除。
from datetime import datetime
# 尝试将 'release' 列转换为日期,无法解析的设置为 NaT
raw_data['release'] = pd.to_datetime(raw_data['release'], errors='coerce', format='%Y/%m/%d')
# 删除包含 NaT 的行
raw_data = raw_data.dropna(subset=['release'])
# 选择2005-01-01作为参考点
specific_date = datetime.strptime('2005-01-01', '%Y-%m-%d')
raw_data['release'] = pd.to_datetime(raw_data['release']).map(lambda date: (date - specific_date).days)
print(raw_data['release'])
# 计算依次最大最小值是为了检查是否出现非法值,此外根据最大最小值调整参考日期
print(max(raw_data['release']))
print(min(raw_data['release']))
字符型数据处理
- 这里先介绍几种常见的非数值类型编码方式:
哑编码(独热编码,One-Hot Encoding)
: 独热编码是一种将分类变量转换为一系列二进制列的过程,其中每列对应一个可能的类别值。这些列中的值通常为0或1,表示某个特定类别是否存在。- 当分类变量的类别数量较少时,独热编码是一种有效的处理方法。
分解(Factorization)
:分解是将分类数据转换为整数的过程。每个类别被分配一个唯一的整数。这种方法不会产生独热编码那样的稀疏矩阵,但它可能会丢失一些类别间的信息,因为它只保留了一个整数而不是整个类别结构。- 当类别数量较多,且不需要保留原始类别结构时,分解是一种节省空间的处理方法。
标签编码(Label Encoding)
:类别编码是将类别转换为数值的方法
- 那么我们现在来观察这两个字符型数据,首先来观察
primary_genre
-
-
观察可以发现,每个游戏分类都已经分配好对应的序号,且继续观察多标签的行数基本上都属于错误数据,都已经被剔除,故这里只需要提取出
单分类
的游戏分类对应的数据即可
-
- 我们导入正则表达式
re
库对括号的数字进行提取,并对其中是否为单数字进行检测
import re
# 使用正则表达式提取括号中的数字,并将它们转换为逗号分隔的字符串
raw_data['primary_genre'] = raw_data['primary_genre'].apply(
lambda x: ','.join(re.findall(r'\((\d+)\)', x)))
is_single_digit = raw_data['primary_genre'].apply(lambda x: all(len(str(item)) == 1 for item in x))
# 检查是否全部为单数字
all_single_digit = is_single_digit.all()
print(all_single_digit)
print(raw_data['primary_genre'])
# 将字符串转换为数字,使用之前的函数
raw_data['primary_genre'] = raw_data['primary_genre'].apply(to_numeric_if_possible)
raw_data = raw_data.dropna()
print(raw_data['primary_genre'])
# 输出最大最小值进行确认
print(max(raw_data['primary_genre']))
print(min(raw_data['primary_genre']))
- 然后接着我们来看
detected_technologies
- 观察发现,
detected_technologies
由好几种开发工具组成,为此我们使用代码进行统计
primary_genre_str = raw_data['detected_technologies'].str.cat(sep=';')
# split 分割文本
entries = [entry for line in primary_genre_str.split("\n") for entry in line.split(";")]
all_categories=set()
for entry in entries:
entry = entry.strip() # 移除前后的空白字符
if '.' in entry:
all_categories.add(entry.split('.')[0])
print(all_categories)
-
通过统计字符
.
前面的字符串,我们得到了所有字符的分类 -
这里我们对所有标签进行统计,计算总类别数
all_categories=set()
# 遍历每个条目,检查是否以 all_categories_label 中的某个标签开头
for entry in entries:
entry = entry.strip()
for label in all_categories_label:
if entry.startswith(label):
all_categories.add(entry)
break
print("all_categories ", all_categories)
print("Number of all_categories:", len(all_categories))
- 一共151类,够多的…这么看来使用独热编码是不太现实的,这里我采用
标签编码
# 进行标签编码
all_categories_dict = {label: idx for idx, label in enumerate(sorted(all_categories))}
print(all_categories_dict)
-
如下顾名思义
-
紧接着我们对原数据中的标签进行替换
# 使用正则表达式检查是否为数字
data_as_lists = []
for entry in raw_data['detected_technologies']:
# 分割字符串
split_entries = entry.split('; ')
# 只保留数字部分
numeric_entries = [int(num) for num in split_entries if re.match(r'^\d+$', num)]
data_as_lists.append(numeric_entries)
raw_data['detected_technologies']=data_as_lists
print(raw_data['detected_technologies'])
-
原始数据
detected_technologies
就完成了非字符型的处理的第一步 -
仔细观察,上述
detected_technologies
的数据的每一行是一个长度不定的输入,对于线性模型,在不想增加其输入维度的情况下,这里我采用PCA降维
-
主成分分析(PCA,Principal Component Analysis)
是一种统计方法,它通过正交变换将一组可能相关的变量转换为一组线性不相关的变量,这组变量称为主成分。PCA的主要目的是降维,即在尽可能保留原始数据信息的前提下,减少数据的特征维度。
from sklearn.decomposition import PCA
import numpy as np
# 找到最长的列表长度
max_length = max(len(lst) for lst in raw_data['detected_technologies'])
# 使用列表推导式和列表的extend方法来填充列表,确保所有列表长度一致
padded_technologies = [x + [0]*(max_length - len(x)) for x in raw_data['detected_technologies']]
technologies_array = np.array(padded_technologies)
# 应用PCA降维到1维
pca = PCA(n_components=1)
technologies_pca = pca.fit_transform(technologies_array)
print(technologies_pca)
raw_data['detected_technologies']=technologies_pca
- 降维后我们得到:
最终检查
- 最终再确认一下
all_label=['rating','release','peak_players','positive_reviews','negative_reviews','primary_genre','detected_technologies','review_percentage','players_right_now','24_hour_peak','all_time_peak']
# 再次检查每一列的数据类型
for col in all_label:
print(f"{col}: {raw_data[col].dtype}")
# 确保所有列都是数值类型
for col in all_label:
if col=='detected_technologies':
continue
if raw_data[col].dtype == object:
# 尝试将非数值类型转换为数值类型
raw_data[col] = pd.to_numeric(raw_data[col], errors='coerce')
# 删除任何仍然包含 NaN 值的行
raw_data = raw_data.dropna(subset=all_label)
# 再次检查每一列的数据类型
for col in all_label:
print(f"{col}: {raw_data[col].dtype}")
print(raw_data)
模型训练
- 那么我们正式开始训练模型…
生成训练集/测试集
数据标准化(Standardization)
:数据标准化旨在调整数据的尺度,使每个特征具有相同的数值范围,从而消除特征之间的量纲影响,确保数据在训练过程中被平等对待训练集(Training Set)
-用于训练模型,即通过迭代优化模型的参数(如权重和偏置)来最小化损失函数。(有时候还会进行训练集
和验证集
的划分)测试集(Test Set)
: 用于评估模型在未见过的数据上的表现,即模型的泛化能力。
import torch
from sklearn.model_selection import train_test_split
import torch.nn as nn
from sklearn.preprocessing import StandardScaler
# 选择除了 'rating' 之外的所有列作为特征
X = raw_data.drop(columns=['rating']).values
# 标准化
scaler = StandardScaler()
X = scaler.fit_transform(X)
y = raw_data['rating'].values
# 划分训练集和测试集
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
# 将数据转换为PyTorch张量
X_train_tensor = torch.tensor(X_train, dtype=torch.float32)
y_train_tensor = torch.tensor(y_train, dtype=torch.float32).view(-1, 1)
X_test_tensor = torch.tensor(X_test, dtype=torch.float32)
y_test_tensor = torch.tensor(y_test, dtype=torch.float32).view(-1, 1)
定义模型
- 这边定义了一个简单的线性模型,继承自
nn
模组,需要重写forward
用于前向传播
class LinearRegressionModel(nn.Module):
def __init__(self, input_dim):
super(LinearRegressionModel, self).__init__()
self.linear = nn.Linear(input_dim, 1)
def forward(self, x):
return self.linear(x)
# 实例化模型
input_dim = X_train.shape[1]
model = LinearRegressionModel(input_dim)
定义损失函数和优化器
均方误差损失(MSELoss)
:- MSELoss计算预测值和真实值之间差的平方的平均值。
- 它适用于回归问题,特别是当输出是连续值时。
- 数学表达式为:MSELoss=1𝑛∑𝑖=1𝑛(𝑦𝑖−𝑦𝑖)2MSELoss=n1∑i=1n(yi−yi)2 其中,𝑦𝑖yi 是真实值,𝑦𝑖yi 是预测值,𝑛n 是样本数量。
随机梯度下降(SGD)
:- SGD是一种常见的优化算法,用于寻找使损失函数最小化的参数。
model.parameters()
是模型中所有可学习参数的迭代器。lr
是学习率(learning rate),它控制了参数更新的步长大小。学习率的选择对模型训练至关重要,太低的学习率可能导致训练缓慢,而太高的学习率可能导致训练过程不稳定或越过最小值。
criterion = nn.MSELoss()
optimizer = torch.optim.SGD(model.parameters(), lr=0.004)
模型训练
- 模型的训练很简单,如下,需要记得手动进行
梯度清零
和设置反向传播
和优化
即可
# 训练模型
num_epochs = 1000
for epoch in range(num_epochs):
model.train()
optimizer.zero_grad()
# 前向传播
outputs = model(X_train_tensor)
# 计算损失
loss = criterion(outputs, y_train_tensor)
# 反向传播和优化
loss.backward()
optimizer.step()
if (epoch+1) % 100 == 0:
print(f'Epoch [{epoch+1}/{num_epochs}], Loss: {loss.item():.4f}')
模型测试
- 在测试集上进行测试
# 在测试集上验证模型
model.eval()
with torch.no_grad():
predictions = model(X_test_tensor)
test_loss = criterion(predictions, y_test_tensor)
print(f'Test Loss: {test_loss.item():}')
获取结果
- 通过输出权重和偏差,我们可以得到我们的方程
weights = model.linear.weight.data
bias = model.linear.bias.data
print(f"Weights: {weights}")
print(f"Bias: {bias}")
-
如下我们得到10输入的权重和偏差值
-
通过上述步骤,我们观察到损失在逐渐减小最后稳定
总结和优化-加入非线性
加入ReLu激活函数和BN
- 可以看得出来,上述线性模型的效果并没用达到很好的效果,这时我们考虑引入非线性
class ComplexModel(nn.Module):
def __init__(self, input_size):
super(ComplexModel, self).__init__()
self.fc = nn.Linear(input_size, 64)
self.bn = nn.BatchNorm1d(64)
self.relu = nn.ReLU()
self.fc2 = nn.Linear(64, 1)
def forward(self, x):
out = self.fc(x)
out = self.bn(out)
out = self.relu(out)
out = self.fc2(out)
return out
批量归一化(Batch Normalization,简称BN)
是一种用于加速深度网络训练的技术,同时也可以作为一种正则化手段,提高模型的泛化能力。ReLU(Rectified Linear Unit)激活函数
是目前深度学习中使用最广泛的激活函数之一- 𝑓(𝑥)=max(0,𝑥)f(x)=max(0,x)
非线性
:ReLU函数为网络引入了非线性
特性,这对于神经网络能够捕捉复杂的数据模式至关重要。
更换优化器Adam
- 同时我们更换优化器为
Adam
:Adam算法通过计算梯度的一阶矩估计(即均值)和二阶矩估计(即未中心化的方差)来适应性地调整每个参数的学习率。
optimizer = torch.optim.Adam(model.parameters(), lr=0.4)
增加迭代轮数
- 我们
稍微
增加以下迭代轮数
num_epochs = 50000
结果
- 通过训练结果可以看到,相比于线性模型,非线性的引入使模型的loss下降了,虽然还有一些参数和模型可以调整进一步优化(懒了不想做啦)
总结
- 从上述我们可以看出,数据预处理实际上远远比模型训练更重要也更关键,往往成败就在数据处理种
- 非线性的引入会使模型损失大大减小
- 本文仅提供处理此问题的一种思路,并
不是最优
,还有参数可以调整,如有错误,请多多指正。