IBM反洗钱交易数据_GNN节点分类检测案例
1.使用HI-Small_Trans.csv作为训练和测试数据集。
表头:
| Timestamp | From Bank | Account | Amount Received | To Bank | Account.1 |
|---|---|---|---|---|---|
| Amount Received | Receiving Currency | Amount Paid | Payment Currency | Payment | Format |
2.数据分析
从所有交易中提取接收方和付款方的所有账户,以便对可疑账户进行排序。我们可以将整个数据集转化为节点分类问题,将每个账户视为节点,将账户间的交易视为边。
import datetime
import os
from typing import Callable, Optional
import pandas as pd
from sklearn import preprocessing
import numpy as np
import torch
# 用于处理图数据
from torch_geometric.data import (
Data,
InMemoryDataset
)
pd.set_option('display.max_columns', None)
path = './data/raw/HI-Small_Trans.csv'
df = pd.read_csv(path)
# 打印前几行数据
print(df.head())
# 打印数据框df中每一列的数据类型
print(df.dtypes)
# 检查是否有任何空值
# 打印数据框df中每列的缺失值数量
print(df.isnull().sum())
# 有两列分别表示每笔交易的支付和收到的金额
# 通过判断交易的支付和收到的金额是否相等,可将金额拆分为两列或合并为一列
print('收到的金额是否等于支付的金额')
print(df['Amount Received'].equals(df['Amount Paid']))
print("收款货币种类是否等于支付货币种类")
print(df['Receiving Currency'].equals(df['Payment Currency']))
打印结果后发现两个数据框的大小显示存在交易费用和不同货币之间的交易,我们不能合并/删除金额列。
理清列的数量后,需要对列进行编码,必须确保相同属性的类别是对齐的。
检查一下收款货币和付款货币的列表是否相同。
# 打印出df['Receiving Currency']列中的唯一值,并按照字母顺序进行排序
print(sorted(df['Receiving Currency'].unique()))
# 打印出df['Payment Currency']列中的唯一值,并按照字母顺序进行排序
print(sorted(df['Payment Currency'].unique()))
对照后发现相同,后续对数据进行预处理。
3.数据预处理
在数据预处理中,执行以下转换:
1.使用最小-最大归一化转换时间戳。
2.通过将银行代码与账号相加,为每个账户创建唯一ID。
3.使用接收账户、接收金额和货币信息创建receiving_df。
4.使用付款账户、付款金额和货币信息创建paying_df。
5.创建一个包含所有交易中使用的货币的列表。
6.使用sklearn LabelEncoder将’Payment Format’、'Payment Currency’和’Receiving Currency’标记为类别。
# 对指定的列进行标签编码
def df_label_encoder(df, columns):
# 创建 LabelEncoder 对象
le = preprocessing.LabelEncoder()
for i in columns:
# 将列中的值转换为字符串类型,并进行标签编码
df[i] = le.fit_transform(df[i].astype(str))
return df
def preprocess(self,df):
# 1.使用最小-最大归一化转换时间戳。
# 对指定的列进行标签编码
df = self.df_label_encoder(df, ['Payment Format', 'Payment Currency', 'Receiving Currency'])
# 将时间戳转换为日期时间类型
df['Timestamp'] = pd.to_datetime(df['Timestamp'])
# 将时间戳归一化到 [0, 1] 范围内
df['Timestamp'] = df['Timestamp'].apply(lambda x: x.value)
df['Timestamp'] = (df['Timestamp'] - df['Timestamp'].min()) / (df['Timestamp'].max() - df['Timestamp'].min())
# 2.通过将银行代码与账号字符串相拼接,为每个账户创建唯一ID
# 将 'Account' 列和 'From Bank' 列进行字符串拼接,作为新的 'Account' 列
df['Account'] = df['From Bank'].astype(str) + '_' + df['Account']
# 将 'Account.1' 列和 'To Bank' 列进行字符串拼接,作为新的 'Account.1' 列
df['Account.1'] = df['To Bank'].astype(str) + '_' + df['Account.1']
# 3.使用接收账户、接收金额和货币信息创建receiving_df
# df按照 'Account' 列进行升序排序
df = df.sort_values(by=['Account'])
# 从 df 中提取 'Account.1', 'Amount Received', 'Receiving Currency' 列,作为 receiving_df 数据框
receiving_df = df[['Account.1', 'Amount Received', 'Receiving Currency']]
# 4.使用付款账户、付款金额和货币信息创建paying_df。
# 从 df 中提取 'Account', 'Amount Paid', 'Payment Currency' 列,作为 paying_df 数据框
paying_df = df[['Account', 'Amount Paid', 'Payment Currency']]
# 将 receiving_df 的 'Account.1' 列重命名为 'Account'
receiving_df = receiving_df.rename({'Account.1': 'Account'}, axis=1)
# 5.创建一个包含所有交易中使用的货币的列表
# 获取 df 中 'Receiving Currency' 列的唯一值,并进行排序,得到货币种类列表 currency_ls
currency_ls = sorted(df['Receiving Currency'].unique())
return df, receiving_df, paying_df, currency_ls
从付款方和收款方中提取所有唯一的账户作为图表的节点,节点属性包括唯一的账户ID、银行代码和“是否洗钱”的标签,其中涉及非法交易的付款方和收款方都视为可疑账户,并标记这两个可疑账户的“是否洗钱”为1。
def get_all_account(df):
# 创建一个包含所有账户的列表
ldf = df[['Account', 'From Bank']]
rdf = df[['Account.1', 'To Bank']]
# 从df中选择'Is Laundering'等于1的行,作为可疑账户(其中付款方和接收方都为可疑账户)
suspicious = df[df['Is Laundering'] == 1]
s1 = suspicious[['Account', 'Is Laundering']]
s2 = suspicious[['Account.1', 'Is Laundering']]
s2 = s2.rename({'Account.1': 'Account'}, axis=1)
# 将s1和s2按行连接,赋值给suspicious(付款方和接收方都为可疑账户)
suspicious = pd.concat([s1, s2], join='outer')
# 删除suspicious中的重复行
suspicious = suspicious.drop_duplicates()
# 将ldf的'From Bank'列重命名为'Bank',赋值给ldf
ldf = ldf.rename({'From Bank': 'Bank'}, axis=1)
# 将rdf的'Account.1'列重命名为'Account',将'To Bank'列重命名为'Bank',赋值给rdf
rdf = rdf.rename({'Account.1': 'Account', 'To Bank': 'Bank'}, axis=1)
# 将ldf和rdf按行连接,赋值给df
df = pd.concat([ldf, rdf], join='outer')
# 删除df中的重复行,赋值给df
df = df.drop_duplicates()
# 将df的'Is Laundering'列的所有值设为0
df['Is Laundering'] = 0
# 将df的索引设置为'Account'
df.set_index('Account', inplace=True)
# 使用suspicious的'Account'列作为索引,更新df的'Is Laundering'列
df.update(suspicious.set_index('Account'))
# 重置df的索引
df = df.reset_index()
return df
4.图的节点和边
4.1 节点特征
将不同类型货币的支付和收到金额的平均值作为每个节点的新特征进行聚合。
def paid_currency_aggregate(currency_ls, paying_df, accounts):
for i in currency_ls:
# 从支付数据中筛选出支付货币为当前货币的数据
temp = paying_df[paying_df['Payment Currency'] == i]
# 计算每个账户的平均支付金额
accounts['avg paid '+str(i)] = temp['Amount Paid'].groupby(temp['Account']).transform('mean')
# 返回计算后的账户信息
return accounts
def received_currency_aggregate(currency_ls, receiving_df, accounts):
for i in currency_ls:
# 从收款数据中筛选出收到货币为当前货币的数据
temp = receiving_df[receiving_df['Receiving Currency'] == i]
# 计算每个账户的平均收款金额
accounts['avg received ' + str(i)] = temp['Amount Received'].groupby(temp['Account']).transform('mean')
# 将缺失值填充为0
accounts = accounts.fillna(0)
# 返回计算后的账户信息
return accounts
接下来通过银行代码和不同货币类型的付款和收款金额的平均值来定义节点属性。
# 获取节点属性
def get_node_attr(currency_ls, paying_df,receiving_df, accounts):
node_df = paid_currency_aggregate(currency_ls, paying_df, accounts)
node_df = received_currency_aggregate(currency_ls, receiving_df, node_df)
# 将node_df中的'Is Laundering'列的值转换为torch.float类型,并赋值给node_label
node_label = torch.from_numpy(node_df['Is Laundering'].values).to(torch.float)
# 从node_df中删除'Account'和'Is Laundering'两列
node_df = node_df.drop(['Account', 'Is Laundering'], axis=1)
# 调用df_label_encoder函数,将node_df中的'Bank'列进行标签编码
node_df = df_label_encoder(node_df, ['Bank'])
return node_df, node_label
node_df, node_label = get_node_attr(currency_ls, paying_df, receiving_df, accounts)
4.2 边特征
将每个交易视为边。
边索引,将所有帐户替换为索引,并将其堆叠到大小为[2,交易数]的列表中,这里是为了描述edge_index(表示图中边的索引的张量)
边属性只包含:“时间戳”、“收到的金额”、“收款货币”、“支付金额”、“支付货币”和“支付格式”。
def get_edge_df(accounts, df):
# 将accounts的索引重置为'ID'列
accounts = accounts.reset_index(drop=True)
accounts['ID'] = accounts.index
# 创建一个字典,将accounts的'Account'列的值映射为对应的'ID'列的值
mapping_dict = dict(zip(accounts['Account'], accounts['ID']))
# 将df中的'Account'列的值通过映射字典转换为对应的ID,并赋值给'From'列
df['From'] = df['Account'].map(mapping_dict)
# 将df中的'Account.1'列的值通过映射字典转换为对应的ID,并赋值给'To'列
df['To'] = df['Account.1'].map(mapping_dict)
# 删除df中的'Account', 'Account.1', 'From Bank', 'To Bank'列
df = df.drop(['Account', 'Account.1', 'From Bank', 'To Bank'], axis=1)
# 创建一个二维张量,其中第一行是df['From']列的值,第二行是df['To']列的值
edge_index = torch.stack([torch.from_numpy(df['From'].values), torch.from_numpy(df['To'].values)], dim=0)
# 删除df中的'Is Laundering', 'From', 'To'列
df = df.drop(['Is Laundering', 'From', 'To'], axis=1)
edge_attr = df
return edge_attr, edge_index
# edge_attr是边的属性,edge_index是边的索引
edge_attr, edge_index = get_edge_df(accounts, df)
总体代码如下:
import datetime
import os
from typing import Callable, Optional
import pandas as pd
# 数据预处理
from sklearn import preprocessing
import numpy as np
import torch
# 用于处理图数据
from torch_geometric.data import (
Data,
InMemoryDataset
)
pd.set_option('display.max_columns', None)
path = './data/raw/HI-Small_Trans.csv'
df = pd.read_csv(path)
class AMLtoGraph(InMemoryDataset):
def __init__(self, root: str, edge_window_size: int = 10,
transform: Optional[Callable] = None,
pre_transform: Optional[Callable] = None):
# 初始化函数,接收root(数据存储路径)、edge_window_size(边窗口大小)、transform(数据转换函数)、pre_transform(预处理函数)作为参数
self.edge_window_size = edge_window_size
super().__init__(root, transform, pre_transform)
# 调用父类的初始化函数
self.data, self.slices = torch.load(self.processed_paths[0])
# 加载已处理的数据
@property
def raw_file_names(self) -> str:
# 返回原始数据文件名
return 'HI-Small_Trans.csv'
@property
def processed_file_names(self) -> str:
# 返回处理后的数据文件名
return 'data.pt'
@property
def num_nodes(self) -> int:
# 返回节点数量
return self._data.edge_index.max().item() + 1
# 对指定的列进行标签编码
def df_label_encoder(self, df, columns):
# 创建 LabelEncoder 对象
le = preprocessing.LabelEncoder()
for i in columns:
# 将列中的值转换为字符串类型,并进行标签编码
df[i] = le.fit_transform(df[i].astype(str))
return df
def preprocess(self,df):
# 1.使用最小-最大归一化转换时间戳。
# 对指定的列进行标签编码
df = self.df_label_encoder(df, ['Payment Format', 'Payment Currency', 'Receiving Currency'])
# 将时间戳转换为日期时间类型
df['Timestamp'] = pd.to_datetime(df['Timestamp'])
# 将时间戳归一化到 [0, 1] 范围内
df['Timestamp'] = df['Timestamp'].apply(lambda x: x.value)
df['Timestamp'] = (df['Timestamp'] - df['Timestamp'].min()) / (df['Timestamp'].max() - df['Timestamp'].min())
# 2.通过将银行代码与账号字符串相拼接,为每个账户创建唯一ID
# 将 'Account' 列和 'From Bank' 列进行字符串拼接,作为新的 'Account' 列
df['Account'] = df['From Bank'].astype(str) + '_' + df['Account']
# 将 'Account.1' 列和 'To Bank' 列进行字符串拼接,作为新的 'Account.1' 列
df['Account.1'] = df['To Bank'].astype(str) + '_' + df['Account.1']
# 3.使用接收账户、接收金额和货币信息创建receiving_df
# df按照 'Account' 列进行升序排序
df = df.sort_values(by=['Account'])
# 从 df 中提取 'Account.1', 'Amount Received', 'Receiving Currency' 列,作为 receiving_df 数据框
receiving_df = df[['Account.1', 'Amount Received', 'Receiving Currency']]
# 4.使用付款账户、付款金额和货币信息创建paying_df。
# 从 df 中提取 'Account', 'Amount Paid', 'Payment Currency' 列,作为 paying_df 数据框
paying_df = df[['Account', 'Amount Paid', 'Payment Currency']]
# 将 receiving_df 的 'Account.1' 列重命名为 'Account'
receiving_df = receiving_df.rename({'Account.1': 'Account'}, axis=1)
# 5.创建一个包含所有交易中使用的货币的列表
# 获取 df 中 'Receiving Currency' 列的唯一值,并进行排序,得到货币种类列表 currency_ls
currency_ls = sorted(df['Receiving Currency'].unique())
return df, receiving_df, paying_df, currency_ls
def get_all_account(self,df):
# 创建一个包含所有账户的列表
ldf = df[['Account', 'From Bank']]
rdf = df[['Account.1', 'To Bank']]
# 从df中选择'Is Laundering'等于1的行,作为可疑账户(其中付款方和接收方都为可疑账户)
suspicious = df[df['Is Laundering'] == 1]
s1 = suspicious[['Account', 'Is Laundering']]
s2 = suspicious[['Account.1', 'Is Laundering']]
s2 = s2.rename({'Account.1': 'Account'}, axis=1)
# 提取可疑账户
# 将s1和s2按行连接,赋值给suspicious(付款方和接收方都为可疑账户)
suspicious = pd.concat([s1, s2], join='outer')
# 删除suspicious中的重复行
suspicious = suspicious.drop_duplicates()
# 合并账户信息
# 将ldf的'From Bank'列重命名为'Bank',赋值给ldf
ldf = ldf.rename({'From Bank': 'Bank'}, axis=1)
# 将rdf的'Account.1'列重命名为'Account',将'To Bank'列重命名为'Bank',赋值给rdf
rdf = rdf.rename({'Account.1': 'Account', 'To Bank': 'Bank'}, axis=1)
# 将ldf和rdf按行连接,赋值给df
df = pd.concat([ldf, rdf], join='outer')
# 删除df中的重复行,赋值给df
df = df.drop_duplicates()
# 更新账户的洗钱标签
# 将df的'Is Laundering'列的所有值设为0
df['Is Laundering'] = 0
# 将df的索引设置为'Account'
df.set_index('Account', inplace=True)
# 使用suspicious的'Account'列作为索引,更新df的'Is Laundering'列
df.update(suspicious.set_index('Account'))
# 重置df的索引
df = df.reset_index()
return df
# 按付款货币种类对账户进行聚合
def paid_currency_aggregate(self,currency_ls, paying_df, accounts):
for i in currency_ls:
# 从支付数据中筛选出支付货币为当前货币的数据
temp = paying_df[paying_df['Payment Currency'] == i]
# print(i)
# print(temp)
# 计算每个账户的平均支付金额
accounts['avg paid '+str(i)] = temp['Amount Paid'].groupby(temp['Account']).transform('mean')
# 返回计算后的账户信息
return accounts
# 按收款货币种类对账户进行聚合
def received_currency_aggregate(self,currency_ls, receiving_df, accounts):
for i in currency_ls:
# 从收款数据中筛选出收到货币为当前货币的数据
temp = receiving_df[receiving_df['Receiving Currency'] == i]
# 计算每个账户的平均收款金额
accounts['avg received '+str(i)] = temp['Amount Received'].groupby(temp['Account']).transform('mean')
# 将缺失值填充为0
accounts = accounts.fillna(0)
# 返回计算后的账户信息
return accounts
# 获取节点属性
def get_node_attr(self,currency_ls, paying_df, receiving_df, accounts):
node_df = self.paid_currency_aggregate(currency_ls, paying_df, accounts)
node_df = self.received_currency_aggregate(currency_ls, receiving_df, node_df)
# 将node_df中的'Is Laundering'列的值转换为torch.float类型,并赋值给node_label
node_label = torch.from_numpy(node_df['Is Laundering'].values).to(torch.float)
# 从node_df中删除'Account'和'Is Laundering'两列
node_df = node_df.drop(['Account', 'Is Laundering'], axis=1)
# 调用df_label_encoder函数,将node_df中的'Bank'列进行标签编码
node_df = self.df_label_encoder(node_df, ['Bank'])
node_df = torch.from_numpy(node_df.values).to(torch.float)
# 返回node_df和node_label作为函数的结果
return node_df, node_label
def get_edge_df(self,accounts, df):
# 将accounts的索引重置为'ID'列
accounts = accounts.reset_index(drop=True)
accounts['ID'] = accounts.index
# 创建一个字典,将accounts的'Account'列的值映射为对应的'ID'列的值
mapping_dict = dict(zip(accounts['Account'], accounts['ID']))
# 将df中的'Account'列的值通过映射字典转换为对应的ID,并赋值给'From'列
df['From'] = df['Account'].map(mapping_dict)
# 将df中的'Account.1'列的值通过映射字典转换为对应的ID,并赋值给'To'列
df['To'] = df['Account.1'].map(mapping_dict)
# 删除df中的'Account', 'Account.1', 'From Bank', 'To Bank'列
df = df.drop(['Account', 'Account.1', 'From Bank', 'To Bank'], axis=1)
# 创建一个二维张量,其中第一行是df['From']列的值,第二行是df['To']列的值
edge_index = torch.stack([torch.from_numpy(df['From'].values), torch.from_numpy(df['To'].values)], dim=0)
# 删除df中的'Is Laundering', 'From', 'To'列
df = df.drop(['Is Laundering', 'From', 'To'], axis=1)
edge_attr = torch.from_numpy(df.values).to(torch.float)
# edge_attr是边的属性,edge_index是边的索引
return edge_attr, edge_index
# # edge_attr是边的属性,edge_index是边的索引
# edge_attr, edge_index = get_edge_df(accounts, df)
def process(self):
# 读取数据
df = pd.read_csv(self.raw_paths[0])
df, receiving_df, paying_df, currency_ls = self.preprocess(df)
accounts = self.get_all_account(df)
node_attr, node_label = self.get_node_attr(currency_ls, paying_df, receiving_df, accounts)
edge_attr, edge_index = self.get_edge_df(accounts, df)
# 创建数据集
data = Data(x=node_attr,
edge_index=edge_index,
y=node_label,
edge_attr=edge_attr
)
data_list = [data]
if self.pre_filter is not None:
data_list = [d for d in data_list if self.pre_filter(d)]
# 过滤数据
if self.pre_transform is not None:
data_list = [self.pre_transform(d) for d in data_list]
# 数据预处理
data, slices = self.collate(data_list)
# 将数据列表转换为Batch对象
torch.save((data, slices), self.processed_paths[0])
# 保存处理后的数据
5.模型
这里分别采用GAT和传统的GCN模型。
GAT:这里采用两个GATConv层构建,后跟一个具有sigmoid输出的线性层,用于分类。
class GAT(torch.nn.Module):
def __init__(self, in_channels, hidden_channels, out_channels, heads):
super().__init__()
self.conv1 = GATConv(in_channels, hidden_channels, heads,
dropout=0.6)
self.conv2 = GATConv(hidden_channels * heads, int(hidden_channels / 4), heads=1, concat=False,
dropout=0.6)
self.lin = Linear(int(hidden_channels / 4),
out_channels)
self.sigmoid = nn.Sigmoid() # 用于分类
def forward(self, x, edge_index, edge_attr):
x = F.dropout(x, p=0.6, training=self.training)
x = F.elu(self.conv1(x, edge_index, edge_attr))
x = F.dropout(x, p=0.6, training=self.training)
x = F.elu(self.conv2(x, edge_index, edge_attr))
x = self.lin(x)
x = self.sigmoid(x) # 用于分类
return x
GCN:
import torch
from torch_geometric.nn import GCNConv, Linear
class GCN(torch.nn.Module):
def __init__(self, in_channels, hidden_channels, out_channels):
super().__init__()
self.conv1 = GCNConv(in_channels, hidden_channels) # 第一层GCN卷积层,输入特征维度为in_channels,输出特征维度为hidden_channels
self.conv2 = GCNConv(hidden_channels, int(hidden_channels / 4)) # 第二层GCN卷积层,输入特征维度为hidden_channels,输出特征维度为int(hidden_channels/4)
self.lin = Linear(int(hidden_channels / 4),
out_channels)
self.relu = nn.ReLU()
self.dropout = nn.Dropout(p=0.6)
def forward(self, x, edge_index):
x = self.dropout(x)
x = self.relu(self.conv1(x, edge_index))
x = self.dropout(x)
x = self.relu(self.conv2(x, edge_index))
x = self.lin(x)
x = F.sigmoid(x)
return x
6.模型训练
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
dataset = AMLtoGraph('./data') # 加载数据集
data = dataset[0] # 取出第一个数据
epoch = 10 # 定义训练轮数
# model = GAT(in_channels=data.num_features, hidden_channels=16, out_channels=1, heads=8)
model = GCN(in_channels=data.num_features, hidden_channels=16, out_channels=1)
model = model.to(device)
criterion = torch.nn.BCELoss()
optimizer = torch.optim.SGD(model.parameters(), lr=0.0001)
# 定义数据集划分方式,使用随机节点划分,将数据集划分为训练集、验证集和测试集
split = T.RandomNodeSplit(split='train_rest', num_val=0.1, num_test=0)
# 对数据集进行划分
data = split(data)
# 定义训练集加载器,使用NeighborLoader类,将数据集data加载进来,每个batch的大小为256,每个节点的邻居数为30
train_loader = loader = NeighborLoader(
data,
num_neighbors=[30] * 2,
batch_size=256,
input_nodes=data.train_mask,
)
# 定义测试集加载器,使用NeighborLoader类,将数据集data加载进来,每个batch的大小为256,每个节点的邻居数为30
test_loader = loader = NeighborLoader(
data,
num_neighbors=[30] * 2,
batch_size=256,
input_nodes=data.val_mask,
)
acc_list_test = []
for i in range(epoch):
total_loss = 0
model.train()
for data in train_loader:
optimizer.zero_grad()
data.to(device)
# pred = model(data.x, data.edge_index, data.edge_attr)
pred = model(data.x, data.edge_index)
ground_truth = data.y # 获取真实标签
loss = criterion(pred, ground_truth.unsqueeze(1)) # 计算损失
loss.backward()
optimizer.step()
total_loss += float(loss) # 累加损失
if epoch % 10 == 0: # 每10轮输出一次训练结果
print(f"Epoch: {i + 1}, Loss: {total_loss:.4f}")
model.eval()
acc = 0 # 定义准确率
total = 0 # 定义总数
for test_data in test_loader:
test_data.to(device)
# pred = model(test_data.x, test_data.edge_index, test_data.edge_attr)
pred = model(test_data.x, test_data.edge_index)
ground_truth = test_data.y # 获取真实标签
correct = (pred == ground_truth.unsqueeze(1)).sum().item()
total += len(ground_truth)
acc += correct
acc = acc / total # 计算准确率
print('accuracy:', acc)
acc_list_test.append(acc)
plt.plot(acc_list_test)
plt.xlabel('Epoch')
plt.ylabel('Accuracy On TestSet')
plt.show()
分别用GCN和GAT模型进行训练,得到如下结果:


7.总结
这个问题本质是基于GNN的分类问题,难点主要在如何将数据处理成适配于GNN的图数据。首先通过修改原始数据集构造新的数据集(处理方式是基于https://papers.ssrn.com/sol3/papers.cfm? abstract_id=4441971这篇论文),模型选择了GCN和GAT,都是基于两个Conv层构建,后跟一个具有sigmoid输出的线性层。原始数据集属于洗钱的的比例大概只有2%,最终的准确率可能不太正确。
1973

被折叠的 条评论
为什么被折叠?



