合成表格数据的两种生成方法
本文探讨了生成合成表格数据的方法,使得合成数据与真实数据中的分布相匹配。也就是说,给定一个数据集,我们研究如何生成任意数量的额外行,这些行可以看起来像原始(真实)数据一样现实。
同时,也可以调整合成数据的典型性(或相反:新颖性或不寻常性),我们将探讨如何控制这一点。
这篇文章基于我书《Python中的异常检测》(Outlier Detection in Python)的摘录。为了简洁起见,本文将只探讨两种生成现实数据的方法,一种是基于预测模型,另一种是基于高斯混合模型。生成合成数据是异常检测的重要部分,因此这本书还涵盖了这两种方法以及其它几种方法,包括模拟、基于核密度估计(KDE)的方法,以及使用深度神经网络的方法。你可能希望查看的其他方法包括贝叶斯网络,以及使用Python库如Synthetic Data Vault或faker等工具。
在数据科学的其他领域,除了异常检测之外,合成数据也非常有用——不一定像在异常检测中那样关键,但仍然非常有用。例如,合成数据常用于匿名化敏感数据(生成统计上相似的数据,但其中没有可识别的真实世界实体)或生成数据来测试其他软件系统。但是,无论你专注于哪种类型的数据科学工作,通常都会很有用地处理合成数据,因此值得了解各种生成这种数据的方法。有些方法可能比其他方法更适用于你的工作类型。
我们的目标是创建具有与你的真实数据相同分布和列之间关系的合成数据。本文不会假设合成数据的特定用途;它只是概述了生成紧密模仿你真实数据的合成表格数据的方法。未来的文章可能会涵盖合成数据的一些应用、生成数据的其他方法以及评估合成数据质量的方法(尽管在本文末尾会快速介绍评估合成数据创建的方法)。
1、生成数据的简单方法
一种简单的生成合成数据的方法是按照真实数据集中的模式生成合成行,逐行生成,每行中逐个单元格生成——每个单元格独立于其他单元格生成。我们很快会看到这种方法有点过于简单,但它是一个很好的起点;在某些情况下可能足够,并且是其他方法的基础。
我们可能有一个真实的表格数据,如下所示:
此数据表示单个公司某个日期范围内的员工费用。它有七个列(虽然更现实的数据会有许多列)。
假设我们有10,000行,并且希望创建10,000个类似的行。我们可以从确定每一列的分布开始。
员工ID列有一组特定的值,每个值出现在一些行中。例如,员工ID 9000483可能出现在1.2%的行中,93003332出现在0.8%的行中,依此类推。同样,部门、账户、费用日期和提交日期列中的值也是如此。提交时间列我们可以将其视为数值特征(例如,考虑为自午夜以来的秒数)。金额特征是数值型的。
为了表示数值列的分布,我们可以采用多种方法。一种方法是假设它遵循某种分布并拟合参数以建模此分布。例如,如果我们假设该列是正态分布的,我们可以用均值和标准差来描述它;如果是泊松分布,则用lambda参数;对于其他分布也是如此,包括对于美元金额列更现实的对数正态分布。
我们还可以使用像distfit这样的工具(如果使用Python)来尝试许多潜在的分布,确定最佳的分布,并然后拟合必要的参数来建模这个分布。
另一种方法,我们在这里使用的是用直方图来建模数据分布。这也很简单且稳健:它允许我们捕捉难以建模的其他数据分布,例如多峰分布。假设金额列的分布如下图所示,使用15个bin:
因此,为了建模数据,我们可以:
- 通过枚举列中所有唯一值的集合并确定每个值的相对频率来建模每个分类列;和
- 用直方图建模每个数值列。
然后我们可以逐行生成数据。对于每个合成行,我们可以首先选择一个员工ID的值,根据真实数据中的频率进行选择。因此,在真实数据中出现频率较高的员工ID在合成数据中也会出现得更频繁;而在真实数据中较少见的员工ID在合成数据中也会较少见。
我们还可以选择性地允许新值出现在合成数据中。在这个例子中,这可能允许合成数据中出现不在真实数据中的员工ID(以及部门、账户等)。对于这个练习,我们假设不允许这样做,但这通常是很有用的(不过需要额外的努力来生成现实的新值)。
接下来,我们可以为每个其他列选择值。其他分类列可以以同样的方式填充——从真实数据中按其在真实数据中的频率选择一个值。对于数值列,有多种方法可以根据分布选择值,但在本例中我们使用直方图来表示分布,我们可以根据每个bin的概率选择一个bin,然后在该bin内选择一个随机值。
鉴于上述直方图,我们首先选择15个bin中的一个。我们更有可能选择第6或第7个,而不是其他(这些代表更常见的值,由其他bin的更高计数显示),而最不可能选择第14个,但我们可以选择任何15个。
每个bin代表一定的值范围。在这里,每个bin都有一个最小值和最大值,每个跨度大约1.30美元。我们可以在该范围内均匀选择。在下面的代码中,我们展示了一种不同的方法,即确定每个bin的中心,并选择该中心,加上一些随机抖动。
以下代码将生成一个与给定真实数据集相匹配的合成数据集。在这个例子中,我们使用了一个比上面提到的员工费用示例更现实的数据集,尽管仍然非常干净。我们使用OpenML上的Baseball数据集(具有公共许可证)。这包含了关于棒球运动员的数据,每行代表一个球员。有16列,提供了每个球员的统计数据,包括赛季数、击球次数、二垒打数、三垒打数、本垒打数、击球平均数、RBIs(击球得分)等。
import pandas as pd
import numpy as np
from sklearn.datasets import fetch_openml
# 收集数据
data = fetch_openml('baseball', version=1, parser='auto')
df = pd.DataFrame(data.data, columns=data.feature_names)
# 填充空值,仅在这一列中存在
df['Strikeouts'] = df['Strikeouts'].fillna(df['Strikeouts'].median())
# 将特征分为分类和数字,假设任何具有10个或更少唯一值的特征都可以被视为分类
cat_features = [x for x in df.columns if df[x].nunique() <= 10]
num_features = [x for x in df.columns if x not in cat_features]
# 生成数字特征
synth_data = []
for num_feat in num_features:
# 创建一个直方图来表示真实数据中值的分布
hist = np.histogram(df[num_feat], density=True)
bin_centers = [(x+y)/2 for x, y in zip(hist[1][:-1], hist[1][1:])]
p = [x/sum(hist[0]) for x in hist[0]]
vals = np.random.choice(bin_centers, p=p, size=len(df)).astype(int)
vals = [x + (((1.0 * np.random.random()) - 0.5) * df[num_feat].std())
for x in vals]
# 生成分类特征
synth_data.append(vals)
for cat_feat in cat_features:
vc = df[cat_feat].value_counts(normalize=True)
# 选择一个随机的值集,比例于该列中每个值在真实数据中的频率
vals = np.random.choice(list(vc.index), p=list(vc.values), size=len(df))
synth_data.append(vals)
# 将合成数据数组转换为pandas dataframe。
synth_df = pd.DataFrame(synth_data).T
synth_df.columns = num_features + cat_features
这将倾向于生成每个单独值都是现实的数据。这在我们限制值时尤其如此,正如我们在代码中所做的:例如,对于分类特征,我们将合成数据限制为在真实数据中看到的值;对于数值特征,我们将合成数据限制为在真实数据中看到的最小和最大值之间(尽管添加的随机抖动可能会使一些合成值略微超出此范围)。
一旦执行,这可能会生成在各种方面异常的数据。我们将考虑员工费用数据和棒球数据来检查这一点。从员工费用数据开始,我们可能会生成一行如下:
- 员工ID:…………..….900043
- 部门**:**……..…市场
- 账户: …………….重型设备
- 费用日期: ….2025年1月10日
- 提交日期: ……2025年1月5日
- 提交时间:……晚上11:09:00
- 金额: ………………14.44美元
每个单独的值都是合理的,但整个行在很多方面是不现实的。员工被转移到市场部,但其他记录(更重要的是,同一日期周围的其他记录)显示该员工在销售部。
重型设备可能偶尔出现在真实数据中,但可能永远不会出现在市场部,而且价格约为14美元。提交日期早于费用日期也是不太寻常的。在晚上11点左右提交这类费用也可能是奇怪的(尽管可能对于轮班工人来说会出现,他们通常在此时输入他们的费用)。
由于有更多的特征,这稍微难以想象,但在棒球数据集中我们也看到了同样的情况。例如,我们可能会有双打数很高的球员,但三打数却很低,尽管这些在真实数据中往往是相关的。或者,我们可能会有在一个赛季中打了很长时间的球员,本垒打数很高,但击球次数却很少。
为了突出这一点,我们可以单独绘制出特征和成对的特征,如下面的图表所示。这有三个面板。第一个显示了At_bats的分布,包括真实数据和合成数据(用蓝色和红色表示)。分布大致相同。这是其他特征的情况也是如此。但是,接下来的两个面板显示了一对特征,我们可以看到成对关系的崩溃。这里没有显示,但任何三个或更多特征之间的关系也会崩溃。在中间的面板中,我们看到真实数据中At_bats和RBIs(击球得分)的关系。有明显的相关性。在第三个(最右边)的面板中,我们看到合成数据中的这种情况;相关性消失了,有一些非常不寻常的值组合。
如果我们的目标是创建异常数据,这可能很有用,例如用来测试异常检测系统。但如果我们目标是创建现实数据,这并不有用。
2、合成数据创建的目标
不过,我们应该指出,我们的目标不仅仅是创建现实的数据,还要创建多样化且现实的数据。有可能生成的数据中每一行在各方面都完全合理,但所有合成行几乎彼此相同。这可能发生,例如使用生成对抗网络(GANs)时发生所谓的“模式崩溃”,并且倾向于重复生成相同的输出。
也有可能创建现实的数据,但每个合成行与一个或多个真实数据行非常相似。我们要小心不要仅仅记住数据,或者过度限制生成的数据。生成好的合成数据意味着平衡这些关注点——创建现实的数据,但覆盖尽可能广泛的可能性。从某种意义上说,我们试图从完整的可行记录集中均匀采样。更准确地说:完整的一组可行记录,这些记录尚未在真实数据中出现。
3、左到右生成数据
当我们像上面那样生成每个值时(仅仅根据其在真实数据中的出现频率),主要问题在于值的组合可能不现实,有时甚至毫无意义。这是因为大多数表格数据中,特征之间存在某种关系(无论是数值、日期还是分类)。在现实世界中,表格数据的特征很少是完全独立的。
在员工费用表中,每个员工往往只在一个部门,或者可能随着时间的推移在少数几个部门。每个部门往往会有一种类型的费用和一定的费用金额。费用日期和费用提交日期之间往往会有一个一定的滞后。
在棒球数据中,参加赛季更多的球员会有更多的击球次数;击球次数越多的球员往往会有更多的击球得分。他们还会拥有更多的安打、本垒打等。得到二垒打与得到三垒打有关联。数据中会有许多这样的关系。
要创建现实的数据,必须捕捉这些关系。
一种方法是逐个单元格生成数据,每次选择一个值,该值在考虑到该行中其他值的情况下是现实的。也就是说,不要独立地生成各个单元格,而是生成值,使其在考虑到已经指定的该行的值的情况下是现实的。
值可以按任何顺序生成。有些顺序会比其他顺序更好,为了生成更具多样性的合成数据,最好混合列的顺序,但对于这一点,我们假设我们是左到右生成数据。
在员工费用的例子中,这意味着首先生成一个随机的员工ID值(再次根据真实数据中的分布)。然后生成部门。这是随机的,但根据真实数据中员工ID的选择概率。然后生成账户——再次根据已选员工ID和部门选择一个合理可能的值。依此类推,每个值都是根据其左侧的值生成的。最右边的列最后填充,此时选择一个金额值,该值在考虑到该行中其他列的值的情况下是现实的。在这种方法下,生成的值将倾向于高度现实。
在下面的列表中,我们展示了一种以这种方式生成数据的方法,使用随机森林来基于已生成的特征预测每个特征值。为了效率,这会一次性生成合成数据中每个特征的所有值,因此循环遍历特征从左到右,每次迭代创建该列的完整值集(根据我们希望生成的合成行数)。
这个过程与之前相同,收集数据并确定数值和分类列。在这个例子中,我们再次使用棒球数据集:
import pandas as pd
import numpy as np
from sklearn.datasets import fetch_openml
data = fetch_openml('baseball', version=1, parser='auto')
df = pd.DataFrame(data.data, columns=data.feature_names)
df['Strikeouts'] = df['Strikeouts'].fillna(df['Strikeouts'].median())
cat_features = [x for x in df.columns if df[x].nunique() <=10]
num_features = [x for x in df.columns if x not in cat_features]
然后更新其余代码以从左到右生成值:
from sklearn.ensemble import RandomForestRegressor, RandomForestClassifier
import matplotlib.pyplot as plt
import seaborn as sns
synth_data = []
# 设置最左边的列仅基于其分布
feature_0 = df.columns[0]
# 创建第一个特征的直方图
hist = np.histogram(df[feature_0], density=True)
bin_centers = [(x+y)/2 for x, y in zip(hist[1][:-1], hist[1][1:])]
p = [x/sum(hist[0]) for x in hist[0]]
# 生成与真实行数相同的合成行。我们首先创建第一列的完整合成值集。
vals = np.random.choice(bin_centers, p=p, size=len(df)).astype(int)
vals = [x + (((1.0 * np.random.random()) - 0.5) * df[feature_0].std())
for x in vals]
synth_data.append(vals)
synth_cols = [feature_0]
# 循环遍历最左边的特征之后的特征
for col_name in df.columns[1:]:
print(col_name)
synth_df = pd.DataFrame(synth_data).T
synth_df.columns = synth_cols
if col_name in num_features:
# 在真实数据上训练随机森林
regr = RandomForestRegressor()
regr.fit(df[synth_cols], df[col_name])
# 根据到目前为止生成的其他合成数据进行预测
pred = regr.predict(synth_df[synth_cols])
# 添加抖动,这样就不会只有少量唯一的值
vals = [x + (((1.0 * np.random.random()) - 0.5) * pred.std())
for x in pred]
synth_data.append(vals)
# 如果下一个列是分类的,以与数值数据类似的方式填充它。
if col_name in cat_features:
clf = RandomForestClassifier()
clf.fit(df[synth_cols], df[col_name])
synth_data.append(clf.predict(synth_df[synth_cols]))
# 跟踪我们已经生成的列
synth_cols.append(col_name)
# 将数据数组转换为pandas dataframe
synth_df = pd.DataFrame(synth_data).T
synth_df.columns = synth_cols
# 显示At_bats和RBIS的分布,包括原始数据和合成数据
sns.scatterplot(data=df, x="At_bats", y="RBIs", color='blue', alpha=0.1)
sns.scatterplot(data=synth_df, x="At_bats", y="RBIs",
color='red', marker="*", s=200)
plt.show()
这首先仅基于真实数据中第一个特征的分布创建第一个特征,与我们之前的代码相同。然而,每个后续特征是基于已经创建的特征生成的。也就是说,所有其他特征都是基于预测其他值生成的。在这里,我们使用随机森林来预测这个。随机森林是强大的模型,通常能够很好地捕捉数据中的模式。使用其他模型或调整随机森林的参数,可以调整数据的真实性,并增加生成的数据集的多样性。
下图显示了At_bats和RBIs之间的联合分布得到了良好保持。这是所有特征组合的情况,即使跨越多个维度也是如此。
这段代码提供了一般的想法,但实际上有些过于简化;在生产环境中需要进行一些额外的修改。首先,它假设最左边的列是数值型的,这在棒球数据集中是正确的;但最左边的列也可能是分类的,因此需要添加类似的代码来处理这种情况(再次使用与前面代码相同的代码来选择与该列在真实数据中的值成比例的分类值)。也就是说,我们会添加代码如下:
vals = np.random.choice(list(vc.index), p=list(vc.values), size=len(df))
如果第一列是分类的,则执行此操作。与之前的代码一样,变量vc是基于调用value_counts()来获取该列在真实数据中每个唯一值的计数设置的。
其次,它之所以有效,是因为棒球数据集中只有一个分类列,即Positions列;并且因为这是最右边的特征,可以基于生成的数值列进行预测。
在有多个分类列的情况下,或者在分类列的右侧还有其他列的情况下,完整的代码稍微复杂一点,但只是稍微复杂一点。我们需要能够使用已经创建的分类列作为特征来预测后续列。如果使用随机森林分类器(至少在scikit-learn的实现中,X列中的所有特征都必须是数值型的),这意味着对其他分类值进行编码,例如使用独热编码或目标编码。此外,而不是使用predict(),使用predict_proba()会更好,这将为当前特征提供概率分布。
例如,考虑我们有两个分类特征:Position特征(我们现在有的),以及Team特征。假设棒球数据集包含20支球队,因此该特征有20个潜在值。假设Team列是最后一列,就在Position的右边。这意味着,当生成Team列(如果从左到右生成),我们将基于所有其他列生成的合成值来生成Team特征。
为此,我们创建一个随机森林分类器,可以基于所有其他列(包括Position特征的编码版本)预测Team。因此,一旦对Position特征进行编码(例如,使用独热编码),除Team以外的所有特征(目标列)都将为数值型,可以作为随机森林分类器的特征。然后,我们可以训练一个随机森林分类器(与其他列一样,这在真实数据上进行训练,并基于到目前为止生成的合成数据进行预测)。
使用这个随机森林分类器,我们使用predict_proba()而不是Predict()。这将为每一行生成一组20个概率,看起来像:
[0.05540283, 0.04307444, 0.051385277, 0.06090132, 0.143254817, 0.050655052,
0.04222497, 0.048893178, 0.036884325, 0.012775138, 0.038525177, 0.0385151,
0.0085251377, 0.00239232, 0.092022022, 0.08191191, 0.02020221, 0.0737373122,
0.07438382, 0.024333644]
也就是说,对于每一行,我们得到一组20个概率(总和为1.0),表示在其他列的值(如Number_Seasons、At_bats、Doubles、Triples、RBIs、Position等)的情况下,Team的每个可能值的概率。我们可以然后选择20个团队中的一个,概率与其成比例(因此最可能的团队会被经常选择,但不是总是如此,从而在生成的数据中允许一些多样性)。
4、调整数据的现实程度
上述代码可能会生成与原始(真实)数据匹配良好的数据,但可能缺乏多样性。它可能很好地遵循真实数据中的关系,但可能不会涵盖生产中可能的组合。有许多方法可以调整数据的现实性和约束程度。我们可以使用比随机森林更强或更弱的模型来生成预测,或者调整其超参数。例如,对于随机森林,我们可以调整树的最大深度。设置这个非常低会使随机森林比使用更深的决策树更粗糙,并且倾向于遵循目标列的整体分布,而不那么强烈地考虑其他特征。
我们还可以调整添加的抖动。当预测数值特征时,我们不想使用随机森林的精确预测输出,因为随机森林可能在预测的唯一值集上有限制——它们的预测受限于训练期间看到的内容,尽管在实践中这不一定那么限制。但潜在地,这可能导致重复预测少量的值。在上面的代码中,我们添加了一些噪声以增加多样性,这可以增加或减少。
当预测分类特征时,如前所述,我们会使用predict_proba()来获得每个行的每个可能类别的概率。然而,可以调整这些概率(类似于调整大型语言模型中的温度),以更偏好最可能的类别,或者使类别之间的概率更加相似(允许代码更频繁地选择不太可能的类别)。
另一种方法是基于所有已创建的特征来预测每个值,而不是只基于一些这些特征。使用的特征越多,并且它们与当前列的相关性越强,生成的数据就越现实。
在下面的代码中展示了调整所使用特征数量的一个示例。这将一些上面的代码放入一个参数化的函数中,允许我们控制使用多少先前的特征来预测当前特征的适当值。它还允许我们控制是否使用这些特征中最左边的或最右边的。例如,在员工费用表中,当生成金额(第七列)的值时,我们已经选择了该行中其他六列的值。为了预测金额的值,我们可以使用这六个中的全部,或者只是其中的三个。如果使用最左边的三个,我们将基于员工ID、部门和账户预测金额。如果使用最右边的三个,我们将基于费用日期、提交日期和提交时间预测金额(这些可能远不如前面的特征有预测力)。
多次调用此函数并使用不同的参数将允许我们创建多样化的数据集。对此的其他变化,以及以不同顺序生成特征(在调用之前对列顺序进行洗牌),也可以很好地工作,并增加生成的测试集的多样性。这假设包含真实数据的pandas dataframe(称为df)已经被创建。
from sklearn.ensemble import RandomForestRegressor, RandomForestClassifier
import matplotlib.pyplot as plt
import seaborn as sns
def generate_dataset(df, max_cols_used, use_left):
feature_0 = df.columns[0]
hist = np.histogram(df[feature_0], density=True)
bin_centers = [(x+y)/2 for x, y in zip(hist[1][:-1], hist[1][1:])]
p = [x/sum(hist[0]) for x in hist[0]]
vals = np.random.choice(bin_centers, p=p, size=len(df)).astype(int)
vals = [x + (((1.0 * np.random.random()) - 0.5) * df[feature_0].std())
for x in vals]
synth_data = []
synth_data.append(vals)
synth_cols = [feature_0]
for col_name in df.columns[1:]:
print(col_name, synth_cols, len(synth_data))
synth_df = pd.DataFrame(synth_data).T
synth_df.columns = synth_cols
# 使用指定的一组特征训练随机森林
if use_left:
use_synth_cols = synth_cols[:max_cols_used]
else:
use_synth_cols = synth_cols[-max_cols_used:]
# 剩余的代码与之前相同。
if col_name in num_features:
regr = RandomForestRegressor()
regr.fit(df[use_synth_cols], df[col_name])
pred = regr.predict(synth_df[use_synth_cols])
vals = [x + (((1.0 * np.random.random()) - 0.5) * pred.std())
for x in pred]
synth_data.append(vals)
if col_name in cat_features:
clf = RandomForestClassifier()
clf.fit(df[use_synth_cols], df[col_name])
synth_data.append(clf.predict(synth_df[use_synth_cols]))
synth_cols.append(col_name)
synth_df = pd.DataFrame(synth_data).T
synth_df.columns = synth_cols
return synth_df
synth_df = generate_dataset(df, max_cols_used=2, use_left=False)
执行此代码,并绘制At_bats和RBIs,我们得到如下结果:
我们可以看到,一般关系得到了保持,但不像基于所有先前特征生成每个特征时那么接近。
5、使用高斯混合模型
本文探讨的第二种方法基于高斯混合模型(GMM)。高斯混合模型(及其在异常检测中的应用)在Python中的异常检测一书中进行了更详细的描述,但作为一个简短的描述,GMM描述了数据的分布。它们假设数据处于一定数量的簇中,每个簇有一个中心和一个大小,并可以用协方差矩阵表示。协方差矩阵是一个描述数据形状的矩阵。它指定了每个特征的方差(它假设所有数据都是数值型的)和每对特征之间的协方差。
GMM假设每个簇内的数据在每个维度上大致呈高斯分布,但允许任意数量的簇,并且每个簇可以有任意大小。此外,它假设每个簇内的数据位于超椭圆中。也就是说:在每个簇中,每个特征可以有任意程度的方差,每对特征可能有任意程度的相关性,但假设这些足以合理描述每个簇中数据的形状。实际上,如果数据在一个或多个结构良好的簇中,这通常成立。事实上,如果簇不是结构良好的,通常可以将其细分为单独的簇,使得每个簇都能合理地形成。
一个使用三个簇的二维数据集的示例显示如下:
在这里,我用不同的颜色绘制了三个簇,绿色簇是最小的,黄色次之,蓝色最大。对于数据空间中的每个位置,我们可以确定数据点出现在该位置的可能性,这是该位置由蓝色簇、绿色簇和黄色簇产生的可能性的总和。
每个簇中心周围的同心椭圆环表示该距离处的数据点被创建的可能性(越靠近中心,可能性越高)。这些是椭圆而不是圆形,因为GMM通过使用的协方差矩阵考虑了数据的形状:鉴于某些特征是相关的,簇通常具有比圆形更椭圆的形状。
GMM假设数据围绕每个簇的中心呈高斯分布,因此靠近中心的区域比远离中心的区域更密集,这与我们看到的高斯分布相同。
靠近两个或多个簇中心的位置比靠近一个(或更少)簇中心的位置更可能包含数据,其他条件相同。例如,位于绿色簇左侧的位置比位于绿色簇右侧的位置更可能包含数据,因为位于绿色簇左侧的位置很可能由蓝色簇和绿色簇产生数据。另一方面,位于绿色簇右侧的位置相对较不可能由蓝色簇(或黄色簇)产生数据。
GMM通常用于聚类数据,以及用于异常检测。但它们也是生成模型,因此可用于创建非常类似于真实数据的数据。为此,GMM必须首先拟合到真实数据。这意味着确定簇的数量,以及每个簇:簇中心、簇大小(或更准确地说,簇权重——数据点相对于其他簇被创建的可能性),以及协方差矩阵。
GMM然后能够生成任意数量的新数据(在后台,一次生成一个新记录)。要生成一个新记录,调用sample()方法。对于每个新记录,GMM首先随机选择一个簇,比例于该簇的权重,然后为该簇生成一个点。这类似于从简单的1D高斯分布中抽样,但实际上是抽样来自多变量高斯分布。
以下代码包含一个示例,再次使用棒球数据集并生成这个数据集的一个非常干净的版本。
import pandas as pd
import numpy as np
from sklearn.datasets import fetch_openml
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.mixture import GaussianMixture
from sklearn.ensemble import IsolationForest
# 加载数据
data = fetch_openml('baseball', version=1, parser='auto')
df = pd.DataFrame(data.data, columns=data.feature_names)
df['Strikeouts'] = df['Strikeouts'].fillna(df['Strikeouts'].median())
# 清理强异常值
df = pd.get_dummies(df)
np.random.seed(0)
clf_if = IsolationForest()
clf_if.fit(df)
pred = clf_if.decision_function(df)
trimmed_df = df.loc[np.argsort(pred)[50:]]
# 确定要使用的最佳簇数
best_score = np.inf
best_n_clusters = -1
for n_clusters in range(2, 10):
gmm = GaussianMixture(n_components=n_clusters)
gmm.fit(trimmed_df)
score = gmm.bic(trimmed_df)
if score < best_score:
best_score = score
best_n_clusters = n_clusters
# 拟合GMM
gmm = GaussianMixture(n_components=best_n_clusters)
gmm.fit(trimmed_df)
# 使用GMM生成合成数据
samples = gmm.sample(n_samples=500)
synth_df = pd.DataFrame(samples[0], columns=df.columns)
# 绘制两个列作为二维散点图
sns.scatterplot(data=df, x="At_bats", y="RBIs", color='blue', alpha=0.1)
sns.scatterplot(data=synth_df, x="At_bats", y="RBIs",
color='red', marker="*", s=200)
plt.show()
与从左到右生成特征(使用每个特征的预测器)一样,使用GMM生成的数据非常现实,很好地尊重特征之间的关联。下图再次显示了At_bats和RBIs特征,这次是使用上面代码中的GMM生成的。
再次,真实数据显示为蓝色点,合成数据显示为红色星号。我们可以看到合成数据遵循相同的分布,并且在这一对特征和生成的体积下,实际上往往比一些真实数据更典型。
6、处理分类数据
处理分类数据稍微涉及更多,因为GMM假设完全是数值数据,要求任何分类列都被编码。生成的合成行也将完全是数值型的,因此我们需要反过来,将GMM生成的数值值转换为数据集中使用的原始分类值。
这取决于数据是如何编码的。例如,如果数据是一热编码的,我们可以选择二进制列中值最高的作为生成的值;如果是计数编码,我们选择最接近生成值的计数。其他编码方式也是如此。
7、生成更多多样化数据
以这种方式使用GMM非常适合生成现实数据,但不适合生成异常数据。如果希望生成一些更多变化的数据,可以通过多种方式实现。一种方法是修改每个簇相关的协方差矩阵。Scikit-learn的GMM类(我们在这些示例中使用)维护一个3D数组的协方差。第一个维度与簇相关。然后,每个簇有一个2D矩阵。上面的代码选择了9个簇,因此有9个2D协方差矩阵。每个是22x22,因为编码后有22个特征,矩阵存储每对特征之间的协方差(以及每个特征的方差在主对角线上)。
我们可以修改协方差以允许更大的值范围:
for i in range(len(gmm.covariances_)):
for j in range(len(gmm.covariances_[i])):
for k in range(len(gmm.covariances_[i])):
gmm.covariances_[i][j][k] *= 20.0
这将每个簇的协方差矩阵中的每个值乘以20.0;在不同情况下使用较小或较大的值可能有用,但再次,这可以调整以创建一系列用于测试的合成数据集。这产生了如下图所示的合成数据:
合成数据(红色星号)在所有方向上的范围都比真实数据大得多,可以认为相对于原始数据包含大量异常值。
其他建模数据空间密度的工具也可以以类似的方式生成数据。例如,scikit-learn的KernelDensity与这种方法非常相似,也提供了一个sample() API。其他方法,如多维直方图,也可以用来建模真实数据并生成匹配的合成数据。
8、评估创建的数据
我们没有时间在这篇文章中深入讨论评估合成数据,但这是一个非常重要的主题,我希望在未来的文章中涵盖。很容易生成在某种方式上不理想的数据——特别是那些要么不如我们期望的现实,或者相反,比我们期望的更受限制的数据。
当然,我们应该仔细查看数据,并对其进行一些探索性数据分析(EDA)以查看是否有明显的东西。我们也可以编写一些测试。评估合成数据的一种简单方法是训练一个分类器,看看它是否能正确区分真实数据和合成数据。如果我们使用一个强大的模型,比如CatBoost,并且它无法检测到大部分合成数据,那么我们很可能会有高质量的合成数据。理想情况下,我们会花一些时间调整CatBoost模型,尽管CatBoost自带了强大的默认超参数。
另一种方法是进行异常检测。如果我们在一个真实数据上训练一个或多个异常检测器,然后使用这些异常检测器对真实数据和合成数据进行评估,我们可以确定合成数据是否有更多的异常值——以及是否有更极端的异常值。这样做时,我们还应进行一些集体异常检测,这不仅检查单个合成行是否现实,还检查它们整体是否合理。
9、结束语
这仅介绍了两种生成合成数据的方法:逐个单元格使用预测模型生成单元格值;以及使用高斯混合模型(GMM)。还有许多其他方法,包括贝叶斯网络、模拟、生成对抗网络(GANs)、自动编码器、大型语言模型(LLMs)等,我希望能在未来的文章中涵盖这些内容。但这里介绍的两种方法允许直接、快速且相对可解释地生成与原始真实数据相匹配的合成数据。
原文链接:Robust methods to generate synthetic table data
汇智网翻译整理,转载请标明出处