姓氏分类:基于MLP和CNN分别实现
然而,这种增长不会很大(尤其是与“用CNN对姓氏进行分类的例子”中的模型相比)。尽管我们使用了来自“带有多层感知器的姓氏分类”中的相同数据集,但在实现上有一个不同之处:数据集由one-hot向量矩阵组成,而不是一个收缩的one-hot向量。如果你想在GPU上运行,要确保在 GPU 计算中使用的所有张量和模型都在同一设备上,以避免设备不匹配错误。管理姓氏和国籍的词汇表,并提供将姓氏向量化为one-h
目录
前期准备
本人分类任务使用google colab平台
实验数据
姓氏数据集
https://drive.google.com/drive/folders/1e0KwP20_gnY5-1ZLHihJI0RWmATpwa1o?usp=sharing
MLP相关文件
https://drive.google.com/drive/folders/1EiDLuq6gvAAYyFFvRd6vfQkayLjGPRRf?usp=sharing
cnn相关文件
https://drive.google.com/drive/folders/1XOMpSJrpWiVq96YoRfQmVXFl1StvGnaj?usp=sharing
数据预处理
姓氏数据集收集了来自18个不同国家的10,000个姓氏,具有一些使其有趣的属性。第一个性质是它是相当不平衡的。第二个特点是,在国籍和姓氏正字法(拼写)之间有一种有效和直观的关系。有些拼写变体与原籍国联系非常紧密(比如“O ‘Neill”、“Antonopoulos”、“Nagasawa”或“Zhu”)。
所以要先对姓氏数据集进行数据清洗。
代码文件
https://drive.google.com/file/d/1U-MkM5vy-RSnTREaPGNUhpB8QHJM8JBF/view?usp=sharing
如果不想自己动手,有我清洗好的姓氏数据集
https://drive.google.com/file/d/1il5qglvdWgUjzjr32gRyHYrBirEJrFqg/view?usp=sharing
使用MLP实现姓氏分类
加载姓氏数据集
将姓氏和国籍向量化,并计算每个类别的权重,以便在深度学习模型中使用。
from torch.utils.data import Dataset
import pandas as pd
from collections import Counter
class SurnameDataset(Dataset):
def __init__(self, surname_df, vectorizer):
self._surname_df = surname_df # 保存数据集的 DataFrame
self._vectorizer = vectorizer # 保存用于数据向量化的 vectorizer
self.class_weights = self.calculate_class_weights() # 计算类别权重
@classmethod
def load_dataset_and_make_vectorizer(cls, surname_csv):
# 从CSV文件加载数据集并创建一个vectorizer
surname_df = pd.read_csv(surname_csv)
return cls(surname_df, SurnameVectorizer.from_dataframe(surname_df))
def get_vectorizer(self):
# 获取vectorizer
return self._vectorizer
def __len__(self):
# 返回数据集的长度
return len(self._surname_df)
def __getitem__(self, index):
# 获取数据集中指定索引的条目
row = self._surname_df.iloc[index]
surname_vector = self._vectorizer.vectorize(row.surname) # 将姓氏向量化
nationality_index = self._vectorizer.nationality_vocab.lookup_token(row.nationality) # 查找国籍的索引
return {'x_surname': surname_vector, 'y_nationality': nationality_index}
def calculate_class_weights(self):
# 计算每个类别的权重
class_counts = Counter(self._surname_df['nationality']) # 计算每个类别的数量
total_samples = sum(class_counts.values()) # 计算总样本数
# 计算每个类别的权重
class_weights = {cls: total_samples / count for cls, count in class_counts.items()}
return class_weights
向量化姓氏数据集
管理姓氏和国籍的词汇表,并提供将姓氏向量化为one-hot编码的功能。
class SurnameVectorizer(object):
"""管理词汇表并将其应用到使用中的向量化器"""
def __init__(self, surname_vocab, nationality_vocab):
# 初始化姓氏词汇表和国籍词汇表
self.surname_vocab = surname_vocab
self.nationality_vocab = nationality_vocab
def vectorize(self, surname):
"""对提供的姓氏进行向量化处理
Args:
surname (str): 姓氏
Returns:
one_hot (np.ndarray): 折叠的单热编码
"""
vocab = self.surname_vocab
# 创建一个与词汇表大小相同的零向量
one_hot = np.zeros(len(vocab), dtype=np.float32)
# 将姓氏中的每个字符转换为对应的索引,并在one-hot向量中标记
for token in surname:
one_hot[vocab.lookup_token(token)] = 1
return one_hot
@classmethod
def from_dataframe(cls, surname_df):
"""从数据框实例化向量化器
Args:
surname_df (pandas.DataFrame): 姓氏数据集
Returns:
SurnameVectorizer 的一个实例
"""
# 初始化词汇表,设置未知词标记
surname_vocab = Vocabulary(unk_token="@")
nationality_vocab = Vocabulary(add_unk=False)
# 遍历数据框中的每一行,添加姓氏中的字符和国籍到对应的词汇表中
for index, row in surname_df.iterrows():
for letter in row.surname:
surname_vocab.add_token(letter)
nationality_vocab.add_token(row.nationality)
# 返回一个新的 SurnameVectorizer 实例
return cls(surname_vocab, nationality_vocab)
管理词汇表
管理和操作词汇表,提供 token 到索引的映射功能,并支持处理未知词标记。
class Vocabulary:
def __init__(self, unk_token=None, add_unk=True):
"""
初始化词汇表
参数:
unk_token (str, 可选): 用于表示未知词的标记,如果为 None,则不添加未知词标记
add_unk (bool): 是否添加未知词标记,默认为 True
"""
self._token_to_idx = {} # 用于存储从 token 到索引的映射
self._idx_to_token = [] # 用于存储索引到 token 的映射
self.unk_token = unk_token # 未知词标记
if add_unk:
self.add_token(unk_token) # 如果选择添加未知词标记,则调用 add_token 方法
def add_token(self, token):
"""
添加一个新的 token 到词汇表
参数:
token (str): 要添加的 token
"""
if token not in self._token_to_idx:
self._idx_to_token.append(token) # 将 token 添加到索引列表
self._token_to_idx[token] = len(self._idx_to_token) - 1 # 更新 token 到索引的映射
def lookup_token(self, token):
"""
根据 token 查找对应的索引。如果 token 不在词汇表中,则返回未知词标记的索引
参数:
token (str): 要查找的 token
返回:
int: 对应的索引,如果 token 不在词汇表中,则返回未知词标记的索引
"""
if self.unk_token is not None and token not in self._token_to_idx:
return self._token_to_idx[self.unk_token] # 如果 token 不在词汇表中,返回未知词标记的索引
return self._token_to_idx[token] # 返回 token 对应的索引
def __len__(self):
"""
获取词汇表中 token 的数量
返回:
int: 词汇表中 token 的数量
"""
return len(self._idx_to_token) # 返回索引列表的长度,即词汇表的大小
创建感知器模型
创建一个两层多层感知器模型,用于对姓氏进行分类。
import torch.nn as nn
import torch.nn.functional as F
class SurnameClassifier(nn.Module):
""" 用于姓氏分类的两层多层感知器 """
def __init__(self, input_dim, hidden_dim, output_dim):
"""
初始化分类器参数
参数:
input_dim (int): 输入向量的大小
hidden_dim (int): 第一个线性层的输出大小
output_dim (int): 第二个线性层的输出大小
"""
super(SurnameClassifier, self).__init__() # 调用父类的构造函数
self.fc1 = nn.Linear(input_dim, hidden_dim) # 定义第一个全连接层
self.fc2 = nn.Linear(hidden_dim, output_dim) # 定义第二个全连接层
def forward(self, x_in, apply_softmax=False):
"""
分类器的前向传递
参数:
x_in (torch.Tensor): 输入数据张量。x_in 的形状应为 (batch, input_dim)
apply_softmax (bool): 是否应用 softmax 激活的标志。如果与交叉熵损失一起使用,应为 False
返回:
结果张量。张量的形状应为 (batch, output_dim)
"""
intermediate_vector = F.relu(self.fc1(x_in)) # 通过第一个全连接层并应用ReLU激活函数
prediction_vector = self.fc2(intermediate_vector) # 通过第二个全连接层
if apply_softmax:
prediction_vector = F.softmax(prediction_vector, dim=1) # 如果需要,应用softmax激活函数
return prediction_vector # 返回预测结果张量
设置训练配置参数
初始化数据集、创建模型,并设置训练所需的配置参数。
from argparse import Namespace
# 定义一个Namespace对象,包含训练所需的各种参数
args = Namespace(
# 数据和路径信息
surname_csv="/content/drive/MyDrive/NLPlab4/surnames/surnames_with_splits.csv", # 姓氏数据集的CSV文件路径
vectorizer_file="vectorizer.json", # 存储向量化器的文件路径
model_state_file="model.pth", # 存储模型状态的文件路径
save_dir="/content/drive/MyDrive/NLPlab4/model/surname_mlp", # 存储模型的目录路径
# 模型超参数
hidden_dim=300, # 隐藏层维度大小
# 训练超参数
seed=1337, # 随机种子值
num_epochs=100, # 训练的轮数
early_stopping_criteria=5, # 早停准则
learning_rate=0.001, # 学习率
batch_size=64, # 批次大小
device='cpu' # 默认设备为 CPU
)
# 从 CSV 文件加载数据集并创建向量化器
dataset = SurnameDataset.load_dataset_and_make_vectorizer(args.surname_csv)
# 获取向量化器
vectorizer = dataset.get_vectorizer()
# 初始化 SurnameClassifier 模型,指定输入维度、隐藏层维度和输出维度
classifier = SurnameClassifier(input_dim=len(vectorizer.surname_vocab),
hidden_dim=args.hidden_dim,
output_dim=len(vectorizer.nationality_vocab))
# 将模型移动到指定的设备(CPU 或 GPU)
classifier = classifier.to(args.device)
import torch
import torch.optim as optim
import torch.nn as nn
# 假设 class_weights_dict 是一个字典,包含每个类别的权重
class_weights_dict = dataset.class_weights
# 将字典中的类别权重值转换为列表
class_weights_list = list(class_weights_dict.values())
# 将列表转换为 PyTorch 的 Tensor 类型
class_weights_tensor = torch.tensor(class_weights_list, dtype=torch.float32)
# 创建 CrossEntropyLoss 对象,并将类别权重传入
loss_func = nn.CrossEntropyLoss(weight=class_weights_tensor)
# 使用 Adam 优化器,指定学习率
optimizer = optim.Adam(classifier.parameters(), lr=args.learning_rate)
如果你想在GPU上运行,要确保在 GPU 计算中使用的所有张量和模型都在同一设备上,以避免设备不匹配错误。
检查GPU是否可用
use_cuda = torch.cuda.is_available()
args.device = 'cuda' if use_cuda else 'cpu'
print(f"Using device: {args.device}")
将模型和数据移动到 GPU
# 1.将模型移动到GPU
classifier = classifier.to(args.device)
# 2.在训练和预测过程中,将输入数据张量移动到 GPU。
vectorized_surname = torch.tensor(vectorized_surname).to(args.device)
# 3.在训练循环中,将批处理数据(即 x_surname 和 y_nationality)移动到 GPU。
x_surname = batch_dict['x_surname'].to(args.device)
y_nationality = batch_dict['y_nationality'].to(args.device)
训练MLP
使用数据加载器迭代训练多层感知器模型,包括计算损失、反向传播和参数更新的步骤。
from torch.utils.data import Dataset, DataLoader
# 加载数据集并创建数据加载器
dataset = SurnameDataset.load_dataset_and_make_vectorizer(args.surname_csv) # 从CSV文件加载数据集并创建数据集实例
data_loader = DataLoader(dataset, batch_size=args.batch_size, shuffle=True) # 使用 DataLoader 创建数据加载器,设置批次大小并启用数据打乱
import numpy as np
# 初始化 running_loss
running_loss = 0.0 # 初始化累计损失,用于跟踪训练过程中的损失变化
for batch_index, batch_dict in enumerate(data_loader):
# 步骤 1. 清零梯度
optimizer.zero_grad() # 在每个训练批次开始时,清除优化器中所有参数的梯度
# 步骤 2. 计算输出
y_pred = classifier(batch_dict['x_surname']) # 将批次数据传入模型,计算预测输出
# 步骤 3. 计算损失
loss = loss_func(y_pred, batch_dict['y_nationality']) # 计算模型输出与真实标签之间的损失
loss_batch = loss.to("cpu").item() # 将损失从GPU转移到CPU,并获取其标量值
running_loss += (loss_batch - running_loss) / (batch_index + 1) # 更新累计损失,使用滑动平均方法
# 步骤 4. 使用损失来生成梯度
loss.backward() # 通过损失函数的反向传播计算梯度
# 步骤 5. 使用优化器进行梯度更新
optimizer.step() # 使用优化器更新模型参数
# 打印当前的运行损失
if batch_index % 100 == 0: # 每100个批次打印一次损失
print(f"Batch {batch_index}, Loss: {running_loss}") # 输出当前批次的损失值
训练的损失值
测试
from torch.utils.data import DataLoader
import torch
# 创建测试数据集和数据加载器
test_dataset = SurnameDataset.load_dataset_and_make_vectorizer(args.surname_csv)
test_loader = DataLoader(test_dataset, batch_size=args.batch_size, shuffle=False)
# 将模型设置为评估模式
classifier.eval()
correct = 0
total = 0
with torch.no_grad(): # 禁用梯度计算,以加速评估
for batch_dict in test_loader:
x_surname = batch_dict['x_surname'].to(args.device)
y_nationality = batch_dict['y_nationality'].to(args.device)
# 计算模型预测
y_pred = classifier(x_surname)
_, predicted = torch.max(y_pred, 1)
# 统计正确预测的数量
total += y_nationality.size(0)
correct += (predicted == y_nationality).sum().item()
accuracy = correct / total
print(f"Test Accuracy: {accuracy * 100:.2f}%")
测试集的准确率和损失值
该模型对测试数据的准确性达到45%左右。如果遵循代码,你可以尝试隐藏维度的不同大小,应该注意到性能的提高。然而,这种增长不会很大(尤其是与“用CNN对姓氏进行分类的例子”中的模型相比)。其主要原因是收缩的one-hot向量化方法是一种弱表示。虽然它确实简洁地将每个姓氏表示为单个向量,但它丢弃了字符之间的顺序信息,这对于识别起源非常重要。
使用CNN实现姓氏分类
尽管我们使用了来自“带有多层感知器的姓氏分类”中的相同数据集,但在实现上有一个不同之处:数据集由one-hot向量矩阵组成,而不是一个收缩的one-hot向量。
加载姓氏数据集
__getitem__
方法从数据集中提取样本,将姓氏转换为矩阵表示,并将国籍转换为索引。
from torch.utils.data import Dataset
import pandas as pd
from collections import Counter
class SurnameDataset(Dataset):
def __init__(self, surname_df, vectorizer):
self._surname_df = surname_df # 保存数据集的 DataFrame
self._vectorizer = vectorizer # 保存用于数据向量化的 vectorizer
self.class_weights = self.calculate_class_weights() # 计算类别权重
@classmethod
def load_dataset_and_make_vectorizer(cls, surname_csv):
# 从CSV文件加载数据集并创建一个vectorizer
surname_df = pd.read_csv(surname_csv)
return cls(surname_df, SurnameVectorizer.from_dataframe(surname_df))
def get_vectorizer(self):
# 获取vectorizer
return self._vectorizer
def __len__(self):
# 返回数据集的长度
return len(self._surname_df)
def __getitem__(self, index):
# 从数据框中根据索引获取一行数据
row = self._target_df.iloc[index]
# 使用 vectorizer 将姓氏转换为矩阵形式,考虑到最大序列长度
surname_matrix = self._vectorizer.vectorize(row.surname, self._max_seq_length)
# 使用 vectorizer 将国籍转换为索引
nationality_index = self._vectorizer.nationality_vocab.lookup_token(row.nationality)
# 返回一个字典,包含两个键值对:
# 'x_surname': 姓氏的矩阵表示
# 'y_nationality': 国籍的索引
return {'x_surname': surname_matrix,
'y_nationality': nationality_index}
def calculate_class_weights(self):
# 计算每个类别的权重
class_counts = Counter(self._surname_df['nationality']) # 计算每个类别的数量
total_samples = sum(class_counts.values()) # 计算总样本数
# 计算每个类别的权重
class_weights = {cls: total_samples / count for cls, count in class_counts.items()}
return class_weights
向量化姓氏数据集
class SurnameVectorizer(object):
"""协调词汇表并将其应用于向量化处理的向量化器"""
def __init__(self, character_vocab, nationality_vocab, max_surname_length):
"""
初始化 SurnameVectorizer 实例
Args:
character_vocab (Vocabulary): 处理姓氏的字符词汇表
nationality_vocab (Vocabulary): 处理国籍的词汇表
max_surname_length (int): 数据集中最长姓氏的长度
"""
self.character_vocab = character_vocab
self.nationality_vocab = nationality_vocab
self.max_surname_length = max_surname_length
def vectorize(self, surname):
"""
将姓氏向量化为一个one-hot矩阵
Args:
surname (str): 要向量化的姓氏
Returns:
one_hot_matrix (np.ndarray): 一个one-hot编码矩阵
"""
# 创建一个one-hot矩阵,其大小为 (字符词汇表大小, 最大姓氏长度)
one_hot_matrix_size = (len(self.character_vocab), self.max_surname_length)
one_hot_matrix = np.zeros(one_hot_matrix_size, dtype=np.float32)
# 遍历姓氏中的每个字符,填充one-hot矩阵
for position_index, character in enumerate(surname):
character_index = self.character_vocab.lookup_token(character)
one_hot_matrix[character_index][position_index] = 1
return one_hot_matrix
@classmethod
def from_dataframe(cls, surname_df):
"""从数据框实例化向量化器
Args:
surname_df (pandas.DataFrame): 姓氏数据集
Returns:
SurnameVectorizer: 一个 SurnameVectorizer 实例
"""
# 初始化字符词汇表和国籍词汇表,并设置最大姓氏长度
character_vocab = Vocabulary(unk_token="@")
nationality_vocab = Vocabulary(add_unk=False)
max_surname_length = 0
# 遍历数据框中的每一行,更新词汇表和最大姓氏长度
for index, row in surname_df.iterrows():
max_surname_length = max(max_surname_length, len(row.surname))
for letter in row.surname:
character_vocab.add_token(letter)
nationality_vocab.add_token(row.nationality)
return cls(character_vocab, nationality_vocab, max_surname_length)
创建CNN分类器
import torch.nn as nn
import torch.nn.functional as F
class SurnameClassifier(nn.Module):
def __init__(self, initial_num_channels, num_classes, num_channels):
"""
初始化 SurnameClassifier 类
Args:
initial_num_channels (int): 输入特征向量的大小
num_classes (int): 输出预测向量的类别数
num_channels (int): 网络中使用的常量通道大小
"""
super(SurnameClassifier, self).__init__()
# 定义卷积神经网络的结构
self.convnet = nn.Sequential(
nn.Conv1d(in_channels=initial_num_channels,
out_channels=num_channels, kernel_size=3),
nn.ELU(),
nn.Conv1d(in_channels=num_channels, out_channels=num_channels,
kernel_size=3, stride=2),
nn.ELU(),
nn.Conv1d(in_channels=num_channels, out_channels=num_channels,
kernel_size=3, stride=2),
nn.ELU(),
nn.Conv1d(in_channels=num_channels, out_channels=num_channels,
kernel_size=3),
nn.ELU()
)
# 定义全连接层,将卷积层的输出映射到类别空间
self.fc = nn.Linear(num_channels, num_classes)
def forward(self, x_surname, apply_softmax=False):
"""
分类器的前向传播过程
Args:
x_surname (torch.Tensor): 输入数据张量。其形状应为 (batch, initial_num_channels, max_surname_length)
apply_softmax (bool): 是否应用 softmax 激活函数。如果与交叉熵损失函数一起使用,应设置为 False
Returns:
torch.Tensor: 预测结果张量,其形状应为 (batch, num_classes)
"""
# 通过卷积网络提取特征
features = self.convnet(x_surname).squeeze(dim=2)
# 通过全连接层生成预测向量
prediction_vector = self.fc(features)
# 如果 apply_softmax 为 True,则应用 softmax 激活函数
if apply_softmax:
prediction_vector = F.softmax(prediction_vector, dim=1)
return prediction_vector
args = Namespace(
surname_csv="/content/drive/MyDrive/NLPlab4/surnames/surnames_with_splits.csv",
vectorizer_file="vectorizer.json",
model_state_file="model.pth",
save_dir="/content/drive/MyDrive/NLPlab4/model/cnn",
# 模型超参数
hidden_dim=100, # 隐藏层维度,通常用于全连接层(在这个例子中不直接用到,但可以用来定义其他层)
num_channels=256, # 卷积层的通道数
# 训练超参数
seed=1337, # 随机种子,用于确保实验的可重复性
learning_rate=0.001, # 学习率,优化器更新权重的步长
batch_size=128, # 批处理大小,训练中每个批次的数据量
num_epochs=100, # 训练的轮次
early_stopping_criteria=5, # 提前停止的标准(在验证集上若连续 5 个轮次没有提升,则停止训练)
dropout_p=0.1, # dropout 概率,防止过拟合的正则化技术
)
训练
from torch.utils.data import Dataset, DataLoader
# 加载数据集并创建数据加载器
dataset = SurnameDataset.load_dataset_and_make_vectorizer(args.surname_csv) # 从CSV文件加载数据集并创建数据集实例
data_loader = DataLoader(dataset, batch_size=args.batch_size, shuffle=True) # 使用 DataLoader 创建数据加载器,设置批次大小并启用数据打乱
import numpy as np
# 初始化 running_loss
running_loss = 0.0 # 初始化累计损失,用于跟踪训练过程中的损失变化
for batch_index, batch_dict in enumerate(data_loader):
# 步骤 1. 清零梯度
optimizer.zero_grad() # 在每个训练批次开始时,清除优化器中所有参数的梯度
# 步骤 2. 计算输出
y_pred = classifier(batch_dict['x_surname']) # 将批次数据传入模型,计算预测输出
# 步骤 3. 计算损失
loss = loss_func(y_pred, batch_dict['y_nationality']) # 计算模型输出与真实标签之间的损失
loss_batch = loss.to("cpu").item() # 将损失从GPU转移到CPU,并获取其标量值
running_loss += (loss_batch - running_loss) / (batch_index + 1) # 更新累计损失,使用滑动平均方法
# 步骤 4. 使用损失来生成梯度
loss.backward() # 通过损失函数的反向传播计算梯度
# 步骤 5. 使用优化器进行梯度更新
optimizer.step() # 使用优化器更新模型参数
# 打印当前的运行损失
if batch_index % 100 == 0: # 每100个批次打印一次损失
print(f"Batch {batch_index}, Loss: {running_loss}") # 输出当前批次的损失值
测试
from torch.utils.data import DataLoader
import torch
# 创建测试数据集和数据加载器
test_dataset = SurnameDataset.load_dataset_and_make_vectorizer(args.surname_csv)
test_loader = DataLoader(test_dataset, batch_size=args.batch_size, shuffle=False)
# 将模型设置为评估模式
classifier.eval()
correct = 0
total = 0
with torch.no_grad(): # 禁用梯度计算,以加速评估
for batch_dict in test_loader:
x_surname = batch_dict['x_surname'].to(args.device)
y_nationality = batch_dict['y_nationality'].to(args.device)
# 计算模型预测
y_pred = classifier(x_surname)
_, predicted = torch.max(y_pred, 1)
# 统计正确预测的数量
total += y_nationality.size(0)
correct += (predicted == y_nationality).sum().item()
accuracy = correct / total
print(f"Test Accuracy: {accuracy * 100:.2f}%")
预测
def predict_nationality(surname, classifier, vectorizer):
"""从新的姓氏预测国籍
Args:
surname (str): 要分类的姓氏
classifier (SurnameClassifier): 分类器的实例,用于预测姓氏的国籍
vectorizer (SurnameVectorizer): 用于将姓氏向量化的向量化器实例
Returns:
dict: 包含最可能的国籍及其概率的字典
- 'nationality': 最可能的国籍
- 'probability': 预测该国籍的概率
"""
# 使用向量化器将姓氏转换为向量
vectorized_surname = vectorizer.vectorize(surname)
# 将向量转换为 PyTorch 张量,并添加一个批次维度
vectorized_surname = torch.tensor(vectorized_surname).unsqueeze(0)
# 使用分类器进行预测,apply_softmax=True 表示输出概率
result = classifier(vectorized_surname, apply_softmax=True)
# 从预测结果中找到概率最高的类别
probability_values, indices = result.max(dim=1)
index = indices.item()
# 根据索引查找对应的国籍
predicted_nationality = vectorizer.nationality_vocab.lookup_index(index)
# 提取概率值
probability_value = probability_values.item()
# 返回预测结果的字典
return {'nationality': predicted_nationality, 'probability': probability_value}
over
更多推荐
所有评论(0)