原文:
annas-archive.org/md5/5532fd447031f1db26ab91548948a023
译者:飞龙
第四章:清理杂乱数据与数据处理
在本章中,我们将深入探讨数据处理的策略,重点介绍高效清理和修复杂乱数据集的技巧。我们将移除无关列,系统地处理不一致的数据类型,并修复日期和时间。
在本章中,我们将涵盖以下主题:
-
重命名列
-
移除无关或冗余的列
-
修复数据类型
-
处理日期和时间
技术要求
你可以在以下 GitHub 链接找到本章的所有代码:github.com/PacktPublishing/Python-Data-Cleaning-and-Preparation-Best-Practices/tree/main/chapter04
。
每个文件都根据本章所涉及的相应章节命名。
重命名列
为列重命名更具描述性和意义的名称,使得每列的内容和目的更加容易理解。清晰直观的列名提高了数据集的可解释性,特别是在与他人共享或协作时。
为了更好地理解本章中介绍的所有概念,我们将在本章中使用一个场景。假设有一家电子商务公司,想要分析客户的购买数据,以优化其营销策略。数据集包含有关客户交易的信息,如购买金额、支付方式和交易时间戳。然而,数据集很杂乱,需要清理和处理才能提取有意义的洞察。
以下图展示了特征的分布。要构建以下的统计图表,请执行文件:github.com/PacktPublishing/Python-Data-Cleaning-and-Preparation-Best-Practices/blob/main/chapter04/1.descriptive_stats.py
。运行此脚本后,数据和图表会自动生成。
图 4.1 – 数据转化前的特征分布
数据集包含五个列:
-
CustomerID
:每个客户的唯一标识符。在这个示例中,客户 ID 的范围是1
到11
。 -
ProductName
:表示购买的产品名称。在数据集中,考虑了三种产品:Product_A
、Product_B
和Product_C
。 -
PurchaseAmount
:表示客户在某个产品上的消费金额。金额使用的是任意货币。 -
PaymentMethod
:描述客户用于购买的支付方式。支付方式包括Card
、PayPal
、Cash
和Bank Transfer
。 -
Timestamp
:表示购买发生的日期和时间。它以datetime
对象的格式呈现。
我们首先要检查和更新的是列名。让我们在接下来的部分开始这项工作。
重命名单个列
现在,这家电子商务公司决定重新品牌化其产品,需要更改与产品信息相关的列名。我们将从重命名一个列开始,然后进一步重命名多个列以配合品牌重塑的计划。有关重命名示例,请访问 github.com/PacktPublishing/Python-Data-Cleaning-and-Preparation-Best-Practices/blob/main/chapter04/2.rename_columns.py
。
让我们看看如何在数据集中重命名一个列:
df.rename(columns={'ProductName': 'OldProductName'}, inplace=True)
inplace=True
参数是 pandas DataFrame 方法中的一个可选参数,它允许你直接修改 DataFrame,而不必创建一个新的 DataFrame 对象。
当inplace
设置为True
时,DataFrame 会就地修改,这意味着更改会应用到原始的 DataFrame 对象上。这在你想更新或修改 DataFrame 而无需将修改后的 DataFrame 分配给新变量时非常有用。
如果没有指定inplace=True
或者将其设置为False
(这是默认行为),DataFrame 方法会返回一个新的修改后的 DataFrame 对象,原始 DataFrame 不会被改变。在这种情况下,你需要将修改后的 DataFrame 分配给一个新变量以保存更改。
注意
需要注意的是,使用inplace=True
可能是一个破坏性操作,因为它会直接修改原始的 DataFrame。因此,建议谨慎使用,并确保在需要时有原始 DataFrame 的备份。如果你有一个大数据集,原地修改可以帮助节省内存。
在下一节中,我们将重命名多个列,以便与品牌重塑活动保持一致。
重命名所有列
在一次品牌重塑活动后,公司决定将OldProductName
重命名为NewProductName
,并将PurchaseAmount
重命名为NewPurchaseAmount
,以便与更新后的产品名称一致。此代码演示了如何一次性重命名多个列:
df.rename(columns={'OldProductName': 'NewProductName', 'PurchaseAmount': 'NewPurchaseAmount'}, inplace=True)
如果你想重命名 DataFrame 中的列,并确保过程顺利且无错误,我们可以添加错误处理。例如,确保你打算重命名的列确实存在于 DataFrame 中。如果某个列名拼写错误或不存在,重命名操作将引发错误:
if 'OldProductName' in df.columns:
try:
# Attempt to rename multiple columns
df.rename(columns={'OldProductName': 'NewProductName', 'PurchaseAmount': 'NewPurchaseAmount'}, inplace=True)
except ValueError as ve:
print(f"Error: {ve}")
else:
print("Error: Column 'OldProductName' does not exist in the DataFrame.")
注意
确保新的列名在 DataFrame 中不存在,以避免覆盖已有的列。
重命名列是我们让数据更整洁、易于理解的最简单操作之一。接下来,我们通常会做的是只保留我们需要的或关心的列,如下一节所讨论的那样。
删除无关或冗余的列
大型数据集通常包含大量列,其中一些可能与当前的分析或任务无关。通过删除这些列,我们可以获得一些显著的好处。首先,存储需求大幅减少,从而节省成本并提高资源的使用效率。此外,精简后的数据集使查询性能更快,内存使用更加优化,并且加快了复杂分析的处理时间。这不仅提高了数据处理任务的整体效率,也简化了大型数据集的管理和维护。此外,在基于云的环境中,存储成本是一个重要因素,删除不必要的列有助于提高成本效率。所以,让我们看看如何以高效的方式删除列。
在我们之前展示的电子商务数据集中,我们收集了有关客户购买的信息。然而,由于你的分析侧重于与产品相关的指标和客户行为,某些列,如CustomerID
和Timestamp
,可能对当前分析而言不相关。目标是通过删除这些列来精简数据集。你可以通过以下 Python 脚本进行操作:github.com/PacktPublishing/Python-Data-Cleaning-and-Preparation-Best-Practices/blob/main/chapter04/3.dropping_columns.py
:
columns_to_drop = ['CustomerID', 'Timestamp'] # Replace with the names of the columns you want to drop
try:
# Drop columns considered irrelevant for the current analysis
df.drop(columns=columns_to_drop, inplace=True)
except KeyError as ke:
print(f"Error: {ke}")
现在,如果你查看数据集,删除前的列是这样的:
Index(['CustomerID', 'NewProductName', 'NewPurchaseAmount', 'PaymentMethod','Timestamp'],dtype='object')
删除这两列后,我们得到如下结果:
Index(['NewProductName', 'NewPurchaseAmount', 'PaymentMethod'], dtype='object')
注意
Python 默认区分大小写。这意味着ColumnName
和columnname
被视为不同的。
我们如前所示成功移除了不必要的列。为了进一步评估内存效率,我们可以计算删除列前后 DataFrame 的内存消耗。以下代码提供了一个 Python 示例,演示如何计算删除列前后 DataFrame 的内存使用量:
print("Initial Memory Usage:")
print(df.memory_usage().sum() / (1024 ** 2), "MB") # Convert bytes to megabytes
print("\nMemory Usage After Dropping Columns:")
print(df.memory_usage().sum() / (1024 ** 2), "MB") # Convert bytes to megabytes
初始时,DataFrame 的内存使用量大约为 0.00054 兆字节,删除列后,内存使用量降至约 0.00037 兆字节。内存使用量的减少展示了接近 31%的优化。
虽然这个示例涉及的是一个小数据集,但当这些原则扩展到大数据场景时,内存效率的影响更加显著。在大规模数据集中,删除不必要的列的影响将更加明显。
为了强调操作的重要性,考虑一个包含大量数据集的场景。最初,数据集的大小为 100,000 兆字节,经过去除不必要的列后,大小减少到 69,000 兆字节。为了执行相同的工作负载,最初的选择是使用 AWS EC2 实例类型r7g.4xlarge
,其小时费率为$1.0064,内存为 128 GiB,因为我们需要 100GB 的内存才能加载数据集。然而,通过将数据集大小减少到 61GB,便可以选择一种更具成本效益的替代方案,使用r7g.2xlarge
实例,小时费率为$0.5032,内存为 64 GiB。在五分钟的工作负载运行时间的背景下,操作前的数据处理成本如下:
Cost_before = (Hourly Rate/60) * Runtime(in minutes) = (1.0064/60) * 5 = 0.0838$
Cost_after = (Hourly Rate/60) * Runtime(in minutes) = (0.5032/60) * 5 = 0.041$
去除不必要的列后,解决方案的成本大约降低了 50%。这代表通过优化数据集并使用更合适的 AWS 实例类型所实现的成本节省。
这个例子的简洁性突显了一个重要的讯息:
通过关注真正重要的部分来简化你的数据操作,让这种简洁性 推动成本效益的提高。
从去除列到修复不一致数据类型的过渡涉及确保数据集中剩余列的质量和完整性。
处理不一致和错误的数据类型
在处理 DataFrame 时,确保每一列具有正确的数据类型非常重要。不一致或错误的数据类型可能会导致分析中的错误、意外的行为以及在执行操作时遇到困难。让我们看看如何处理这种情况。你可以在这里找到这个例子的代码:github.com/PacktPublishing/Python-Data-Cleaning-and-Preparation-Best-Practices/blob/main/chapter04/4.data_types.py
。
检查列
检查数据中每一列的类型是识别任何不一致或错误数据类型的重要步骤。DataFrame 的dtypes
属性提供了每一列的数据类型信息。让我们检查数据集中各列的数据类型:
print("\nUpdated Data Types of Columns:")
print(df.dtypes)
这里展示了几种类型:
CustomerID int64
ProductName object
PurchaseAmount int64
PaymentMethod object
Timestamp object
检查数据类型可以帮助你了解当前数据的表示方式,并判断是否需要进行数据类型转换或变换以便进一步分析或数据清理。接下来的章节中,我们将进行不同类型的转换。
列类型转换
在数据处理的世界里,astype
方法是你的好帮手。你应该熟悉的最常见的类型转换将在接下来的章节中介绍。
转换为数值类型
在 pandas 中,astype()
函数用于将列转换为指定的数字数据类型。例如,要将名为PurchaseAmount
的列转换为整数类型,可以使用以下方法:
df['PurchaseAmount'] = pd.to_numeric(df['PurchaseAmount'], errors='coerce')
现在,让我们看看如何将列转换为字符串。
转换为字符串类型
你可以使用astype()
函数将列转换为字符串类型:
df['ProductName'] = df['ProductName'].astype('str')
现在,让我们看看如何将列转换为类别类型。
转换为类别类型
类别类型(categorical type)指的是表示类别或离散变量的数据类型。类别变量可以取有限的,通常是固定的不同类别或级别。这些变量通常表示定性数据或没有内在顺序的属性:
df['PaymentMethod'] = df['PaymentMethod'].astype('category')
我们将讨论的最后一个转换是布尔值(Boolean)。
转换为布尔类型
一个基于特定条件或标准的 True
/False
值。这种转换通常用于创建二进制指示符或标志,使得数据更容易处理和分析:
df['HasDive'] = df['ProductName'].str.contains('Dive', case=False)
前面的代码部分检查 ProductName
列中的每个元素是否包含子字符串 Dive
。它返回一个布尔序列,其中每个元素如果满足条件则为 True
,否则为 False
:
df['HasDive'] = df['HasDive'].astype('bool')
astype('bool')
方法用于显式地将 HasDive
列的数据类型转换为布尔类型。
使用 astype(bool)
时需要注意的事项
如果你遇到所有值都被转换为 True
的情况,可能是由于以下原因之一:
1. 在布尔上下文中,.astype(bool)
会将所有非零值转换为 True
。在这种情况下,请考虑该列是否包含了意外或不必要的非零值。
2. 使用 .astype(bool)
时为 True
。检查该列中是否存在缺失值,并考虑如何处理这些缺失值。在转换之前,可能需要填充或删除缺失值。
在本章的最后一部分,我们将讨论如何处理日期和时间。
处理日期和时间
想象一下,你有关于事件发生时间的数据——能够理解和处理这些时间相关的数据是理解模式和趋势的关键。它不仅仅是了解事件发生的时间,而是通过数据更轻松地进行可视化和讲述故事。无论是分析随时间变化的趋势,筛选特定时期的数据,还是使用机器学习进行预测,熟练掌握日期和时间是从涉及时间维度的数据集中解锁宝贵见解的关键。
现在我们理解了为什么处理日期和时间如此重要,下一步是学习如何获取与时间相关的信息并让它为我们所用。
导入并解析日期和时间数据
Python 提供了几种主要的日期解析函数,具体取决于输入日期字符串的格式和所需的输出。让我们讨论一些常用的日期解析函数。
pandas 库中的 pd.to_datetime()
此函数专门用于解析 pandas DataFrame 或 Series 中的日期字符串,但也可以独立使用。当处理表格数据时非常适用,并且允许同时处理多种日期格式:
df['Timestamp3'] = pd.to_datetime(df['Timestamp'], format='%Y-%m-%d %H:%M:%S')
format
参数指定了输入字符串的预期格式。在此示例中,%Y
表示四位数字的年份,%m
表示月份,%d
表示日期,%H
表示小时,%M
表示分钟,%S
表示秒。
注意事项
如果您的数据集包含缺失或不一致的时间戳值,请考虑使用errors
参数。例如,errors='coerce'
将把解析错误替换为非时间(NaT)值。
尽管pd.to_datetime
效率较高,但对于大数据集,它可能会对性能产生影响。为了提高性能,考虑使用infer_datetime_format=True
参数来自动推断格式(对标准格式效果较好)。当infer_datetime_format
设置为True
,并且parse_dates
启用时,Pandas 将尝试自动推断列中日期时间字符串的格式。如果成功,它将切换到更高效的解析方法,在某些场景下可能将解析速度提高 5 到 10 倍。
如果您的数据涉及不同的时区,请考虑使用utc
和tz
参数来处理协调世界时(UTC)转换和时区本地化。
在下一节中,我们将介绍另一种方法,strftime
。此方法允许自定义日期时间值,从而创建特定且易于阅读的时间表示。
strftime()来自 datetime 模块
此函数用于根据指定的格式字符串将日期字符串解析为日期时间对象。当您有已知的日期格式并希望精确控制解析过程时,它非常适用:
df['FormattedTimestamp'] = df['Timestamp'].dt.strftime('%b %d, %Y %I:%M %p')
结果 DataFrame 如下:
Timestamp FormattedTimestamp
0 2022-01-01 08:30:45 Jan 01, 2022 08:30 AM
1 2022-01-02 14:20:30 Jan 02, 2022 02:20 PM
格式由格式说明符控制,每个说明符以百分号(%
)字符开头,表示日期和时间的不同组成部分(例如,%Y
表示年份,%m
表示月份,%d
表示日期,%H
表示小时,%M
表示分钟,%S
表示秒等)。可以在 Python 文档中找到完整的格式说明符列表:strftime.org/
。
与strftime
所要求的严格结构不同,dateutil.parser.parse()
在解释各种日期和时间表示方式方面表现出色,提供了一种动态的解决方案,用于解析多种不同的日期时间字符串,正如我们将在下一节中看到的那样。
dateutil.parser.parse()来自 dateutil 库
此函数提供了一种灵活的方法来解析日期字符串,自动推断输入的格式。当处理多种日期格式或格式未知时,它非常有用:
df['Timestamp2'] = df['Timestamp'].apply(parser.parse)
需要注意的是,这种方法的解析器可以推断并处理时区信息,使得处理来自不同时间区的数据变得更加便捷。
在下一部分中,我们不再处理日期和时间,而是转向将其分割为各个部分,如天、月和年。
提取日期和时间的组件
你可以使用 datetime 模块提供的属性提取 datetime 对象的特定组件,如年份、月份、日期、小时、分钟或秒:
df['Day'] = df['Timestamp'].dt.day
df['Month'] = df['Timestamp'].dt.month
df['Year'] = df['Timestamp'].dt.year
使用 .dt
访问器,我们可以从 Timestamp
列中提取天、月和年的组件,并创建新的列 Day
、Month
和 Year
,如下所示:
Timestamp Day Month Year
0 2022-01-01 08:30:45 1 1 2022
1 2022-01-02 14:20:30 2 1 2022
提取组件在以下情况下非常有用:
-
时间分析:如果你的分析涉及到跨天、跨月或跨年的模式或趋势,提取这些组件有助于进行更为专注的探索。
-
分组与聚合:当基于时间模式对数据进行分组时,提取组件可以方便地进行聚合和总结。
-
时间序列分析:对于时间序列分析,将日期时间值分解为各个组件对理解季节性和趋势至关重要。
继续计算时间差异和持续时间,将通过引入动态维度提升我们对时间数据的探索。
计算时间差异和持续时间
当使用减法计算两个 datetime 对象之间的时间差时,你可以利用 Python datetime
库的内在能力来生成一个 timedelta
对象。这个对象封装了两个时间戳之间的持续时间,以天、小时、分钟和秒为单位,提供了对时间差的全面表示。该部分的代码可以在此找到:github.com/PacktPublishing/Python-Data-Cleaning-and-Preparation-Best-Practices/blob/main/chapter04/8.time_deltas.py
:
df['TimeSincePreviousPurchase'] = df['Timestamp'].diff()
这个 pandas 函数 .diff()
计算 Timestamp
列中每个元素与前一个元素之间的差异。它有效地计算了自上一个时间戳以来的时间差。
df['TimeUntilNextPurchase'] = -df['Timestamp'].diff(-1)
与第一行类似,这计算了 Timestamp
列中每个元素与下一个元素之间的差异。它计算了直到下一个时间戳的时间持续。负号应用于反转时间差的符号。这样做是为了获取直到下次购买的时间差的正表示。
让我们看看数据中时间差是如何表现的:
Timestamp TimeSincePreviousPurchase TimeUntilNextPurchase
0 2022-01-01 08:30:45 NaT 1 days 05:49:45
1 2022-01-02 14:20:30 1 days 05:49:45 1 days 05:54:40
如果你在考虑何时在数据工作流程中加入一些时间差异,可以阅读以下内容:
-
基于时间的分析:计算时间差异可以分析事件或时间戳之间的持续时间。它有助于量化不同过程、活动或间隔所花费的时间。
-
性能测量:通过测量任务或事件的持续时间,可以评估性能指标,如响应时间、处理时间或完成操作所需的时间。这些信息可以指导优化工作,并识别改进的领域。
-
事件排序:通过比较时间戳,您可以确定事件发生的时间顺序。这种排序有助于理解事件之间的关系及其依赖性。
-
服务级别协议(SLA)监控:时间差异对于 SLA 监控非常有用。通过比较与 SLA 指标相关的时间戳,例如响应时间或解决时间,您可以确保遵守约定的服务水平。监控时间差异有助于识别 SLA 违反并采取适当的措施。
pandas 中的.diff()
方法主要用于计算 Series 或 DataFrame 中连续元素之间的差异。虽然计算一阶差异(即相邻元素之间的差异)是直接的,但还有其他需要考虑和探索的变体。
指定时间间隔
您可以自定义.diff()
来计算特定时间间隔内元素之间的差异。这是通过传递periods
参数来指定要移动的元素数量:
df['TimeDifference'] = df['Timestamp'].diff(periods=2)
让我们观察以下结果:
Timestamp TimeSincePreviousPurchase TimeDifference2periods
0 2022-01-01 08:30:45 NaT NaT
1 2022-01-02 14:20:30 1 days 05:49:45 NaT
2 2022-01-03 20:15:10 1 days 05:54:40 2 days 11:44:25
3 2022-01-04 12:45:30 0 days 16:30:20 1 days 22:25:00
如您所见,.diff(periods=2)
计算了每个时间戳与之前两个位置之间的差异。periods
参数允许您指定计算差异时要移动的元素数量。在这种情况下,它是periods=2
,但您可以为其分配任何适合您用例的值。
处理缺失值
使用diff(periods=2)
时,.diff()
方法会为前两个元素引入 NaN,因为没有前一个元素来计算差异。您可以根据具体用例处理或填充这些缺失值:
df['TimeDifference'] = df['Timestamp']. diff(periods=2).fillna(0)
让我们观察结果:
Timestamp TimeDiff2periods_nonulls TimeDifference2periods
0 2022-01-01 08:30:45 0 NaT
1 2022-01-02 14:20:30 0 NaT
2 2022-01-03 20:15:10 2 days 11:44:25 2 days 11:44:25
3 2022-01-04 12:45:30 1 days 22:25:00 1 days 22:25:00
如您所见,fillna(0)
将 NaN 值替换为0
。
从时间差异和持续时间到时区和夏令时,我们现在将讨论如何处理跨不同区域的时间数据的细节。
处理时区和夏令时
处理时区在处理跨多个地理区域的数据或准确的时间表示至关重要。时区帮助标准化不同地点的时间,考虑到由于地理边界和夏令时调整导致的 UTC 偏移。在我们的示例数据集中,我们将演示如何使用 pandas 处理时区:
df['Timestamp_UTC'] = df['Timestamp'].dt.tz_localize('UTC')
我们将时间戳本地化到特定的时区,在这个例子中是'UTC'
。
df['Timestamp_NY'] = df['Timestamp_UTC'].dt.tz_convert('America/New_York')
然后,我们将本地化的时间戳转换为不同的时区,在这个例子中是'America/New_York'
。让我们观察以下结果:
Timestamp Timestamp_UTC Timestamp_NY
0 2022-01-01 08:30:45 2022-01-01 08:30:45+00:00 2022-01-01 03:30:45-05:00
1 2022-01-02 14:20:30 2022-01-02 14:20:30+00:00 2022-01-02 09:20:30-05:00
想了解管理时区的重要性吗?让我们来看看它为什么重要:
-
在处理来自不同时间区的数据时,必须处理时区以确保准确的分析和解读。如果没有正确的时区处理,分析结果可能会因为时间表示不一致而出现偏差。
-
对于需要精确时间表示的应用,如金融交易、日志条目或事件跟踪,时区处理变得至关重要。
-
在整合来自不同来源的数据或合并数据集时,时区处理变得必要,以确保时间戳的准确对齐。这确保了事件的正确时间顺序,并避免了基于时间的分析中的不一致。
-
如果你正在开发面向不同时间区用户的应用或服务,处理时区是至关重要的,能够为用户提供基于他们本地时间的准确和相关信息。
考虑事项
时区处理应该在数据处理流程中始终如一地实施,以避免不一致或错误。
让我们总结一下本章的学习内容。
总结
本章讨论了清理和处理数据的技巧。从混乱数据的挑战开始,我们介绍了如何删除无关的列以及如何处理不一致的数据类型。通过电子商务数据集展示了实际案例,展示了使用 Python 代码进行有效的数据转换。特别强调了删除不必要列的重要性,突出了潜在的成本降低和内存效率提升,尤其是在大数据环境下。数据类型转换,包括数字、字符串、分类和布尔值转换,通过实际示例进行了说明。接着,本章深入探讨了处理日期和时间的复杂问题,展示了诸如pd.to_datetime()
、strftime
和dateutil.parser.parse()
等方法。
随着本章的结束,它为下一章的数据合并和转换奠定了坚实的基础。
第五章:数据转换 – 合并和拼接
理解如何转换和处理数据对于挖掘有价值的洞察至关重要。技术如连接、合并和附加使我们能够将来自不同来源的信息融合在一起,并组织和分析数据的子集。在本章中,我们将学习如何将多个数据集合并成一个单一的数据集,并探索可以使用的各种技术。我们将理解如何在合并数据集时避免重复值,并学习一些提升数据合并过程的技巧。
本章将涵盖以下主题:
-
连接数据集
-
处理数据合并中的重复项
-
合并时的性能优化技巧
-
拼接 DataFrame
技术要求
你可以在以下链接找到本章的所有代码:github.com/PacktPublishing/Python-Data-Cleaning-and-Preparation-Best-Practices/tree/main/chapter05
。
每个章节后面都有一个具有类似命名约定的脚本,欢迎你执行脚本或通过阅读本章来跟随学习。
连接数据集
在数据分析项目中,常常会遇到数据分散在多个来源或数据集中的情况。每个数据集可能包含与某个共同实体或主题相关的不同信息片段。数据合并,也称为数据连接或数据拼接,是将这些独立的数据集合并成一个统一的数据集的过程。在数据分析项目中,常常会遇到某个特定主题或实体的信息分布在多个数据集中的情况。例如,假设你正在为一个零售企业分析客户数据。你可能有一个数据集包含客户的人口统计信息,如姓名、年龄和地址,另一个数据集包含他们的购买历史,如交易日期、购买的商品和总支出。每个数据集都提供了有价值的见解,但单独来看,它们无法完整展现客户的行为。为了获得全面的理解,你需要将这些数据集合并。通过根据一个共同的标识符(如客户 ID)将客户的人口统计信息与购买历史合并,你可以创建一个单一的数据集,从而进行更丰富的分析。例如,你可以识别出哪些年龄组购买了特定的产品,或支出习惯如何因地域而异。
选择正确的合并策略
选择正确的连接类型至关重要,因为它决定了输入 DataFrame 中哪些行会被包含在连接后的输出中。Python 的 pandas 库提供了几种连接类型,每种类型具有不同的行为。我们将介绍本章将要使用的用例示例,然后深入探讨不同类型的连接。
在本章中,我们的使用案例涉及员工数据和项目分配,适用于一个管理其员工和项目的公司。你可以执行以下脚本,详细查看数据框:github.com/PacktPublishing/Python-Data-Cleaning-and-Preparation-Best-Practices/blob/main/chapter05/1.use_case.py
。
employee_data
数据框表示员工的详细信息,例如他们的姓名和部门,内容如下所示:
employee_id name department
0 1 Alice HR
1 2 Bob IT
project_data
数据框包含项目分配的信息,包括项目名称:
employee_id project_name
0 2 ProjectA
1 3 ProjectB
在接下来的章节中,我们将讨论不同的数据框合并选项,从内连接开始。
内连接
内连接只返回在指定的连接列中,两个数据框中都有匹配值的行。需要特别注意以下几点:
-
任何一个数据框中具有不匹配键的行将会被排除在合并结果之外
-
具有缺失值的键列中的行将被排除在合并结果之外
内连接的结果展示在下图中:
图 5.1 – 内连接
让我们来看看如何使用 pandas merge
函数实现上述结果,参考前一节中的示例:
merged_data = pd.merge(employee_data, project_data, on='employee_id', how='inner')
正如我们在前面的代码片段中看到的,pd.merge()
函数用于合并两个数据框。on='employee_id'
参数指定应使用 employee_id
列作为连接数据框的键。how='inner'
参数指定执行内连接。这种连接类型只返回在两个数据框中都有匹配值的行,在本例中就是 employee_id
在 employee_data
和 project_data
中匹配的行。以下表格展示了两个数据框内连接的结果:
employee_id name department project_name
0 2 Bob IT ProjectA
1 3 Charlie Marketing ProjectB
2 4 David Finance ProjectC
3 5 Eva IT ProjectD
这种方法确保了两个数据框的数据是基于公共键合并的,只有在两个数据框中都有匹配时,才会包含对应的行,从而遵循内连接的原则。
如果仍然不清楚,以下列表展示了在数据世界中,内连接至关重要的具体示例:
-
匹配表格:当你需要匹配来自不同表的数据时,内连接是理想的选择。例如,如果你有一个员工表和一个部门名称表,你可以使用内连接将每个员工与他们相应的部门匹配。
-
数据过滤:内连接可以作为过滤器,排除那些在两个表中没有对应条目的行。这在你只希望考虑那些在多个表中有完整数据的记录时非常有用。例如,只有在客户订单和产品详情都有记录的情况下,才匹配这两者。
-
查询执行效率:由于内部连接只返回两个表中具有匹配值的行,因此在查询执行时间方面可能比需要检查并处理非匹配条目的外部连接更有效。
-
减少数据重复:内部连接通过仅返回匹配的行来帮助减少数据重复,从而确保结果集中的数据是相关的,而不是冗余的。
-
简化复杂查询:在处理多个表格时,内部连接可用于通过减少需要检查和处理的行数来简化查询。这在复杂的数据库模式中特别有用,其中多个表格相互关联。
从内部连接转向外部连接扩展了合并数据的范围,合并所有可用的两个数据集的行,即使它们之间没有对应的匹配项。
外部合并
外部合并(也称为完全外部连接)返回两个数据帧的所有行,结合匹配的行以及不匹配的行。完全外部连接确保不会丢失来自任一数据帧的数据,但在其中一个数据帧中存在不匹配行时,可能会引入 NaN 值。
外部合并的结果如下图所示:
图 5.2 – 外部合并
让我们看看如何使用 pandas 的 merge
函数来实现前述结果,在上一节中提供的示例中:
full_outer_merged_data = pd.merge(employee_data, project_data, on='employee_id', how='outer')
正如我们在前面的代码片段中看到的那样,pd.merge()
函数用于合并这两个数据帧。参数 on='employee_id'
指定了应该使用 employee_id
列作为合并数据帧的键。参数 how='outer'
指定执行完全外部连接。这种连接类型返回两个数据帧中的所有行,并在没有匹配项的地方填充 NaN
。在以下表格中,您可以看到这两个数据帧进行外部连接的输出:
employee_id name department project_name
0 1 Alice HR NaN
1 2 Bob IT ProjectA
2 3 Charlie Marketing ProjectB
3 4 David Finance ProjectC
4 5 Eva IT ProjectD
5 6 NaN NaN ProjectE
该方法确保合并来自两个数据帧的数据,允许全面查看所有可用数据,即使由于数据帧之间的不匹配导致部分数据不完整。
在以下列表中,我们提供了数据领域中外部合并至关重要的具体示例:
-
包含可选数据:当您希望包含另一个表格中具有可选数据的行时,外部连接是理想的选择。例如,如果您有一个用户表和一个单独的地址表,不是所有用户都可能有地址。外部连接允许您列出所有用户,并显示那些有地址的用户的地址,而不排除没有地址的用户。
-
数据完整性和完整性:在需要一个包含两张表中所有记录的全面数据集的场景中,无论是否在连接表中有匹配记录,外连接都是必不可少的。这确保了你能全面查看数据,特别是在需要展示所有实体的报告中,比如列出所有客户及其购买情况的报告,其中包括那些没有购买的客户。
-
数据不匹配分析:外连接可以用来识别表之间的差异或不匹配。例如,如果你在比较注册用户列表与事件参与者列表,外连接可以帮助识别未参与的用户和未注册的参与者。
-
复杂数据合并:在合并来自多个来源的数据时,这些数据无法完美对齐,外连接可以确保在合并过程中没有数据丢失。这在数据完整性至关重要的复杂数据环境中尤为有用。
从外连接过渡到右连接,缩小了合并数据的关注范围,强调包含右侧 DataFrame 中的所有行,同时保持左侧 DataFrame 中的匹配行。
右连接
右连接(也称为右外连接)返回右侧 DataFrame 中的所有行,以及左侧 DataFrame 中的匹配行。右连接的结果如下图所示:
图 5.3 – 右连接
让我们来看一下如何使用 pandas 的merge
函数实现前述结果,参考上一节中提供的示例:
right_merged_data = pd.merge(employee_data, project_data, on='employee_id', how='right')
how='right'
参数指定执行右外连接。此类型的连接返回右侧 DataFrame(project_data
)中的所有行,以及左侧 DataFrame(employee_data
)中的匹配行。如果没有匹配,则结果中左侧 DataFrame 的列会显示为 NaN
。在下表中,你可以看到前述两个 DataFrame 合并的输出结果:
employee_id name department project_name
0 2 Bob IT ProjectA
1 3 Charlie Marketing ProjectB
2 4 David Finance ProjectC
3 5 Eva IT ProjectD
4 6 NaN NaN ProjectE
在以下列表中,我们展示了数据领域中右连接至关重要的具体示例:
-
完成数据:当你需要确保保留右侧 DataFrame 中的所有条目时,右连接非常有用,这在右侧 DataFrame 包含必须保留的重要数据时尤其重要。
-
数据增强:这种类型的连接可用于通过从另一个数据集(左侧 DataFrame)中获取附加属性来丰富数据集(右侧 DataFrame),同时确保保留主数据集中的所有记录。
-
数据不匹配分析:与外连接类似,右连接可以帮助识别右侧 DataFrame 中哪些条目没有对应的左侧 DataFrame 条目,这对于数据清洗和验证过程至关重要。
从右连接转为左连接,改变了合并数据的视角,优先考虑包括左侧数据框的所有行,同时保持右侧数据框的匹配行。
左连接
左连接(也称为左外连接)返回左侧数据框的所有行以及右侧数据框的匹配行。左连接的结果如以下图所示:
图 5.4 – 左连接
让我们看看如何使用 pandas 的merge
函数来实现前述结果,使用上一节中提供的示例:
left_merged_data = pd.merge(employee_data, project_data, on='employee_id', how='left')
how='left'
参数指定应执行左外连接。这种类型的连接返回左侧数据框(employee_data
)的所有行,以及右侧数据框(project_data
)的匹配行。如果没有匹配项,结果将会在右侧数据框的列中显示NaN
。在以下表格中,您可以看到前述两数据框合并的结果:
employee_id name department project_name
0 1 Alice HR NaN
1 2 Bob IT ProjectA
2 3 Charlie Marketing ProjectB
3 4 David Finance ProjectC
4 5 Eva IT ProjectD
如果你想知道何时使用左连接,那么之前关于右连接的考虑同样适用于左连接。现在我们已经讨论了合并操作,接下来我们来讨论在合并过程中可能出现的重复项如何处理。
合并数据集时处理重复项
在执行合并操作之前处理重复键非常重要,因为重复项可能导致意外结果,例如笛卡尔积,行数会根据匹配条目的数量而增加。这不仅会扭曲数据分析,还会因为结果数据框的大小增加而显著影响性能。
为什么要处理行和列中的重复项?
重复的键可能会导致一系列问题,这些问题可能会影响结果的准确性和数据处理的效率。让我们来探讨一下为什么在合并数据之前处理重复键是一个好主意:
-
如果任一表格中存在重复键,合并这些表格可能会导致笛卡尔积,即一个表格中的每个重复键与另一个表格中相同键的每个出现匹配,从而导致行数呈指数增长。
-
重复的键可能表示数据错误或不一致,这可能导致错误的分析或结论。
-
通过删除重复项来减少数据集的大小,可以加速合并操作的处理时间。
在理解了处理重复键的重要性后,让我们来看看在进行合并操作之前,有哪些有效的策略可以管理这些重复项。
删除重复行
在数据集中删除重复项涉及识别并删除基于特定键列的重复行,以确保每个条目都是唯一的。这一步不仅简化了后续的数据合并,还通过消除由重复数据引起的潜在错误来源,提高了分析的可靠性。为了展示删除重复项,我们将扩展我们一直在使用的示例,在每个 DataFrame 中添加更多的重复行。像往常一样,您可以在此查看完整代码:github.com/PacktPublishing/Python-Data-Cleaning-and-Preparation-Best-Practices/blob/main/chapter05/6a.manage_duplicates.py
。
让我们首先创建一些具有重复 employee_id
键的示例员工数据:
employee_data = pd.DataFrame({
'employee_id': [1, 2, 2, 3, 4, 5, 5],
'name': ['Alice', 'Bob', 'Bob', 'Charlie', 'David', 'Eva', 'Eva'],
'department': ['HR', 'IT', 'IT', 'Marketing', 'Finance', 'IT', 'IT']
})
让我们还创建一些具有重复 employee_id
键的示例项目数据:
project_data = pd.DataFrame({
'employee_id': [2, 3, 4, 5, 5, 6],
'project_name': ['ProjectA', 'ProjectB', 'ProjectC', 'ProjectD', 'ProjectD', 'ProjectE']
})
现在,我们要合并这些数据集。但首先,我们将删除所有重复项,以使合并操作尽可能轻便。删除重复项后的合并操作在以下代码片段中展示:
employee_data = employee_data.drop_duplicates(subset='employee_id', keep='first')
project_data = project_data.drop_duplicates(subset='employee_id', keep='first')
如代码所示,drop_duplicates()
用于根据 employee_id
删除重复行。keep='first'
参数确保仅保留首次出现的记录,其他记录将被删除。
删除重复项后,您可以继续进行合并操作,如以下代码所示:
merged_data = pd.merge(employee_data, project_data, on='employee_id', how='inner')
合并后的数据集如下所示:
employee_id name department project_name
0 2 Bob IT ProjectA
1 3 Charlie Marketing ProjectB
2 4 David Finance ProjectC
3 5 Eva IT ProjectD
merged_data
DataFrame 包含了来自 employee_data
和 project_data
两个 DataFrame 的列,显示了每个在两个数据集中都存在的员工的 employee_id
、name
、department
和 project_name
的值。重复项被删除,确保每个员工在最终合并的数据集中仅出现一次。drop_duplicates
操作对避免数据冗余和合并过程中可能出现的冲突至关重要。接下来,我们将讨论如何确保合并操作尊重键的唯一性并遵守特定的约束条件。
合并前验证数据
在合并数据集时,尤其是处理大型和复杂数据集时,确保合并操作的完整性和有效性至关重要。pandas 在 merge()
函数中提供了 validate
参数,用于强制执行合并键之间的特定条件和关系。这有助于识别并防止可能影响分析的无意重复或数据不匹配。
以下代码演示了如何使用validate
参数来强制执行merge()
约束,并在这些约束未满足时处理异常。你可以在github.com/PacktPublishing/Python-Data-Cleaning-and-Preparation-Best-Practices/blob/main/chapter05/6b.manage_duplicates_validate.py
查看完整代码:
try:
merged_data = pd.merge(employee_data, project_data, on='employee_id', how='inner', validate='one_to_many')
print("Merged Data Result:")
print(merged_data)
except ValueError as e:
print("Merge failed:", e)
在前面的代码片段中,合并操作被包装在try-except
代码块中。这是一种处理异常的方式,异常是指程序执行过程中发生的错误。try
代码块包含可能引发异常的代码,在这种情况下是合并操作。如果发生异常,代码执行将跳转到except
代码块。
如果合并操作未通过验证检查(在我们的例子中,如果左侧 DataFrame 中存在重复的键,而这些键应该是唯一的),将引发ValueError
异常,并执行except
代码块。except
代码块捕获ValueError
异常并打印Merge failed:
消息,后跟 pandas 提供的错误信息。
执行上述代码后,你将看到以下错误消息:
Merge failed: Merge keys are not unique in left dataset; not a one-to-many merge
validate='one_to_many'
参数包含在合并操作中。该参数告诉 pandas 检查合并操作是否符合指定类型。在这种情况下,one_to_many
表示合并键在左侧 DataFrame(employee_data
)中应唯一,但在右侧 DataFrame(project_data
)中可以有重复项。如果验证检查失败,pandas 将引发ValueError
异常。
何时使用哪种方法
当你需要精细控制重复项的识别和处理方式,或者当重复项需要特殊处理(例如基于其他列值的聚合或转换)时,使用手动删除重复项。
当你希望直接在合并操作中确保数据模型的结构完整性时,使用合并验证,尤其是在表之间的关系明确定义并且根据业务逻辑或数据模型不应包含重复键的简单情况。
如果数据中存在重复项是有充分理由的,我们可以考虑在合并过程中采用聚合方法,以合并冗余信息。
聚合
聚合是管理数据集重复项的强大技术,特别是在处理应唯一但包含多个条目的关键列时。通过在这些关键列上分组数据并应用聚合函数,我们可以将重复条目合并为单一的汇总记录。可以使用求和、平均值或最大值等聚合函数,以与分析目标对齐的方式来合并或汇总数据。
让我们看看如何利用聚合来有效地处理数据重复问题。为了帮助展示这个例子,我们稍微扩展一下数据集,具体如下所示。你可以在github.com/PacktPublishing/Python-Data-Cleaning-and-Preparation-Best-Practices/blob/main/chapter05/6c.merge_and_aggregate.py
看到完整示例:
employee_data = pd.DataFrame({
'employee_id': [1, 2, 2, 3, 4, 5, 5],
'name': ['Alice', 'Bob', 'Bob', 'Charlie', 'David', 'Eva', 'Eva'],
'department': ['HR', 'IT', 'IT', 'Marketing', 'Finance', 'IT', 'IT'],
'salary': [50000, 60000, 60000, 55000, 65000, 70000, 70000]
})
# Sample project assignment data with potential duplicate keys
project_data = pd.DataFrame({
'employee_id': [2, 3, 4, 5, 7, 6],
'project_name': ['ProjectA', 'ProjectB', 'ProjectC', 'ProjectD', 'ProjectD', 'ProjectE']
})
现在,让我们进行聚合步骤:
aggregated_employee_data = employee_data.groupby('employee_id').agg({
'name': 'first', # Keep the first name encountered
'department': 'first', # Keep the first department encountered
'salary': 'sum' # Sum the salaries in case of duplicates
}).reset_index()
groupby()
方法在employee_data
上使用,employee_id
作为键。这将 DataFrame 按employee_id
分组,因为存在重复的employee_id
值。
然后,agg()
方法被应用于对不同列进行特定的聚合操作:
-
'name': 'first'
和'department': 'first'
确保在分组数据中保留这些列的首次遇到的值。 -
'salary': 'sum'
对每个employee_id
值的薪资进行求和,如果重复数据表示累计数据的拆分记录,这将非常有用。
在最后一步,使用pd.merge()
函数通过在employee_id
列上进行内连接,将aggregated_employee_data
与project_data
合并:
merged_data = pd.merge(aggregated_employee_data, project_data, on='employee_id', how='inner')
这确保了只有有项目分配的员工会被包含在结果中。合并后的结果如下所示:
employee_id name department salary project_name
0 2 Bob IT 120000 ProjectA
1 3 Charlie Marketing 55000 ProjectB
2 4 David Finance 65000 ProjectC
3 5 Eva IT 140000 ProjectD
pandas 中的agg()
方法非常灵活,提供了许多超出简单“保留首个”方法的选项。这个方法可以应用各种聚合函数来汇总数据,比如对数值进行求和、求平均值,或选择最大值或最小值。我们将在下一章深入探讨agg()
方法的多种功能,探索如何运用这些不同的选项来提升数据准备和分析的质量。
让我们从使用聚合来处理重复数据过渡到拼接重复行,这在处理文本或类别数据时非常有效。
拼接
将重复行的值拼接成一行是一种有用的技巧,特别是在处理可能包含多个有效条目的文本或类别数据时。这种方法允许你保留重复数据中的所有信息,而不会丢失数据。
让我们看看如何通过拼接行来有效地处理数据重复问题,在合并数据之前。为了展示这一方法,我们将使用以下 DataFrame:
employee_data = pd.DataFrame({
'employee_id': [1, 2, 2, 3, 4, 5, 5],
'name': ['Alice', 'Bob', 'Bob', 'Charlie', 'David', 'Eva', 'Eva'],
'department': ['HR', 'IT', 'Marketing', 'Marketing', 'Finance', 'IT', 'HR']
})
现在,让我们进行拼接步骤,如下面的代码片段所示:
employee_data['department'] = employee_data.groupby('employee_id')['department'].transform(lambda x: ', '.join(x))
在拼接步骤中,groupby('employee_id')
方法按employee_id
对数据进行分组。然后,transform(lambda x: ', '.join(x))
方法应用于department
列。此时,使用lambda
函数通过逗号将每个组(即employee_id
)的department
列的所有条目合并成一个字符串。
此操作的结果替换了employee_data
中原始的department
列,现在每个employee_id
都有一个包含所有原始部门数据合并为一个字符串的单一department
条目,如下表所示:
employee_id name department
0 1 Alice HR
1 2 Bob Marketing, IT
3 3 Charlie Marketing
4 4 David Finance
5 5 Eva IT, HR
当你需要在重复条目中保留所有类别或文本数据,而不偏向某一条目时,可以使用连接。
这种方法有助于以可读且信息丰富的方式总结文本数据,特别是在处理可能具有多个有效值的属性时(例如,一个员工属于多个部门)。
一旦解决了每个数据框中的重复行,注意力就转向识别和解决跨数据框的重复列问题。
处理列中的重复
在合并来自不同来源的数据时,遇到列名重叠的数据框并不罕见。这种情况通常发生在合并类似数据集时。
扩展我们迄今为止使用的示例数据,我们将调整数据框(DataFrame)以帮助展示在处理多个数据框中共有列时可用的选项。数据可以在此查看:
employee_data_1 = pd.DataFrame({
'employee_id': [1, 2, 3, 4, 5],
'name': ['Alice', 'Bob', 'Charlie', 'David', 'Eva'],
'department': ['HR', 'IT', 'Marketing', 'Finance', 'IT']
})
employee_data_2 = pd.DataFrame({
'employee_id': [6, 7, 8, 9, 10],
'name': ['Frank', 'Grace', 'Hannah', 'Ian', 'Jill'],
'department': ['Logistics', 'Marketing', 'IT', 'Marketing', 'Finance']
})
让我们看看如何通过应用不同的技巧来合并这些数据集,而不会破坏合并操作。
合并时处理重复列
前面展示的两个数据框中的列名相同,可能表示相同的数据。然而,我们决定在合并的数据框中保留两组列。这个决定基于这样的怀疑:尽管列名相同,但条目并不完全相同,这表明它们可能是相同数据的不同表示形式。这个问题我们可以在合并操作后再处理。
保持两个列集的最佳方法是使用merge()
函数中的suffixes
参数。这将允许你区分来自每个数据框的列,而不会丢失任何数据。以下是在 Python 中使用 pandas 实现这一点的方法:
merged_data = pd.merge(employee_data_1, employee_data_2, on='employee_id', how='outer', suffixes=('_1', '_2'))
pd.merge()
函数用于在employee_id
上合并两个数据框。how='outer'
参数确保包括来自两个数据框的所有记录,即使没有匹配的employee_id
值。suffixes=('_1', '_2')
参数为每个数据框的列添加后缀,以便在合并后的数据框中区分它们。当列名相同但来自不同数据源时,这一点尤为重要。让我们回顾一下输出数据框:
employee_id name_1 department_1 name_2 department_2
0 1 Alice HR NaN NaN
1 2 Bob IT NaN NaN
2 3 Charlie Marketing NaN NaN
3 4 David Finance NaN NaN
4 5 Eva IT NaN NaN
5 6 NaN NaN Frank Logistics
6 7 NaN NaN Grace Marketing
7 8 NaN NaN Hannah IT
8 9 NaN NaN Ian Marketing
9 10 NaN NaN Jill Finance
这种方法在从不同来源合并数据时尤其有用,尤其是当涉及到列名重叠的情况,但同时也需要保留并清晰地区分这些列。另一个需要考虑的点是,后缀可以帮助识别数据来源的数据框,这在涉及多个来源的数据分析中非常有用。
在下一节中,我们将解释如何通过在合并之前删除列来处理重复列。
在合并前删除重复列
如果我们发现要合并的两个 DataFrame 中有相同列的副本,而且其中一个 DataFrame 中的列比另一个更可靠或足够使用,那么在合并操作之前删除其中一个重复列可能更为实际,而不是保留两个副本。做出这一决策的原因可能是简化数据集、减少冗余,或者当某一列对分析没有额外价值时。让我们看一下这个示例的数据:
employee_data_1 = pd.DataFrame({
'employee_id': [1, 2, 3, 4, 5],
'name': ['Alice', 'Bob', 'Charlie', 'David', 'Eva'],
'department': ['HR', 'IT', 'Marketing', 'Finance', 'IT']
})
employee_data_2 = pd.DataFrame({
'employee_id': [1, 2, 3, 4, 5],
'name': ['Alice', 'Bob', 'Charlie', 'David', 'Eva'],
'department': ['Human Resources', 'Information Technology', 'Sales', 'Financial', 'Technical']
})
如果我们仔细查看这些数据,可以发现两个 DataFrame 中的 department
列捕获了相同的信息,但格式不同。为了简化我们的示例,假设我们知道 HR 系统以第一个 DataFrame 中呈现的格式跟踪每个员工的部门。这就是为什么我们会更信任第一个 DataFrame 中的列,而不是第二个 DataFrame 中的列。因此,我们将在合并操作之前删除第二个列。下面是如何在合并之前删除列的操作:
employee_data_2.drop(columns=['department'], inplace=True)
在合并之前,employee_data_2
中的 department
列被删除,因为它被认为不够可靠。这是通过 drop(columns=['department'], inplace=True)
方法完成的。在删除了不需要的列之后,我们可以继续进行合并:
merged_data = pd.merge(employee_data_1, employee_data_2, on=['employee_id', 'name'], how='inner')
使用 pd.merge()
函数,以 employee_id
和 name
列作为键合并 DataFrame。使用 how='inner'
参数来执行内连接,只包含在两个 DataFrame 中具有匹配值的行。
为了优化合并过程并提高性能,通常在执行合并操作之前删除不必要的列是有益的,原因如下:
-
通过显著减少合并操作时的内存占用,这种做法可以提高性能,因为它最小化了需要处理和合并的数据量,从而加快了处理速度。
-
结果 DataFrame 变得更加简洁清晰,便于数据管理和后续分析。这种复杂度的减少不仅简化了合并操作,还减少了出错的可能性。
-
在资源受限的环境中,例如计算资源有限的情况,减少数据集大小在进行如合并等密集型操作之前,可以提高资源效率,并确保更顺畅的执行。
如果在 DataFrame 中存在相同的列,另一种选择是考虑是否可以将它们作为合并操作的键。
重复键
当遇到跨多个 DataFrame 的相同键时,一种智能的做法是基于这些共同列进行合并。让我们回顾一下前一节中提供的示例:
merged_data = pd.merge(employee_data_1, employee_data_2, on=['employee_id', 'name'], how='inner')
我们可以看到,这里我们使用了 ['employee_id', 'name']
作为合并的键。如果 employee_id
和 name
是可靠的标识符,能够确保在 DataFrame 之间准确匹配记录,那么它们应该作为合并的键。这确保了合并后的数据准确地代表了两个来源的结合记录。
随着数据量和复杂性的不断增长,高效地合并数据集变得至关重要,正如我们在接下来的部分中将要学习的那样。
合并的性能技巧
在处理大型数据集时,合并操作的性能可能会显著影响数据处理任务的整体效率。合并是数据分析中常见且常常必需的步骤,但它可能是计算密集型的,尤其是在处理大数据时。因此,采用性能优化技术对于确保合并操作尽可能快速高效地执行至关重要。
优化合并操作可以减少执行时间,降低内存消耗,并带来更加流畅的数据处理体验。在接下来的部分,我们将探讨一些可以应用于 pandas 合并操作的性能技巧,如使用索引、排序索引、选择合适的合并方法以及减少内存使用。
设置索引
在 pandas 中使用索引是数据处理和分析中的一个关键方面,尤其是在处理大型数据集或进行频繁的数据检索操作时。索引既是标识工具,也是高效数据访问的工具,提供了多种好处,能够显著提高性能。具体来说,在合并 DataFrame 时,使用索引能够带来性能上的提升。与基于列的合并相比,基于索引的合并通常更快,因为 pandas 可以使用优化的基于索引的连接方法来执行合并操作,这比基于列的合并更高效。让我们回顾一下员工示例来证明这一概念。此示例的完整代码可以在github.com/PacktPublishing/Python-Data-Cleaning-and-Preparation-Best-Practices/blob/main/chapter05/8a.perfomance_benchmark_set_index.py
中找到。
首先,让我们导入必要的库:
import pandas as pd
import numpy as np
from time import time
为每个 DataFrame 选择基准示例的行数:
num_rows = 5
让我们创建示例所需的 DataFrame,这些 DataFrame 的行数将由 num_rows
变量定义。这里定义了第一个员工 DataFrame:
employee_data_1 = pd.DataFrame({
'employee_id': np.arange(num_rows),
'name': ['Alice', 'Bob', 'Charlie', 'David', 'Eva'],
'department': ['HR', 'IT', 'Marketing', 'Finance', 'IT'],
'salary': [50000, 60000, 70000, 80000, 90000]
})
第二个 DataFrame 如下所示:
employee_data_2 = pd.DataFrame({
'employee_id': np.arange(num_rows),
'name': ['Alice', 'Bob', 'Charlie', 'David', 'Eva'],
'department': ['HR', 'IT', 'Sales', 'Finance', 'Operations'],
'bonus': [3000, 4000, 5000, 6000, 7000]
})
为了展示我们所应用的性能技巧的效果,我们最初将执行不利用索引的合并操作。我们将计算此操作所需的时间。接着,我们会在两个 DataFrame 中设置索引并重新执行合并操作,重新计算时间。最后,我们将展示结果。希望这个方法能产生预期的效果!开始计时:
start_time = time()
让我们在不使用索引的情况下执行合并操作,只通过['employee_id', 'name']
进行内连接:
merged_data = pd.merge(employee_data_1, employee_data_2, on=['employee_id', 'name'], how='inner', suffixes=('_1', '_2'))
让我们计算执行合并所花费的时间:
end_time = time()
merge_time = end_time - start_time
Merge operation took around 0.00289 seconds
注意
执行程序的计算机可能会导致时间有所不同。这个想法是,优化后的版本比原始合并操作所需的时间更短。
通过将employee_id
作为两个 DataFrame(employee_data_1
和employee_data_2
)的索引,我们让 pandas 使用基于索引的优化连接方法。这尤其有效,因为 pandas 中的索引是通过哈希表或 B 树实现的,具体取决于数据类型和索引的排序性,这有助于加速查找:
employee_data_1.set_index('employee_id', inplace=True)
employee_data_2.set_index('employee_id', inplace=True)
在设置索引后,我们再执行一次合并操作,并重新计算时间:
start_time = time()
merged_data_reduced = pd.merge(employee_data_1, employee_data_2, left_index=True, right_index=True, suffixes=('_1', '_2'))
end_time = time()
merge_reduced_time = end_time - start_time
Merge operation with reduced memory took around 0.00036 seconds
现在,如果我们计算从初始时间到最终时间的百分比差异,我们发现仅仅通过设置索引,我们就将时间缩短了约 88.5%。这看起来很令人印象深刻,但我们也需要讨论一些设置索引时的注意事项。
索引注意事项
选择合适的列进行索引设置非常重要,应基于查询模式。过度索引可能导致不必要的磁盘空间占用,并且由于维护索引的开销,可能会降低写操作性能。
重建或重组索引对于优化性能至关重要。这些任务解决了索引碎片问题,并确保随着时间推移性能的一致性。
虽然索引可以显著提高读取性能,但它们也可能影响写入性能。在优化读取操作(如搜索和连接)与保持高效的写入操作(如插入和更新)之间找到平衡至关重要。
多列索引或连接索引在多个字段经常一起用于查询时可能是有益的。然而,索引定义中字段的顺序非常重要,应反映出最常见的查询模式。
在证明了设置索引的重要性后,我们进一步讨论在合并前对索引进行排序的选项。
排序索引
在 pandas 中排序索引在你经常对大规模 DataFrame 进行合并或连接操作的场景中尤其有利。当索引被排序时,pandas 可以利用更高效的算法来对齐和连接数据,这可能会显著提升性能。让我们在继续代码示例之前深入探讨这一点:
-
当索引已排序时,pandas 可以使用二分查找算法来定位 DataFrame 之间的匹配行。二分查找的时间复杂度是 O(log n),这比未排序索引所需的线性查找要快得多,特别是当 DataFrame 的大小增加时。
-
排序索引有助于更快地对齐数据。这是因为 pandas 可以对数据的顺序做出一些假设,从而简化在合并时查找每个 DataFrame 中对应行的过程。
-
使用排序后的索引,pandas 可以避免进行不必要的比较,这些比较是当索引未排序时所必需的。这样可以减少计算开销,加速合并过程。
让我们回到代码示例,加入索引排序的步骤。原始数据保持不变;但是在本实验中,我们比较的是在设置索引后执行合并操作的时间与在设置并排序索引后执行合并操作的时间。以下代码展示了主要的代码组件,但和往常一样,你可以通过 github.com/PacktPublishing/Python-Data-Cleaning-and-Preparation-Best-Practices/blob/main/chapter05/8b.performance_benchmark_sort_indexes.py
跟进完整示例:
employee_data_1.set_index('employee_id', inplace=True)
employee_data_2.set_index('employee_id', inplace=True)
让我们在不排序索引的情况下执行合并操作:
merged_data = pd.merge(employee_data_1, employee_data_2, left_index=True, right_index=True, suffixes=('_1', '_2'))
Merge operation with setting index took around 0.00036 seconds
让我们在排序索引后重复合并操作,并再次计算时间:
employee_data_1.sort_index(inplace=True)
employee_data_2.sort_index(inplace=True)
merged_data_reduced = pd.merge(employee_data_1, employee_data_2, left_index=True, right_index=True, suffixes=('_1', '_2'))
Merge operation after sorting took around 0.00028 seconds.
现在,如果我们计算从初始时间到最终时间的百分比差异,我们会发现通过排序索引,我们成功地将时间减少了大约 ~22%。这看起来很不错,但我们也需要讨论设置索引时的一些注意事项。
排序索引的注意事项
排序 DataFrame 的索引并不是没有计算成本的。初始的排序操作本身需要时间,因此当排序后的 DataFrame 在多个合并或连接操作中被使用时,这种做法最为有利,可以通过这些操作摊销排序的成本。
排序有时会增加内存开销,因为 pandas 可能会创建 DataFrame 索引的排序副本。在处理非常大的数据集时,若内存是一个限制因素,应该考虑这一点。
排序索引最有利的情况是,合并所用的键不仅是唯一的,而且具有一定的逻辑顺序,例如时间序列数据或有序的分类数据。
索引管理和维护是你在处理 pandas DataFrame 时需要考虑的关键因素,尤其是在处理大型数据集时。维护一个良好的索引需要谨慎考虑。例如,定期更新或重新索引 DataFrame 可能会引入计算成本,类似于排序操作。每次修改索引(通过排序、重新索引或重置)时,可能会导致额外的内存使用和处理时间,尤其是在大型数据集上。
索引需要以平衡性能和资源使用的方式进行维护。例如,如果你经常合并或连接 DataFrame,确保索引已正确排序并且是唯一的,可以显著加速这些操作。然而,持续维护一个已排序的索引可能会消耗大量资源,因此当 DataFrame 需要进行多次操作并利用已排序的索引时,这样做最为有利。
此外,选择合适的索引类型——无论是基于整数的简单索引、用于时间序列数据的日期时间索引,还是用于层次数据的多级索引——都可能影响 pandas 处理数据的效率。索引的选择应与数据的结构和访问模式相匹配,以最小化不必要的开销。
在接下来的部分,我们将讨论使用 join
函数而非 merge
如何影响性能。
合并与连接
虽然合并是根据特定条件或键来合并数据集的常用方法,但还有另一种方法:join
函数。这个函数提供了一种简化的方式,主要通过索引执行合并,为更通用的合并函数提供了一个更简单的替代方案。当涉及的 DataFrame 已经将索引设置为用于连接的键时,pandas 中的 join
方法特别有用,它能够高效、直接地进行数据组合,而无需指定复杂的连接条件。
使用 join
函数代替 merge
可能会以多种方式影响性能,主要是因为这两个函数的底层机制和默认行为:
-
pandas 中的
join
函数针对基于索引的连接进行了优化,意味着它在通过索引连接 DataFrame 时被设计得更为高效。如果你的 DataFrame 已经按你想要连接的键进行了索引,那么使用join
可以更高效,因为它利用了优化过的索引结构[2][6][7]。 -
Join 是 merge 的简化版本,默认按索引进行连接。这种简化可能带来性能上的优势,尤其是对于那些连接任务简单、合并复杂性不必要的场景。通过避免对非索引列的对齐开销,在这些情况下,join 可以更快速地执行[2][6]。
-
从底层实现来看,join 使用的是 merge[2][6]。
-
在连接大型 DataFrame 时,join 和 merge 处理内存的方式会影响性能。通过专注于基于索引的连接,join 可能在某些场景下更高效地管理内存使用,尤其是当 DataFrame 具有 pandas 可优化的索引时 [1][3][4]。
-
虽然 merge 提供了更大的灵活性,允许在任意列上进行连接,但这种灵活性带来了性能上的代价,尤其是在涉及多个列或非索引连接的复杂连接时。由于其更具体的使用场景,join 在较简单的基于索引的连接中具有性能优势 [2][6]。
总结来说,选择 join
还是 merge
取决于任务的具体需求。如果连接操作主要基于索引,join 可以因其针对基于索引的连接进行优化而提供性能优势,且其接口更为简洁。然而,对于涉及特定列或多个键的更复杂连接需求,merge 提供了必要的灵活性,尽管这可能会对性能产生影响。
连接 DataFrame
当你有多个结构相似(列相同或行相同)的 DataFrame,且想将它们合并成一个 DataFrame 时,连接操作非常适用。连接过程可以沿特定轴进行,按行(axis=0
)或按列(axis=1
)连接。
让我们深入了解按行连接,也称为附加(append)。
按行连接
按行连接用于沿 axis=0
将一个 DataFrame 连接到另一个 DataFrame。为了展示这个操作,可以看到两个结构相同但数据不同的 DataFrame,employee_data_1
和 employee_data_2
:
employee_data_1 = pd.DataFrame({
'employee_id': np.arange(1, 6),
'name': ['Alice', 'Bob', 'Charlie', 'David', 'Eva'],
'department': ['HR', 'IT', 'Marketing', 'Finance', 'IT']
})
employee_data_2 = pd.DataFrame({
'employee_id': np.arange(6, 11),
'name': ['Frank', 'Grace', 'Hannah', 'Ian', 'Jill'],
'department': ['Logistics', 'HR', 'IT', 'Marketing', 'Finance']
})
让我们执行按行连接,如以下代码片段所示:
concatenated_data = pd.concat([employee_data_1, employee_data_2], axis=0)
pd.concat()
函数用于连接两个 DataFrame。第一个参数是要连接的 DataFrame 列表,axis=0
参数指定连接应按行进行,将 DataFrame 堆叠在一起。
结果可以在这里看到:
employee_id name department
0 1 Alice HR
1 2 Bob IT
2 3 Charlie Marketing
3 4 David Finance
4 5 Eva IT
0 6 Frank Logistics
1 7 Grace HR
2 8 Hannah IT
3 9 Ian Marketing
4 10 Jill Finance
执行按行连接时,需要考虑的几点如下:
-
确保你要连接的列对齐正确。pandas 会自动按列名对齐列,并用
NaN
填充任何缺失的列。 -
连接后,你可能希望重置结果 DataFrame 的索引,以避免重复的索引值,尤其是当原始 DataFrame 各自有自己的索引范围时。请在执行
reset
操作之前,观察以下示例中的索引:employee_id name department 0 1 Alice HR 1 2 Bob IT 2 3 Charlie Marketing 3 4 David Finance 4 5 Eva IT 0 6 Frank Logistics 1 7 Grace HR 2 8 Hannah IT 3 9 Ian Marketing 4 10 Jill Finance concatenated_data_reset = concatenated_data.reset_index(drop=True)
让我们再次查看输出:
employee_id name department 0 1 Alice HR 1 2 Bob IT 2 3 Charlie Marketing 3 4 David Finance 4 5 Eva IT 5 6 Frank Logistics 6 7 Grace HR 7 8 Hannah IT 8 9 Ian Marketing 9 10 Jill Finance
重置索引会为连接后的数据框创建一个新的连续索引。使用
drop=True
参数可以避免将旧索引作为列添加到新数据框中。这个步骤对于保持数据框的整洁至关重要,特别是当索引本身不携带有意义的数据时。一个连续的索引通常更容易操作,尤其是在索引、切片以及未来的合并或连接操作中。 -
连接操作可能会增加程序的内存使用,特别是当处理大型数据框时。需要注意可用的内存资源。
在下一节中,我们将讨论按列连接。
按列连接
在 pandas 中,按列连接数据框涉及将两个或更多的数据框并排组合,通过索引对齐它们。为了展示这个操作,我们将使用之前的两个数据框,employee_data_1
和employee_data_2
,操作可以像这样进行:
concatenated_data = pd.concat([employee_data_1, employee_performance], axis=1)
pd.concat()
函数与axis=1
参数一起使用,用于并排连接数据框。这通过索引对齐数据框,有效地将employee_performance
中的新列添加到employee_data_1
中。输出将显示如下:
employee_id name department employee_id performance_rating
0 1 Alice HR 1 3
1 2 Bob IT 2 4
2 3 Charlie Marketing 3 5
3 4 David Finance 4 3
4 5 Eva IT 5 4
在进行按列连接时,你需要考虑的几个事项如下:
-
要连接的数据框的索引会被正确对齐。在按列连接数据框时,结果数据框中的每一行应理想地代表来自同一实体的数据(例如,同一员工)。索引未对齐可能导致来自不同实体的数据被错误地组合,从而产生不准确和误导性的结果。例如,如果索引表示员工 ID,未对齐可能导致某个员工的详细信息与另一个员工的表现数据错误地配对。
-
如果数据框中包含相同名称的列,但这些列打算是不同的,考虑在连接之前重命名这些列,以避免在结果数据框中产生混淆或错误。
-
虽然按列连接通常不像按行连接那样显著增加内存使用,但仍然需要监控内存使用,尤其是对于大型数据框。
连接与连接操作的比较
连接主要用于沿轴(行或列)组合数据框,而不考虑其中的值。它适用于那些你只是想根据顺序将数据框堆叠在一起或通过附加列扩展它们的情况。
连接用于根据一个或多个键(每个数据框中的公共标识符)组合数据框。这更多是基于共享数据点合并数据集,允许更复杂和有条件的数据组合。
在探讨了 pandas 中拼接操作的细节之后,包括它在对齐索引方面的重要性,以及它与连接操作的对比,我们现在总结讨论的关键点,概括我们的理解,并突出我们在探索 pandas 中 DataFrame 操作时的关键收获。
总结
在本章中,我们探讨了 pandas 中 DataFrame 操作的各个方面,重点讨论了拼接、合并以及索引管理的重要性。
我们讨论了合并操作,它适用于基于共享键的复杂组合,并通过内连接、外连接、左连接和右连接等多种连接类型提供灵活性。我们还讨论了如何使用拼接操作在特定轴上(按行或按列)合并 DataFrame,这对于追加数据集或为数据添加新维度尤其有用。我们还讨论了这些操作的性能影响,强调了正确的索引管理可以显著提升这些操作的效率,特别是在处理大数据集时。
在接下来的章节中,我们将深入探讨如何利用groupby
函数与各种聚合函数结合,从复杂的数据结构中提取有意义的洞察。
参考资料
-
stackoverflow.com/questions/40860457/improve-pandas-merge-performance
-
pandas.pydata.org/pandas-docs/version/1.5.1/user_guide/merging.html
第六章:数据分组、聚合、过滤和应用函数
数据分组和聚合是数据清理和预处理中的基础技术,具有多个关键用途。首先,它们能够对大规模数据集进行总结,将庞大的原始数据转化为简洁、有意义的汇总,方便分析和洞察的提取。此外,聚合有助于处理缺失或噪声数据,通过平滑不一致性并填补数据空白。这些技术还帮助减少数据量,提高处理效率,并为进一步的分析或机器学习模型创建有价值的特征。
数据分组和聚合的主要组成部分包括分组键,它定义了数据的分段方式;聚合函数,它执行诸如求和、平均、计数等操作;以及输出列,它显示分组键和聚合后的值。
在本章中,我们将涵盖以下主要内容:
-
使用一个或多个键进行数据分组
-
对分组数据应用聚合函数
-
对分组数据应用函数
-
数据过滤
技术要求
你可以在以下 GitHub 仓库中找到本章的代码:github.com/PacktPublishing/Python-Data-Cleaning-and-Preparation-Best-Practices/tree/main/chapter06
。
使用一个或多个键进行数据分组
在 pandas 中,数据分组是一项基础操作,它涉及根据一个或多个键将数据拆分为多个组,然后在每个组内执行操作。分组常用于数据分析,以便对数据子集进行汇总计算并获得洞察。让我们更深入地探讨数据分组,并通过示例来说明它们的使用。本节的代码可以在这里找到:github.com/PacktPublishing/Python-Data-Cleaning-and-Preparation-Best-Practices/blob/main/chapter06/2.groupby_full_example.py
。
使用一个键进行数据分组
使用一个键进行数据分组是数据分析中的常见操作。
要使用一个键对数据进行分组,我们可以使用 DataFrame 的groupby()
方法,并指定我们希望作为分组键的列:
grouped = df.groupby('column_name')
在分组之后,你通常需要执行一些聚合操作。常见的聚合函数包括:
-
grouped.sum()
:这会计算所有数值列的总和 -
grouped.mean()
:这会计算平均值(算术平均) -
grouped.count()
:这会统计非空值的数量 -
grouped.agg(['sum', 'mean', 'count'])
:这会同时应用多个聚合函数:sum
、mean
和count
让我们展示一个常见的应用案例,来应用我们的学习成果。假设我们为一家电子零售公司工作,需要分析不同产品的销售数据。以下是数据的一个样本:
Category Sub-Category Region Sales Date
0 Electronics Mobile North 200 2023-01-01
1 Electronics Laptop South 300 2023-01-02
2 Electronics Tablet East 250 2023-01-03
3 Electronics Laptop West 400 2023-01-04
4 Furniture Chair North 150 2023-01-05
5 Furniture Table South 350 2023-01-06
在数据分析中,某些列由于其类别性质,通常是进行分组的候选列。这些列通常表示类别、分类或时间相关的分段,适合围绕这些进行数据聚合:
-
类别列:表示数据中不同组别或类型的列。例如,产品类别、用户类型或服务类型。这些列有助于理解每个组的表现或行为。
-
地理列:表示地理区域的列,例如国家、地区、城市或商店位置。这些对于区域表现分析很有用。
-
时间列:表示与时间相关的信息的列,例如年份、季度、月份、周或天。按这些列进行分组有助于进行趋势分析。
-
人口统计列:描述人口统计属性的列,例如年龄段、性别或收入水平。这些列对于根据人口特征进行数据细分非常有用。
-
交易相关列:与交易性质相关的列,例如交易类型、支付方式或订单状态。这些列有助于理解交易数据的不同方面。
根据我们示例中的数据,适合进行分组的列包括类别
、子类别
和地区
。如果我们每天有多个记录,并且想计算每日销售量,那么日期
也可以作为一个候选列。在我们的例子中,经理要求我们报告每个类别的总销售量(销售额)。让我们看看如何计算这个:
category_sales = df.groupby('Category')['Sales'].sum().reset_index()
在这个代码示例中,我们按类别
列对数据进行分组,对每个类别的销售
列求和,并重置索引。结果的 DataFrame 如下所示:
Category Sales
0 Clothing 1070
1 Electronics 1370
2 Furniture 1270
现在我们已经看到了如何按单个键进行分组,让我们通过按类别
和地区
分组来增加一些复杂性。
使用多个键对数据进行分组
按多个键进行分组可以更细致、详细地检查数据。这种方法有助于发现仅使用单一键时可能隐藏的见解,从而更深入地理解数据集中的关系和模式。在我们的示例中,按地区
和类别
进行分组,不仅可以看到整体的销售表现,还能看到不同类别在每个地区的表现。这有助于识别哪些产品在特定地区受欢迎,从而根据地区特征调整营销策略。
要使用多个键对数据进行分组,我们将列名列表传递给groupby()
方法。Pandas 将根据这些列的唯一组合来创建组:
category_region_sales = df.groupby(['Category', 'Region'])['Sales'].sum().reset_index()
在这段代码中,我们按Category
和Region
列对数据进行分组,然后通过对每个组的Sales
列求和来执行聚合。最后,我们重置索引。让我们看看这次操作的输出:
Category Region Sales
0 Clothing East 420
1 Clothing North 100
2 Clothing South 250
3 Clothing West 300
4 Electronics East 250
5 Electronics North 420
6 Electronics South 300
7 Electronics West 400
8 Furniture East 200
9 Furniture North 150
10 Furniture South 350
11 Furniture West 570
只需一行代码,我们就能汇总并展示每个Category
和Region
值的所有销售数据,使我们的经理非常满意。现在,让我们看看在使用 groupby 语句时的一些最佳实践。
分组的最佳实践
在 pandas 中进行数据分组时,需要考虑几件事,以确保结果准确:
-
缺失数据:要注意用于分组的列中是否存在缺失数据。Pandas 会排除包含缺失数据的行,这可能会影响最终的计算结果。
-
MultiIndex
:当按多个列分组时,pandas 会返回一个层次索引(MultiIndex
)。在使用MultiIndex
时要熟悉,并考虑在需要时重置索引,就像我们为了简化所做的那样。 -
运算顺序:执行分组和聚合的顺序可能会影响结果。请注意应用分组和聚合函数的顺序。
-
分组大数据集:对于大型数据集,分组可能会占用大量内存。考虑使用分块处理或并行处理等技术来管理内存使用和计算时间。
我们的管理团队看到了我们执行的 groupby 操作的效率,他们要求我们提供更详细的销售总结!通过设置多个键,我们可以通过对Sales
列应用多个聚合函数,进一步增强我们的分析。这将为我们提供更详细的数据总结。
对分组数据应用聚合函数
在 pandas 中,使用groupby()
方法对数据进行分组后,可以应用聚合函数对分组数据执行计算。聚合函数用于总结或计算每个组的统计信息,结果是一个新的 DataFrame 或 Series。让我们更深入地探讨如何在分组数据上应用聚合函数,并提供一些示例来说明其用法。
基本聚合函数
我们在第一部分中已经介绍了基本的聚合函数,因为没有聚合函数就无法执行 groupby。在本节中,我们将进一步探讨每个函数的作用,以及何时使用每个函数,首先展示以下表格中的所有可用函数:
聚合 函数 | 描述 | 使用时机 | 代码示例 |
---|---|---|---|
sum | 对组中的所有值求和 | 当你需要每个组的总值时。示例:按类别计算总销售额。 | df.groupby('Category')['Sales'].sum() |
mean | 计算组中值的平均数 | 当你需要每个组的平均值时。示例:按区域计算平均销售额。 | df.groupby('Category')['Sales'].mean() |
count | 计算组中非空值的数量 | 当你需要知道每个组中出现次数时。示例:每个子类别的销售交易次数。 | df.groupby('Category')['Sales'].count() |
min | 查找组中的最小值 | 当你需要每个组中的最小值时。示例:每个地区的最小销售值。 | df.groupby('Category')['Sales'].min() |
聚合 函数 | 描述 | 何时 使用 | 代码示例 |
max | 查找组中的最大值 | 当你需要每个组中的最大值时。示例:每个类别的最大销售值。 | df.groupby('Category')['Sales'].max() |
median | 查找组中的中位数值 | 当你需要一个排序数字列表中的中间值时。示例:每个类别的中位销售值。 | df.groupby('Category')['Sales'].median() |
std (标准差) | 衡量组中数值的分布 | 当你需要了解数值的变化时。示例:每个地区的销售标准差。 | df.groupby('Category')['Sales'].std() |
表 6.1 – 基本聚合函数的汇总表
你可以逐个调用这些函数,也可以将它们一起调用,例如:
total_sales = df.groupby('Category')['Sales'].sum().reset_index()
这计算了每个类别的销售数量,正如我们所学的,如果这是你从数据集中提取的唯一聚合信息,那么这已经足够了。然而,如果你被要求为不同的产品类别生成多个销售聚合,一个更高效的方法是一次性执行所有的聚合:
category_region_sales_agg = df.groupby(['Category', 'Region'])['Sales'].agg(['sum', 'mean']).reset_index()
在这段代码中,我们对 Sales
列应用了多个聚合函数(sum
和 mean
)。结果如下:
Category Region sum mean
0 Clothing East 420 210.0
1 Clothing North 100 100.0
2 Clothing South 250 250.0
3 Clothing West 300 300.0
4 Electronics East 250 250.0
5 Electronics North 420 210.0
6 Electronics South 300 300.0
7 Electronics West 400 400.0
8 Furniture East 200 200.0
9 Furniture North 150 150.0
10 Furniture South 350 350.0
11 Furniture West 570 285.0
注意
我们可以在分组子句中添加任意数量的聚合。
我们在计算管理团队要求的各种指标时非常高效,结果是他们现在热衷于理解每个地区和类别的销售指标以及唯一子类别的销售数量。接下来我们来做这个。
使用多个列的高级聚合
为了了解每个地区和类别的销售指标以及每个子类别的唯一销售数量,我们可以对额外的列进行分组,并对 Sales
和 Subcategory
列应用多个聚合:
advanced_agg = df.groupby(['Category', 'Region']).agg({
'Sales': ['sum', 'mean', 'count'],
'Sub-Category': 'nunique' # Unique count of Sub-Category
}).reset_index()
在这段代码中,我们通过 Category
和 Region
对 DataFrame 进行分组,并执行了几个聚合操作:
-
'Sales': ['sum', 'mean', 'count']
计算每个组的总销售额、平均销售额和交易次数(行数)。 -
'Sub-Category': 'nunique'
计算每个Category
和Region
组内唯一子类别的数量。
这里展示的是汇总结果:
Category Region Sales Sub-Category
sum mean count nunique
0 Clothing East 420 210.0 2 2
1 Clothing North 100 100.0 1 1
2 Clothing South 250 250.0 1 1
3 Clothing West 300 300.0 1 1
4 Electronics East 250 250.0 1 1
5 Electronics North 420 210.0 2 1
6 Electronics South 300 300.0 1 1
现在,你可能会想,我们通过这些计算学到了什么?让我来回答这个问题!我们计算了总销售额、平均销售额和交易次数,以了解不同类别-地区组合的财务表现。此外,Sub-Category
的唯一计数揭示了我们产品分销策略的关键方面。此分析有多个目的:它为每个类别-地区细分内产品的多样性提供了洞察。例如,在我们的数据背景下,了解在不同类别下,每个地区销售的独特产品(子类别)数量,有助于了解市场细分和产品组合策略。它还帮助评估市场渗透率,通过突出显示提供更多产品的地区,支持产品组合管理的战略决策,包括扩展和针对区域偏好的库存策略。
标准的聚合函数,如求和、平均值和计数,提供了基本统计信息。然而,自定义函数使你能够计算那些特定于你业务需求或分析目标的指标。例如,计算销售数据的范围或变异系数,可以揭示不同组内销售的分布和变异性。如你所见,我们被要求实现这些自定义指标,接下来我们将进行此操作。
应用自定义聚合函数
当聚合需要复杂的计算,超出简单统计时,自定义函数非常有价值。你可以在需要计算那些独特于你分析目标或业务背景的指标时使用它们。例如,在销售分析中,你可能希望计算利润率、客户生命周期价值或流失率,这些通常不是通过标准聚合函数能够获得的。
让我们回到示例中,构建我们被要求计算的指标:对于每个地区,我们要计算销售范围和销售变异性。让我们看看下面的代码:
-
我们创建一个计算销售范围(最大值与最小值的差)的函数:
def range_sales(series): return series.max() - series.min()
-
然后,我们创建一个计算销售变异系数的函数,它衡量相对于均值的相对变异性:
def coefficient_of_variation(series): return series.std() / series.mean()
-
df
数据框随后按Region
分组:advanced_agg_custom = df.groupby('Region').agg({ 'Sales': ['sum', 'mean', 'count', range_sales, coefficient_of_variation], 'Sub-Category': 'nunique' }).reset_index()
Sales: ['sum', 'mean', 'count', range_sales, coefficient_of_variation]
使用自定义函数计算总销售额、平均销售额、交易次数、销售范围和变异系数。'Sub-Category':'nunique'
计算每个组内独特子类别的数量。然后,我们重置索引以扁平化df
数据框,使其更易于处理。 -
最后,我们重命名聚合后的列,以便输出更加清晰和易于阅读:
advanced_agg_custom.columns = [ 'Region', 'Total Sales', 'Average Sales', 'Number of Transactions', 'Sales Range', 'Coefficient of Variation', 'Unique Sub-Categories' ]
-
让我们打印最终的数据框:
print(advanced_agg_custom)
最终的数据框在这里呈现:
Region TotalSales SalesRange Coef Unique Sub-Categories
0 East 870 120 0.24 4
1 North 670 120 0.32 3
2 South 900 100 0.16 3
3 West 1270 230 0.34 4
让我们花点时间了解一下各个区域的销售波动性。每个区域内销售额的范围可以揭示差异或区别,即最高和最低销售额之间的差距。例如,较大的范围可能表明不同区域间消费者需求或销售表现的显著差异。变异系数有助于将销售波动性相对于其平均值进行标准化。较高的变异系数表明更大的相对波动性,这可能促使进一步调查影响销售波动的因素。
注意
我希望你能清楚地理解,只要一个函数能够从输入的值序列中计算出单一的聚合结果,你就可以将它作为自定义聚合函数来构建。该函数还应返回一个单一的标量值,这是该组聚合的结果。
现在,让我们来看一下在使用聚合函数时的一些最佳实践。
聚合函数的最佳实践
在使用 Pandas 中的聚合函数时,需要考虑一些事项,以确保结果的准确性:
-
编写高效的自定义函数,尽量减少计算开销,特别是在处理大型数据集时。避免不必要的循环或操作,这些操作可能会减慢处理时间。
-
清楚地记录自定义聚合函数的逻辑和目的。这有助于在团队或组织内部维护和共享代码,确保分析的透明性和可重复性。
-
通过将结果与已知基准或手动计算进行比较,验证自定义聚合函数的准确性。此步骤对于确保自定义指标的可靠性和正确实现至关重要。
在 Pandas 中,使用 .agg()
方法与 groupby
时,你定义的聚合函数理想情况下应该为每个操作的列返回单一的标量值。然而,在某些情况下,你可能希望返回多个值或执行更复杂的操作。虽然 Pandas 的 .agg()
方法期望返回标量值,但你可以通过使用返回元组或列表的自定义函数来实现更复杂的聚合。然而,这需要谨慎处理,并且在 Pandas 的原生聚合框架中通常并不简单。对于需要返回多个值或执行复杂计算的更复杂场景,我们可以使用 apply()
替代 agg()
,它更灵活,正如我们将在下一节中看到的。
在分组数据上使用 apply 函数
Pandas 中的 apply()
函数是一个强大的方法,用于沿着 DataFrame 或 Series 的轴应用自定义函数。它非常灵活,可以用于各种场景,以根据自定义逻辑操作数据、计算复杂的聚合或转换数据。apply()
函数可以用于以下操作:
-
按行或按列应用函数
-
当与
groupby()
配合使用时,将函数应用于数据组
在接下来的章节中,我们将重点讨论如何在数据分组后使用apply
函数,首先按我们想要的列进行分组,然后执行apply
操作。
注意
使用不带groupby
的apply
函数,可以直接对 DataFrame 的行或列应用函数。这在你需要执行不需要分组数据的行或列级别的操作时非常有用。应用相同的学习,只需跳过groupby
子句。
在使用 pandas 的apply
函数时,axis=0
(默认)将函数应用于每一列,而axis=1
则将其应用于每一行。我们来深入了解一下这一点。
axis=0
将函数应用于行。换句话说,它独立处理每一列。当你想按列汇总数据(例如,对每列的值求和)时,通常会使用此方法,如下图所示:
图 6.1 – Apply()与 axis=0
如果我们回到我们的用例,管理团队希望了解每个类别中产品的实际销售数量,而不仅仅是销售总额。我们的示例变得越来越复杂,因此,用apply()
实现这个功能是个好主意。我们来看一个代码示例,代码也可以在这里找到:github.com/PacktPublishing/Python-Data-Cleaning-and-Preparation-Best-Practices/blob/main/chapter06/3.apply_axis0.py
。
-
让我们扩展我们的 DataFrame,添加
Quantity
列:data = { 'Category': ['Electronics', 'Electronics', 'Furniture', 'Furniture', 'Clothing', 'Clothing'], 'Sub-Category': ['Mobile', 'Laptop', 'Chair', 'Table', 'Men', 'Women'], 'Sales': [100, 200, 150, 300, 120, 180], 'Quantity': [10, 5, 8, 3, 15, 12], 'Date': ['2023-01-01', '2023-01-02', '2023-01-03', '2023-01-04', '2023-01-05', '2023-01-06'] } df = pd.DataFrame(data)
-
然后我们将
Date
列转换为日期时间格式:df['Date'] = pd.to_datetime(df['Date'])
-
现在,让我们定义一个自定义函数来计算
Sales
和Quantity
的多个统计量:def compute_statistics(series): sum_sales = series['Sales'].sum() mean_sales = series['Sales'].mean() std_sales = series['Sales'].std() cv_sales = std_sales / mean_sales sum_quantity = series['Quantity'].sum() mean_quantity = series['Quantity'].mean() std_quantity = series['Quantity'].std() cv_quantity = std_quantity / mean_quantity return pd.Series([sum_sales, mean_sales, std_sales, cv_sales, sum_quantity, mean_quantity, std_quantity, cv_quantity], index=['Sum_Sales', 'Mean_Sales', 'Std_Sales', 'CV_Sales', 'Sum_Quantity', 'Mean_Quantity', 'Std_Quantity', 'CV_Quantity'])
这个自定义函数(
compute_statistics
)现在计算了在每个由Category
定义的组内,Sales
和Quantity
列的多个统计量(总和、均值、标准差、变异系数)。对于每个类别组(系列),它计算以下内容:-
Sum_Sales
:销售总和 -
Mean_Sales
:销售的均值 -
Std_Sales
:销售的标准差 -
CV_Sales
:Sum_Quantity
:数量的总和 -
Mean_Quantity
:数量的均值 -
Std_Quantity
:数量的标准差 -
CV_Quantity
:数量的变异系数
最终,它返回一个包含这些计算统计量的 pandas Series,并适当地进行索引。
-
-
接下来,我们将在
Category
上执行groupby
操作,并应用我们自定义的函数来计算Sales
和Quantity
的统计量:result_complex = df.groupby('Category').apply(compute_statistics).reset_index()
我们将
apply()
与groupby('Category')
结合使用,将compute_statistics
函数应用于由Category
列定义的每组销售数据。该函数作用于整个组(系列),允许同时计算Sales
和Quantity
列的统计数据。最后,使用reset_index()
将结果 DataFrame 展平,提供按类别划分的两个列的统计数据结构化输出。我们来看一下最终的 DataFrame:
通过按Category
对数据进行分组,我们可以在类别层面分析销售和数量指标,这有助于我们理解不同类型的产品(电子产品、家具、服装)在销售和数量方面的表现。正如我们从呈现的结果中看到的,Furniture
(家具)是主要的收入来源,因为它具有最高的Sum_Sales
和Mean_Sales
,这表明该类别包含受欢迎或高价值的产品。具有较低CV_Sales
和CV_Quantity
值的类别,如Clothing
(服装),在销售和数量上更为稳定,表明需求稳定或销售模式可预测,而具有较高变动性的类别(Std_Sales
和Std_Quantity
)可能表示销售波动或季节性需求。
这在数据分析方面非常有用,但现在,我们需要做出一些与产品组合、定价策略和市场营销措施相关的战略决策。在这一点上,让我们更加富有创意:
-
高
Sum_Sales
值且指标稳定(CV_Sales
,CV_Quantity
)的类别是扩展产品线或投资市场营销的最佳候选者 -
高变动性的类别(
Std_Sales
,Std_Quantity
)可能需要动态定价策略或季节性促销来优化销售 -
我们可以使用
Mean_Sales
和Mean_Quantity
的值来识别具有增长潜力的类别
在使用 pandas 中的apply()
函数时,如果没有指定 axis 参数,默认行为是axis=0
。这意味着该函数将应用于每一列(即,它将独立处理每一列)。这就是我们在之前示例代码中所采用的方法。根据你的具体使用情况,可以调整apply()
来按行(axis=1
)或按列(axis=0
)操作。接下来,让我们关注axis=1
。
axis=1
沿列应用函数,因此它独立处理每一行。这通常用于你想要执行按行操作时(例如,为每一行计算自定义指标)。
图 6.2 – Apply() 使用 axis=1
按行应用函数允许进行行级转换和计算。让我们通过 axis=1
来查看一个代码示例。我们先定义一个要跨列(axis=1
)应用的函数。代码可以在这里找到:github.com/PacktPublishing/Python-Data-Cleaning-and-Preparation-Best-Practices/blob/main/chapter06/4.apply_axis1.py
:
-
row_summary
函数将 DataFrame 的单行作为输入,并返回该行数据的汇总。该函数的输入是关键,理解这一点是至关重要的,它是作为 pandas Series 传入的 DataFrame 单行:def row_summary(row): total_sales_quantity = row['Sales'] + row['Quantity'] sales_quantity_ratio = row['Sales'] / row['Quantity'] if row['Quantity'] != 0 else np.nan return pd.Series( [total_sales_quantity,sales_quantity_ratio], index=['Total_Sales_Quantity', 'Sales_Quantity_Ratio'])
total_sales_quantity
变量将存储该行的Sales
和Quantity
的总和。sales_quantity_ratio
变量将存储该行的Sales
与Quantity
的比率,如果数量为零,则为np.nan
,以提供销售效率的洞察。 -
我们按行应用函数(
axis=1
):df_row_summary = df.apply(row_summary, axis=1) Total_Sales_Quantity Sales_Quantity_Ratio 0 110.0 10.00 1 205.0 40.00 2 158.0 18.75 3 303.0 100.00 4 135.0 8.00 5 192.0 15.0
这将生成一个新的
df_row_summary
DataFrame,其中每一行对应于原始df
中每行的total_sales_quantity
和sales_quantity_ratio
计算值。 -
最后,我们按
Category
分组,以计算每个类别的指标:category_metrics = df.groupby('Category')[['Total_Sales_Quantity', 'Sales_Quantity_Ratio']].mean().reset_index()
让我们看看最终结果:
Category Total_Sales_Quantity Sales_Quantity_Ratio
0 Clothing 163.5 11.500
1 Electronics 157.5 25.000
2 Furniture 230.5 59.375
total_sales_quantity
指标提供了一个简单但有效的衡量标准,帮助我们了解每笔交易的总体销售表现,理解销售数量(Quantity
)和销售价值(Sales
)的综合影响。通过分析 total_sales_quantity
,我们可以识别出销售和数量都较高的交易,这可能表示受欢迎的产品类别或成功的销售策略。相反,它也有助于识别表现不佳的交易,从而指导库存管理和促销调整,以提高销售效率和产品表现。这种双重洞察有助于战略决策,以优化销售和库存管理。
sales_quantity_ratio
指标提供了每单位数量的销售效率的宝贵洞察,揭示了产品如何有效地将数量转化为收入。这个指标对于评估每单位销售所产生的价值至关重要。通过它,我们可以识别每单位产生高收入的产品,表明这些可能是值得优先考虑营销的高价值商品。相反,它有助于发现每单位收入较低的产品,提示可能需要调整价格、进行有针对性的促销,或重新评估产品组合,以优化盈利能力和销售表现。
注意
在可能的情况下,出于性能考虑,优先使用矢量化操作(内置的 pandas 方法或 NumPy 函数)而不是 apply
。矢量化操作通常更快,因为它们利用了优化的 C 代码。
到目前为止我们探讨的概念和技巧直接体现了数据清理中筛选的重要性。一旦我们应用了转换或聚合数据,筛选就能帮助我们聚焦于对分析有意义的特定数据子集,或满足特定条件的子集。例如,在计算了不同产品类别的销售表现指标(如Total_Sales_Quantity
和Sales_Quantity_Ratio
)之后,筛选可以帮助我们识别需要进一步调查的类别或产品,比如那些具有异常高或低表现指标的产品。
数据筛选
数据筛选是数据处理中的一项基本操作,涉及根据指定条件或标准选择数据子集。它用于从较大的数据集中提取相关信息、排除不需要的数据点,或集中关注分析或报告所需的特定部分。
在下面的示例中,我们筛选了 DataFrame,仅保留Quantity
列大于10
的行。这个操作选择了销量超过 10 个单位的产品,重点分析潜在的高绩效产品。github.com/PacktPublishing/Python-Data-Cleaning-and-Preparation-Best-Practices/blob/main/chapter06/5.simple_filtering.py
:
filtered_data = df[df['Quantity'] > 10]
让我们来看一下筛选后的 DataFrame:
Category Sub-Category Sales Quantity Date
4 Clothing Men 120 15 2023-01-05
5 Clothing Women 180 12 2023-01-06
超越简单筛选可以帮助我们识别符合更复杂条件的电子产品,正如我们将在下一节看到的那样。
多重筛选条件
筛选可能涉及复杂的条件,比如结合逻辑AND
和OR
操作,或使用嵌套条件。假设管理团队要求我们识别高价值的电子产品(sales > 1000
),且销售数量相对较低(quantity <
30
)。
让我们看看如何使用多个筛选条件来完成这个操作(代码可以在这里找到:github.com/PacktPublishing/Python-Data-Cleaning-and-Preparation-Best-Practices/blob/main/chapter06/6.advanced_filtering.py
):
filtered_data = df[(df['Sales'] > 1000) & (df['Quantity'] < 30)]
在这个示例中,我们定义了一个筛选条件,筛选出销售额大于1000
且数量小于30
的行。让我们来看一下筛选后的 DataFrame:
Category Sub-Category Sales Quantity Date
1 Electronics Laptop 1500 25 2023-01-02
筛选是一个直接的操作,但让我们探索一些最佳实践,以优化其效果。
筛选的最佳实践
让我们探讨一些最佳实践,以提升筛选操作的效果:
-
根据分析目标清晰定义筛选标准。使用那些具体且与您想要得出的见解相关的条件。
-
利用数据操作库(如 Python 中的 pandas 或数据库中的 SQL 查询)提供的内置过滤函数。这些函数在性能和易用性上进行了优化。
-
确保过滤条件不会排除那些可能对分析有价值的重要数据点。验证结果以确认它们与预期的结果一致。
-
记录过滤条件和应用步骤,以保持透明度并促进分析的可重复性。
随着数据集的增长,过滤变得至关重要,用于高效管理和提取洞察。没有有效的过滤策略,处理大量数据的操作可能会变得极其缓慢。通过减少每次需要存储和处理的数据量,过滤有助于优化资源利用,如内存和处理能力。
随着数据增长,性能考虑因素
让我们来看一下随着数据增长需要注意的事项:
-
过滤操作通过减少需要处理的行或列数来优化查询执行,从而加快数据查询和分析的响应速度。
-
大型数据集消耗大量内存和存储资源。过滤减少了存储在内存中或硬盘上的数据量,提高了效率,并降低了与数据存储相关的运营成本。
现在,让我们总结一下本章的学习内容。
总结
在本章中,我们探讨了一些强大的技术,例如分组、聚合和应用自定义函数。这些方法对于总结和转化数据至关重要,有助于深入洞察数据集。我们学习了如何根据类别变量(如Category
和Region
)高效地分组数据,并应用聚合函数(如求和、平均值和自定义指标)来得出有意义的总结。
此外,我们深入探讨了apply
函数的多功能性,它允许进行行或列的自定义计算。强调了优化函数效率、处理缺失值和理解性能影响等最佳实践,以确保有效的数据处理。最后,我们讨论了过滤器的战略性应用,基于特定标准精炼数据集,提升数据分析精度。
在下一章中,我们将讨论设计和优化数据写入操作,以高效地存储转化和清洗后的数据。
第七章:数据接收端
在现代数据处理的世界中,关于数据管理、存储和处理的关键决策将决定成功的结果。在本章中,我们将深入探讨支撑高效数据处理管道的三大重要支柱:选择正确的数据接收端、选择最优的文件类型,以及掌握分区策略。通过讨论这些关键要素及其在实际应用中的体现,本章将为你提供所需的洞察和策略,帮助你在复杂的数据处理技术领域内设计优化效率、可扩展性和性能的数据解决方案。
在本章中,我们将讨论以下主题:
-
为你的使用案例选择正确的数据接收端
-
为你的使用案例选择正确的文件类型
-
导航分区
-
设计一个在线零售数据平台
技术要求
本章中,我们需要安装以下库:
pip install pymongo==4.8.0
pip install pyarrow
pip install confluent_kafka
pip install psycopg2-binary==2.9.9
和往常一样,你可以在本书的 GitHub 仓库中找到本章的所有代码:github.com/PacktPublishing/Python-Data-Cleaning-and-Preparation-Best-Practices/tree/main/chapter07
。
每个部分后面都有一个类似命名规则的脚本,因此可以执行这些脚本或通过阅读本章进行跟进。
为你的使用案例选择正确的数据接收端
数据接收端指的是数据流向或存储的目标位置。术语“接收端”是用来比喻数据流入并被指定位置吸收的概念。数据接收端通常作为存储位置,数据可以在此永久或临时存储。这些存储可以是数据库、文件或其他数据结构的形式。
数据工程师和数据科学家通常根据其特定的任务和使用场景,使用多种数据接收端。让我们看看一些常见的数据接收端,并附带代码示例,同时考虑每种类型的优缺点。
关系型数据库
关系型数据库是一种数据库管理系统(DBMS),它将数据组织成具有行和列的表格,每行代表一条记录,每列代表一个字段。表格之间的关系通过键建立。主键唯一标识表格中的每一条记录,外键则在表格之间创建链接。
关系型数据库概述
以下是关系型数据库的关键组件的简要概述:
-
表格:数据被组织成表格,每个表格代表特定的实体或概念。例如,在一个图书馆的数据库中,可能会有关于书籍、作者和借阅者的表格。
-
行和列:每个表由行和列组成。行代表特定的记录(例如,一本书),每列代表该记录的特定属性或字段(例如,书名、作者和出版年份)。
-
键:键用于建立表之间的关系。主键唯一标识表中的每一条记录,而相关表中的外键则在它们之间创建连接。
-
结构化查询语言 (SQL):关系型数据库使用 SQL 进行数据查询和操作。SQL 允许用户检索、插入、更新和删除数据,同时定义和修改数据库的结构。
在数据领域,我们通常在以下场景中看到关系型数据库:
-
结构化数据:如果您的数据具有明确的结构,并且实体之间有清晰的关系,那么关系型数据库是一个合适的选择。
-
数据完整性要求:如果您的应用对于数据完整性有严格要求(例如,在金融系统或医疗应用中),关系型数据库提供机制来强制执行完整性约束。
-
原子性、一致性、隔离性和持久性 (ACID) 特性:原子性确保事务是“全有或全无”的操作:要么所有更改都提交,要么都不提交。例如,在账户之间转账时,原子性保证两个账户的余额要么都更新,要么都不更新。一致性意味着事务将数据库从一个有效状态转移到另一个有效状态,同时遵守完整性约束。如果违反了唯一客户 ID 等规则,事务将回滚以保持一致性。隔离性确保事务独立执行,防止并发事务之间的干扰和未提交更改的可见性,避免了脏读等问题。最后,持久性保证一旦事务提交,更改就会永久保留,即使系统发生故障,也能确保更新(如在线应用中的联系人信息)的持久性。如果您的应用需要遵守 ACID 特性,关系型数据库专门设计来满足这些需求。
-
复杂查询:如果您的应用涉及复杂的查询和报告需求,关系型数据库凭借其 SQL 查询功能,非常适合此类场景。
市面上有许多不同的构建关系型数据库的选项,接下来我们将看到这些选项。
关系型数据库管理系统的不同选项
市面上有许多不同的关系型数据库管理系统 (RDBMSs) 。我们在下表中总结了主要的几种:
数据库 | 描述 |
---|---|
MySQL | 一个以速度、可靠性著称的开源关系型数据库管理系统,广泛应用于网页开发 |
PostgreSQL | 一个开源关系型数据库管理系统,具备高级功能、可扩展性,并支持复杂查询 |
Oracle 数据库 | 一款商业 RDBMS,以其可扩展性、安全性以及全面的数据管理功能而著称 |
Microsoft SQL Server | 微软推出的商业 RDBMS,集成了微软技术并支持商业智能 |
SQLite | 一款轻量级、嵌入式、无服务器的 RDBMS,适用于数据库需求较低或中等的应用 |
MariaDB | 一款从 MySQL 派生的开源 RDBMS,旨在兼容性同时引入新特性 |
表 7.1 – RDBMS 概述
现在,让我们看看如何快速设置本地关系型数据库、连接到它并创建一个新表的示例。
一个 PostgreSQL 数据库示例
首先,我们需要安装并设置 PostgreSQL。这根据操作系统(OS)有所不同,但逻辑保持一致。以下脚本自动化了在 macOS 或基于 Debian 的 Linux 系统上安装和设置 PostgreSQL 的过程:github.com/PacktPublishing/Python-Data-Cleaning-and-Preparation-Best-Practices/blob/main/chapter07/setup/setup_postgres.sh
。
首先,它使用 uname
命令检测操作系统:
OS=$(uname)
如果检测到 macOS,它将使用 Homebrew 更新软件包列表,安装 PostgreSQL 并启动 PostgreSQL 服务。如果检测到基于 Debian 的 Linux 操作系统,它将使用 apt-get
更新软件包列表,安装 PostgreSQL 及其 contrib
包,并启动 PostgreSQL 服务。以下是安装 macOS 的代码:
if [ "$OS" == "Darwin" ]; then
echo "Detected macOS. Installing PostgreSQL via Homebrew..."
brew update
brew install postgresql
brew services start postgresql
如果你的操作系统不被该脚本支持,则会显示以下错误消息:
Unsupported OS. Please install PostgreSQL manually.
在这种情况下,你需要手动安装 PostgreSQL 并启动服务。完成后,你可以继续执行脚本的第二部分。然后,脚本切换到默认的 postgres
用户,以执行 SQL 命令,在该用户尚未存在时创建新数据库用户,创建一个由该用户拥有的新数据库,并授予该用户对该数据库的所有权限,如下所示:
psql postgres << EOF
DO \$\$
BEGIN
IF NOT EXISTS (
SELECT FROM pg_catalog.pg_user
WHERE usename = 'the_great_coder'
) THEN
CREATE USER the_great_coder
WITH PASSWORD 'the_great_coder_again';
END IF;
END
\$\$;
EOF
psql postgres << EOF
CREATE DATABASE learn_sql2 OWNER the_great_coder;
EOF
psql postgres << EOF
-- Grant privileges to the user on the database
GRANT ALL PRIVILEGES ON DATABASE learn_sql2 TO the_great_coder;
EOF
要执行上述代码,请按照以下步骤操作:
-
确保将仓库拉取到本地笔记本电脑。
-
转到存放仓库的文件夹。
-
在仓库文件夹位置打开终端。
-
执行以下命令以导航到正确的文件夹:
cd chapter7 setup_postgres.sh script, as shown here:
maria.zevrou@FVFGR3ANQ05P chapter7 % cd setup
maria.zevrou@FVFGR3ANQ05P set up % ls
setup_postgres.sh
-
通过运行以下命令使脚本可执行:
chmod +x setup_postgres.sh
-
最后,使用以下命令运行实际的脚本:
./setup_postgres.sh
执行脚本后,你应该看到一条确认消息,表示 PostgreSQL 设置(包括数据库和用户创建)已完成:
PostgreSQL setup completed. Database and user created.
现在,我们准备执行脚本,以便将传入的数据写入我们在前一步中创建的数据库。你可以在这里找到这个脚本:github.com/PacktPublishing/Python-Data-Cleaning-and-Preparation-Best-Practices/blob/main/chapter07/1.postgressql.py
。
该脚本连接到我们之前创建的 PostgreSQL 数据库并管理其中的表。让我们开始吧:
-
我们首先导入必要的库:
import pandas as pd import psycopg2 from psycopg2 import sql
-
接着,我们需要定义几个函数,从
table_exists
开始。该函数检查指定的表是否已存在于数据库中:def table_exists(cursor, table_name): cursor.execute( sql.SQL("SELECT EXISTS ( \ SELECT 1 FROM information_schema.tables \ WHERE table_name = %s)"), [table_name] ) return cursor.fetchone()[0]
-
我们需要的下一个函数是
create_table
函数,如果表在特定模式下不存在,它将创建一个新表。在我们的例子中,它将有三列:id
作为主键,name
和age
:def create_table(cursor, table_name): cursor.execute( sql.SQL(""" CREATE TABLE {} ( id SERIAL PRIMARY KEY, name VARCHAR(255), age INT ) """).format(sql.Identifier(table_name)) )
-
接着,我们必须定义
insert_data
函数,该函数用于向表中插入数据行:def insert_data(cursor, table_name, data): cursor.executemany( sql.SQL("INSERT INTO {} (name, age) \ VALUES (%s, %s)" ).format(sql.Identifier(table_name)), data )
-
最后,我们必须使用以下函数来显示检索到的数据:
def print_table_data(cursor, table_name): cursor.execute( sql.SQL( "SELECT * FROM {}" ).format(sql.Identifier(table_name)) ) rows = cursor.fetchall() for row in rows: print(row)
此时,脚本将创建一个包含示例数据(名称和年龄)的模拟 DataFrame:
data = { 'name': ['Alice', 'Bob', 'Charlie'], 'age': [25, 30, 22] } df = pd.DataFrame(data)
它使用指定的连接参数(数据库名称、用户名、密码、主机和端口)建立与 PostgreSQL 数据库的连接。这些正是我们在之前的步骤中设置数据库时使用的详细信息,因此你这边无需做任何更改:
db_params = { 'dbname': 'learn_sql', 'user': 'the_great_coder', 'password': 'the_great_coder_again', 'host': 'localhost', 'port': '5432' } conn = psycopg2.connect(**db_params) cursor = conn.cursor()
-
最后,它会检查名为
example_table
的表是否存在,如有必要会创建它,然后将模拟数据插入到表中。在提交更改到数据库后,脚本从表中获取数据并打印,以确认成功插入,最后关闭数据库连接:table_name = 'example_table' if not table_exists(cursor, table_name): create_table(cursor, table_name) insert_data(cursor, table_name, df.values.tolist()) conn.commit() print_table_data(cursor, table_name) cursor.close() conn.close()
要执行前面的脚本,只需在 chapter7
文件夹中执行以下命令:
python 1.postgressql.py
重要说明
记得始终关闭连接,因为这有助于避免性能问题,并确保在需要时能够建立新连接。它使数据库能够释放与连接相关的资源,并确保任何未提交的事务得到适当处理。关闭连接将其返回到连接池,使其可以被应用程序的其他部分重用。
要查看在数据库中创建的表,可以打开终端中的 PSQL 进程,并通过执行以下命令连接到 learn_sql
数据库:
psql -h localhost -U the_great_coder -d learn_sql
然后,运行以下命令以列出所有可用的表:
\dt
你应该会看到类似如下内容:
图 7.1 – 列出数据库中的表
现在你还可以通过执行以下 SQL 命令与表进行交互:
图 7.2 – 显示表中的所有行
如果你在不先删除现有表的情况下重新运行相同的 Python 脚本,你不会看到创建一个新表;相反,新的行会被添加到相同的表中:
图 7.3 – 在脚本重新运行后显示表中的所有行
在了解如何设置关系型数据库并通过写入新数据将其用作存储后,我们深入探讨关系型数据库的优缺点。
关系型数据库的优缺点
在这一部分,我们将总结使用关系型数据库管理系统(RDBMS)的优缺点。
优点如下:
-
RDBMS 系统具有 ACID 属性,提供了一个强大的框架来保证可靠和安全的事务
-
RDBMS 技术已经存在了几十年,产生了成熟且完善的系统,拥有丰富的文档和社区支持
然而,它们也有各种缺点:
-
RDBMS 的严格模式在处理不断变化或动态数据结构时可能成为一种限制,因为它们需要模式修改。新数据可能需要进行模式更改。
-
RDBMS 主要设计用于结构化数据,可能不适合处理非结构化或半结构化数据。
如果你在想关系型数据库中的数据是以什么文件类型存储的,那么接下来的子部分会让你觉得很有趣。
关系型数据库文件类型
在关系型数据库中,存储数据的文件类型通常是抽象的,用户和开发人员不常直接与底层文件交互。关系型数据库通过其内部机制管理数据存储和检索,这些机制通常涉及专有的 文件格式。
在关系型数据库中,存储和组织数据的过程由数据库管理系统(DBMS)管理,用户使用 SQL 或其他查询语言与数据进行交互。DBMS 将物理存储细节从用户中抽象出来,提供一个逻辑层,允许数据操作和检索,而无需直接关注底层文件格式。
让我们讨论一下关系型数据库中文件类型的关键点:
-
关系型数据库供应商通常使用专有的文件格式来存储数据。每个数据库管理系统可能有它自己的内部结构和机制来 管理数据。
-
关系型数据库通常将数据组织成表空间,这是逻辑存储容器。这些表空间由存储数据的页或块组成。页的组织和结构由特定的数据库管理系统(DBMS)决定。
-
关系型数据库优先考虑 ACID 属性,以确保数据完整性和可靠性。内部文件格式被设计用来支持这些事务性保证。
-
关系数据库使用各种索引和优化技术来提升查询性能。包括 B 树或其他索引结构在内的内部文件结构被优化以实现高效的数据检索。
-
用户使用 SQL 命令与关系数据库进行交互。
虽然用户通常不会直接与底层文件格式交互,但理解表空间、页面及数据库管理系统(DBMS)如何管理数据存储的概念,对于数据库管理员和开发人员在优化性能或排查问题时是非常有用的。
从关系数据库管理系统(RDBMS)迁移到不仅仅是 SQL(NoSQL)数据库涉及数据建模、模式设计和查询方法的转变。我们将在接下来的部分中探讨这些差异。
NoSQL 数据库
NoSQL 数据库,也称为不仅仅是 SQL或非 SQL数据库,是一类提供灵活和可扩展的数据存储与处理方法的数据库系统。与传统的关系数据库不同,后者强制使用具有预定义表格、列和关系的结构化模式,NoSQL 数据库旨在处理多种数据模型,适应不同的数据结构和组织方式,并提供更加动态和灵活的数据建模方法。
NoSQL 数据库概述
下面是 NoSQL 数据库关键组件的快速概述:
-
NoSQL 数据库通常采用无模式或灵活模式的方法,允许数据在没有预定义模式的情况下进行存储。这种灵活性在数据结构不断变化或无法预先确定的情况下尤其有用。
-
NoSQL 数据库有不同类型,每种类型都有自己的数据模型,如面向文档的(如 MongoDB)、键值存储(如 Redis)、列族存储(如 Apache Cassandra)和图数据库(如 Neo4j)。数据模型因应不同类型的数据和使用场景而有所不同。面向文档的数据模型将数据存储为 JSON 文档,允许每个文档具有不同的结构,适用于半结构化或非结构化数据。键值数据模型将数据存储为键值对,其中值可以是简单类型或复杂结构,提供快速的数据检索,但查询能力有限。列族数据模型将数据按列而非行组织,使得存储和检索大规模数据集更加高效。最后,图数据模型将数据表示为节点和边,非常适合关注关系的应用,如社交网络和网络分析。
-
NoSQL 数据库通常设计为横向扩展,这意味着它们能够高效地将数据分布到多个节点。
-
NoSQL 数据库通常遵循 一致性、可用性和分区容忍性(CAP)定理,该定理表明分布式系统最多只能提供三项保证中的两项。NoSQL 数据库可能会优先考虑可用性和分区容忍性,而不是严格的一致性。
在数据领域,我们通常会发现 NoSQL 数据库作为以下情况下的“数据存储”:
-
当我们处理的数据模型可能经常变化或事先定义不明确时。
-
当一个应用程序预见到或经历快速增长时,在这种情况下,水平可扩展性至关重要。
-
当数据无法放入具有固定关系的表中时,这时需要一种更灵活的存储模型。
-
当快速开发和迭代至关重要时,在这种情况下,我们需要随时修改数据模型。
-
当某一特定类型的 NoSQL 数据库的具体特性和功能与应用程序的需求相符时(例如,面向内容的应用程序使用面向文档的数据库,缓存使用键值存储,关系数据使用图数据库)。
让我们看一个如何连接到 NoSQL 数据库并写入新表的示例。
一个 MongoDB 数据库的示例
在深入代码之前,我们先花些时间来解释 MongoDB 以及与之相关的一些重要概念:
-
文档:这是 MongoDB 中的数据基本单元,以 二进制 JSON(BSON)对象的形式表示。文档类似于关系数据库中的行,但可以具有不同的结构。文档由字段(键值对)组成。每个字段可以包含不同的数据类型,例如字符串、数字、数组或嵌套文档。
-
集合:MongoDB 文档的集合,类似于关系数据库中的表。集合包含文档,并作为组织数据的主要方法。集合不需要预定义的架构,这使得同一集合中的文档可以具有不同的结构。
-
数据库:集合的容器。MongoDB 数据库包含集合,并作为数据组织的最高层级。每个数据库与其他数据库隔离,这意味着一个数据库中的操作不会影响其他数据库。
现在我们对这些概念有了更清晰的理解,让我们来看看代码。要运行这个示例,请按照操作系统的文档在本地设置 MongoDB。对于 Mac 的安装说明,请访问这里:www.mongodb.com/docs/manual/tutorial/install-mongodb-on-os-x/
。以下代码示例展示了如何创建数据库并使用 pymongo
在 MongoDB 中写入数据。请注意,pymongo
是 MongoDB 的官方 Python 驱动程序,提供了一个 Python 接口来连接 MongoDB 数据库,执行查询,并通过 Python 脚本操作数据。
开始吧:
-
安装完 MongoDB 后,打开你的终端并启动服务。这里提供的命令适用于 Mac;请根据你的操作系统,参考文档中的命令:
brew services start mongodb-community@7.0
-
通过执行以下命令验证服务是否正在运行:
brew services list mongodb-community@7.0 started maria.zervou ~/Library/LaunchAgents/h
安装完成后,我们来设置一个 MongoDB 数据库。
-
在你的终端中,输入以下命令进入 MongoDB 编辑器:
no_sql_db:
best_collection_ever:
db.createCollection("best_collection_ever")
你应该看到类似以下的响应:
{ ok: 1 }
到此为止,我们已经准备好切换到 Python,并开始向这个集合中添加数据。你可以在这里找到代码:github.com/PacktPublishing/Python-Data-Cleaning-and-Preparation-Best-Practices/blob/main/chapter07/2.pymongo.py
。请按照以下步骤操作:
-
首先,我们导入所需的库:
from pymongo import MongoClient
-
每次连接到 NoSQL 数据库时,我们需要提供连接详情。更新
mongo_params
字典中的所有参数值,字典包含了 MongoDB 服务器主机、端口、用户名、密码和认证源:mongo_params = { 'host': 'localhost', 'port': 27017, 'username': 'your_mongo_username', 'password': 'your_mongo_password', 'authSource': 'your_auth_database' }
-
让我们来看一下在本例中用于将文档插入 MongoDB 数据库的不同函数。第一个函数在创建新集合之前,会检查集合是否存在于数据库中:
def collection_exists(db, collection_name): return collection_name in db.list_collection_names()
-
以下函数将数据库和集合名称作为参数,并创建一个我们传递的名称的集合(在我们的例子中,我们提供了
collection_name
作为名称):def create_collection(db, collection_name): db.create_collection(collection_name)
-
最后,我们将使用之前创建的集合,它现在只是一个占位符,并向其中插入一些数据:
def insert_data(collection, data): collection.insert_many(data)
-
让我们创建一些要插入到集合中的数据:
documents = [ {'name': 'Alice', 'age': 25}, {'name': 'Bob', 'age': 30}, {'name': 'Charlie', 'age': 22} ]
-
让我们指定连接所需的参数并创建连接:
db_name = ' no_sql_db' collection_name = 'best_collection_ever' client = MongoClient(**mongo_params) db = client[db_name]
-
现在,让我们检查是否存在具有提供名称的集合。如果集合存在,则使用现有集合;如果不存在,则创建一个新的集合并使用提供的名称:
if not collection_exists(db, collection_name): create_collection(db, collection_name)
-
然后,获取集合并插入提供的数据:
collection = db[collection_name] insert_data(collection, documents)
-
最后,关闭 MongoDB 连接:
client.close()
执行此脚本后,你应该能够看到记录被添加到集合中:
{'_id': ObjectId('66d833ec27bc08e40e0537b4'), 'name': 'Alice', 'age': 25}
{'_id': ObjectId('66d833ec27bc08e40e0537b5'), 'name': 'Bob', 'age': 30}
{'_id': ObjectId('66d833ec27bc08e40e0537b6'), 'name': 'Charlie', 'age': 22}
本脚本演示了如何与 MongoDB 数据库和集合进行交互。与关系型数据库不同,MongoDB 不需要预先创建表或模式。相反,你直接与数据库和集合进行操作。作为练习,为了更好地理解这种灵活的数据模型,尝试将不同结构的数据插入集合中。这与关系型数据库不同,后者是向具有固定模式的表中插入行。你可以在这里找到一个示例:github.com/PacktPublishing/Python-Data-Cleaning-and-Preparation-Best-Practices/blob/main/chapter07/3.pymongo_expand.py
。
最后,脚本使用find
方法查询并从集合中检索文档。与 SQL 查询相比,MongoDB 的查询更加灵活,特别是在处理嵌套数据时。
不要删除 MongoDB 数据库
请不要清理我们创建的 Mongo 资源,因为我们将在流式接收示例中使用它们。我们将在本章结束时清理所有资源。
在接下来的部分,我们将讨论 NoSQL 数据库所提供的优缺点。
NoSQL 数据库的优缺点
让我们总结一下使用 NoSQL 系统的优缺点。
NoSQL 数据库的优点如下:
-
可扩展性:NoSQL 数据库设计时考虑了水平扩展性,可以通过将数据分布到多个服务器来处理大量数据。这使得它们特别适用于大数据应用和云环境。
-
灵活性:与需要固定模式的 SQL 数据库不同,NoSQL 数据库提供灵活的模式。这使得结构化、半结构化和非结构化数据可以存储,从而更容易适应不断变化的数据模型,而无需进行大规模的重构。
-
性能:NoSQL 数据库在处理某些类型的查询时,尤其是处理大数据集时,可能提供更优的性能。它们通常针对高速数据检索进行了优化,并能处理每秒大量的事务。
-
成本效益:许多 NoSQL 数据库是开源的,并且可以使用普通硬件进行扩展,这相比于通常需要昂贵硬件来扩展 SQL 数据库的成本要低。
-
开发者灵活性:模式和数据模型的灵活性使得开发者可以快速迭代,适应新的需求,而无需进行大量的数据库管理。
然而,它们也有一些缺点:
-
缺乏标准化:NoSQL 数据库没有像 SQL 那样的标准化查询语言。这可能导致学习曲线较陡,并且使得在不同 NoSQL 系统之间切换变得具有挑战性。
-
有限的复杂查询支持:NoSQL 数据库通常缺乏 SQL 数据库的高级查询功能,如连接和复杂事务,这可能限制它们在需要复杂数据关系的应用中的使用。
-
数据一致性:许多 NoSQL 数据库优先考虑可用性和分区容忍性,而不是一致性(根据 CAP 定理)。这可能导致最终一致性模型,这对于需要严格数据完整性的应用可能不适用。
-
成熟度和社区支持:与 SQL 数据库相比,NoSQL 数据库相对较新,这意味着它们可能拥有不那么成熟的生态系统和较小的社区。这可能使得寻找支持和资源变得更加困难。
-
复杂的维护:NoSQL 数据库的分布式特性可能导致复杂的维护任务,如数据分布和负载均衡,这些任务需要专业的知识。
现在,让我们讨论在使用 NoSQL 数据库时可能遇到的文件格式。
NoSQL 数据库文件类型
最常见的文件格式是 JSON 和 BSON。JSON 是一种轻量级的、易于人类阅读的数据交换格式,采用键值对结构,并支持嵌套数据结构。由于其简洁性和易于解析,JSON 被广泛应用于基于 Web 的数据交换。JSON 是语言无关的,适用于各种编程语言。JSON 的灵活性和无模式特性与许多 NoSQL 数据库的灵活模式方法相契合,允许轻松处理不断变化的数据结构。NoSQL 数据库通常处理半结构化或非结构化数据,JSON 的层级结构能够很好地适应这种数据。以下是一个 JSON 数据文件的示例:
{
"person": {
"name": "John Doe",
"age": 30,
"address": {
"city": "New York",
"country": "USA"
},
"email": ["john.doe@email.com", "john@example.com"]
}
}
BSON 是一种二进制编码的序列化格式,用于类似 JSON 的文档,旨在提高存储和遍历的效率。它增加了 JSON 中没有的数据类型,如 date
和 binary
。BSON 文件在存储前会被编码,在显示前会被解码。BSON 的二进制格式更适合存储和序列化,因此在需要紧凑表示数据的场景中非常合适。BSON 是 MongoDB 中使用的主要数据格式。让我们来看一下之前展示的文件的 BSON 表示:
\x16\x00\x00\x00
{
"person": {
"name": "John Doe",
"age": 30,
"address": {
"city": "New York",
"country": "USA"
},
"email": ["john.doe@email.com", "john@example.com"]
}
}\x00
在 NoSQL 数据库中,选择 JSON 还是 BSON 通常取决于数据库的具体需求和使用场景。虽然 JSON 在许多场景下更易于人类阅读和操作,但 BSON 的二进制效率在某些情况下,特别是在存储和序列化效率至关重要时,具有优势。
在接下来的部分,我们将讨论数据仓库,它们解决了哪些挑战,以及在使用时应该考虑实施的应用场景。
数据仓库
当数据的体积和复杂性以及对高级分析的需求超出了现有关系型或 NoSQL 数据库的能力时,转向数据仓库变得非常重要。如果您的关系型数据库在处理大量数据、复杂查询或在分析处理期间遇到性能问题,数据仓库可以为此类工作负载提供优化的存储和查询性能。同样,NoSQL 数据库虽然在处理非结构化或半结构化数据以及横向扩展方面表现优异,但可能缺乏深度分析和报告所需的复杂查询能力和性能。数据仓库旨在整合来自多个来源的数据,包括关系型和 NoSQL 数据库,促进全面分析和报告。它们为历史数据分析、复杂查询和数据治理提供强有力的支持,使其成为在需要提升数据整合、分析和报告能力时的理想解决方案,超越了传统数据库的能力。
数据仓库概览
数据仓库是一种专门的数据库系统,旨在高效地存储、组织和检索大量数据,这些数据用于商业智能和分析。与优化用于快速数据更新和单个记录检索的事务性数据库不同,数据仓库的结构是为了支持复杂的查询、聚合和对历史及当前数据的报告。
这里是数据仓库关键组件的快速概述:
-
各种数据源为数据仓库提供数据,包括事务性数据库、外部文件、日志等。
-
提取、转换和加载(ETL)过程用于从源系统收集数据,将其转换为一致的格式,并将其加载到数据仓库中。
-
数据仓库采用优化的存储方法,例如列式存储,以高效地存储大量数据。
-
索引和预聚合表用于优化查询性能。在数据仓库中,索引在优化查询性能方面起着至关重要的作用。索引是一种数据结构,通过创建数据的独立、有序子集来提高从表中检索数据的速度和效率。索引通常在一个或多个列上创建,以便加速查询。没有索引时,数据库必须扫描整个表才能定位相关行。索引帮助数据库快速找到符合查询条件的行。常见的索引候选列包括
WHERE
子句、JOIN
条件和ORDER BY
子句中使用的列。然而,过度索引可能导致收益递减并增加维护负担。虽然索引提高了查询性能,但它们也消耗额外的存储空间,并且由于需要维护索引,可能会减慢写操作的速度。 -
诸如并行处理和索引等技术被用来提高分析查询的速度。
-
与商业智能工具的集成允许用户创建报告和仪表板,并执行数据分析。
-
数据通过多维模型进行组织,通常以星型或雪花型模式的形式出现,以支持分析和报告。
让我们扩展一下维度建模。它是一种在数据仓库中用于结构化数据的设计技术,使得数据能够支持高效的分析查询和报告。与传统的关系模型不同,维度模型经过优化,专注于查询性能和易用性。在接下来的部分中,我们将介绍维度模型中的主要模式类型。
维度模型中的模式类型
维度建模主要涉及两种模式:星型模式和雪花模式。星型模式是最简单的维度建模形式,其中一个中心事实表直接连接到多个维度表,形成类似星星的结构。这种模式直观易懂,便于导航,非常适合简单的查询和报告。每个星型模式中的维度表包含一个主键,与事实表中的外键相关联,为事实表中的定量数据提供描述性上下文。例如,销售星型模式可能包括一个中心销售事实表,外键链接到产品、客户、时间和商店等维度表,从而通过减少连接的数量简化复杂查询:
图 7.4 – 星型模式
另一方面,雪花模式是星型模式的一个更规范化版本,在这种模式下,维度表会进一步拆分成相关的子表,形成类似雪花的结构。这种结构减少了数据冗余,可以节省存储空间,尽管由于需要额外的连接,它在查询设计上引入了更多的复杂性。例如,雪花模式中的产品维度表可能被规范化为独立的产品类别和品牌表,从而形成多层次结构,确保更高的数据完整性并减少更新异常。虽然雪花模式在查询时可能稍显复杂,但在数据维护和可扩展性方面提供了优势,尤其是在数据一致性和存储优化至关重要的环境中:
图 7.5 – 雪花模式
星型模式通常用于较简单的层次结构,并且当查询性能优先时,而雪花模式则可能在更高效地使用存储和实现更高程度的规范化时被选用。
在理解维度建模中的模式类型之后,探索在各种组织环境中实施和利用数据仓库的多种选择至关重要。
数据仓库解决方案
市场上有许多不同的数据仓库选择。我们在下表中总结了主要的选项:
数据仓库 | 描述 |
---|---|
Databricks SQL | 一种基于云的、无服务器的数据仓库,将仓库功能带入数据湖。它以其可扩展性、性能和并行处理能力而闻名,并且具备内建的机器学习功能。 |
Amazon Redshift | 一项完全托管的、可扩展的云数据仓库服务,优化用于高性能分析。 |
Snowflake | 一种基于云的数据仓库,具有多集群共享架构,支持多种工作负载。 |
Google BigQuery | 一种无服务器、高度可扩展的数据仓库,具有内置的机器学习功能。 |
数据仓库 | 描述 |
Teradata | 一种支持扩展性、性能和并行处理的本地或基于云的数据仓库。 |
Microsoft Azure Synapse Analytics | 一种基于云的分析服务,提供按需和预配资源。 |
表 7.2 – 数据仓库解决方案
在下一节中,我们将查看创建 BigQuery 表的示例,以说明数据仓库的实际应用。
数据仓库示例
让我们来学习如何在 BigQuery 中创建一个新表。Google Cloud 为包括 Python 在内的多种编程语言提供了与 BigQuery 交互的客户端库。为了准备好并运行以下示例,请访问 BigQuery 文档:cloud.google.com/python/docs/reference/bigquery/latest
。让我们深入研究这个示例。我们将按照本章至今介绍的相同模式进行操作。让我们开始吧:
注意
要运行此示例,您需要拥有一个Google Cloud Platform(GCP)账户,并准备好一个 Google Storage 存储桶。
-
导入所需的库:
from google.cloud import bigquery from google.cloud.bigquery import SchemaField
-
首先,我们将设置项目 ID。将
your_project_id
替换为你的实际值:client = bigquery.Client(project='your_project_id')
-
定义数据集和表名称,并更新以下字段:
dataset_name = 'your_dataset' table_name = 'your_table'
-
检查数据集和表是否存在:
dataset_ref = client.dataset(dataset_name) table_ref = dataset_ref.table(table_name) table_exists = client.get_table( table_ref, retry=3, timeout=30, max_results=None ) is not None
-
定义表的模式(如果你希望更新数据,可以替换为你的模式)。在本例中,我们将创建一个包含两列(
column1
和column2
)的表。第一列将是STRING
类型,第二列将是INTEGER
类型。第一列不能包含缺失值,而第二列可以:schema = [ SchemaField('column1', 'STRING', mode='REQUIRED'), SchemaField('column2', 'INTEGER', mode='NULLABLE'), # Add more fields as needed ]
-
让我们检查是否存在同名的表。如果表不存在,则使用提供的名称创建它:
if not table_exists: table = bigquery.Table(table_ref, schema=schema) client.create_table(table)
-
让我们创建一些将被插入到表中的模拟数据:
rows_to_insert = [ ('value1', 1), ('value2', 2), ('value3', 3) ]
-
构建要插入的数据:
data_to_insert = [dict(zip([field.name for field in schema], row)) for row in rows_to_insert]
-
插入数据并检查是否有错误。如果一切按预期工作,关闭连接:
errors = client.insert_rows(table, data_to_insert)
如果发生任何插入错误,打印出错误信息:
print(f"Errors occurred during data insertion: {errors}")
-
关闭 BigQuery 客户端:
client.close()
容器和表有什么区别?
容器(在不同系统中有不同的名称,如数据库、数据集或模式)是一种逻辑分组机制,用于组织和管理数据对象,例如表、视图和相关的元数据。容器提供了一种基于特定需求(如访问控制、数据治理或数据域的逻辑分离)分区和结构化数据的方式。另一方面,表是一种基本的数据结构,用于存储实际的数据记录,按行和列组织。表定义了模式(列名称和数据类型),并存储数据值。
现在,我们从了解数据仓库环境的基本组件转向讨论数据仓库在高效管理和分析大量数据时所提供的优缺点。
数据仓库的优缺点
让我们总结一下使用数据仓库的优缺点。
优点如下:
-
针对分析查询和大规模数据处理进行了优化
-
可以处理海量数据
-
与其他数据工具和服务的集成
下面是它们的缺点:
-
相比传统数据库,存储和查询的成本更高
-
可能在实时数据处理方面存在限制
数据仓库中的文件类型
就文件格式而言,可以准确地说,许多现代数据仓库使用专有的内部存储格式来写入数据。这些专有格式通常是列式存储格式,针对高效查询和分析进行了优化。
让我们来看看这些专有格式可能带来的差异:
-
数据仓库通常使用如 Parquet、ORC 或 Avro 等列式存储格式。虽然这些格式是开放且广泛采用的,但每个数据仓库可能有其内部优化或扩展。
-
这些列式存储格式的实际实现可能具有供应商特定的优化或功能。例如,某个特定数据仓库如何处理压缩、索引和元数据可能是特定于该供应商的。
-
用户通过标准接口与数据仓库进行交互,例如 SQL 查询。存储格式的选择通常不会影响用户,只要数据仓库支持常见的数据交换格式用于导入和导出。
因此,尽管内部存储机制可能有供应商特定的优化,但使用公认的、开放的且广泛采用的列式存储格式,确保了一定程度的互操作性和灵活性。用户通常使用标准 SQL 查询或数据交换格式(如 CSV、JSON 或 Avro)与数据仓库交互进行数据的导入/导出,这为这些系统的外部接口提供了一层标准化。
从传统数据仓库过渡到数据湖代表了一种战略转变,拥抱更灵活和可扩展的范式。在接下来的章节中,我们将深入探讨数据湖。
数据湖
从传统的数据仓库到数据湖的过渡,代表了组织处理和分析数据方式的转变。传统的数据仓库设计用于存储结构化数据,这些数据高度组织,并根据预定义的模式格式化,例如关系数据库中的表格和行列。结构化数据便于使用 SQL 进行查询和分析。然而,数据仓库在处理非结构化数据时遇到困难,非结构化数据没有预定义的格式或组织方式。非结构化数据的例子包括文本文件、电子邮件、图像、视频和社交媒体帖子。而数据湖则提供了一种更灵活、可扩展的解决方案,通过存储结构化和非结构化数据的原始格式,使组织能够摄取和存储大量数据,而无需立即进行结构化。这一过渡解决了数据仓库的局限性,提供了一种更具多样性和未来可扩展性的数据管理方法。
数据湖概述
数据湖是一个集中式存储库,允许组织存储大量原始和非结构化数据,且可以存储任何所需格式的数据。它们被设计用来处理多种数据类型,如结构化、半结构化和非结构化数据,并支持数据探索、分析和机器学习。数据湖解决了多个问题:它们使得不同数据源能够统一存储,打破了信息孤岛,并通过提供易于访问的所有类型数据支持高级分析。
请记住
可以将数据湖视为文件系统,就像在你的笔记本电脑上存储数据位置一样,不过规模要大得多。
数据湖解决方案
以下是数据空间中可用的数据湖解决方案的摘要:
数据湖 | 描述 |
---|---|
Amazon S3 | 亚马逊网络服务 (AWS) 提供的基于云的对象存储服务,通常作为数据湖的基础设施。 |
Azure 数据湖存储 | 微软 Azure 提供的可扩展且安全的基于云的存储解决方案,旨在支持大数据分析和数据湖。 |
Hadoop 分布式文件 系统 (HDFS) | 作为 Apache Hadoop 的存储层,HDFS 是一个分布式文件系统,Hadoop 是一个开源的大数据处理框架。 |
Google Cloud Storage | Google Cloud 提供的对象存储服务,通常作为 GCP 中数据湖架构的一部分使用。 |
表 7.3 – 数据湖解决方案
从传统的数据仓库到数据湖的转变,代表了组织管理和分析数据方式的根本性变化。这一转变是由多个因素推动的,包括对更大灵活性、可扩展性以及处理多样化和非结构化数据类型的需求。下表突出显示了传统数据仓库和数据湖之间的主要区别:
数据仓库 | 数据湖 | |
---|---|---|
数据种类和灵活性 | 传统的数据仓库设计用于处理结构化数据,对于处理多样化数据类型或非结构化数据的适应性较差。 | 数据湖的出现是为了应对数据量和数据种类的快速增长。它们为原始、非结构化和多样化的数据类型提供存储库,允许组织存储大量数据,而无需预定义的模式。 |
可扩展性 | 传统的数据仓库在处理海量数据时通常面临可扩展性挑战。扩展数据仓库可能会很昂贵,并且可能存在限制。 | 数据湖,尤其是基于云的解决方案,提供可扩展的存储和计算资源。它们可以有效地水平扩展,以应对日益增长的数据集和处理需求。 |
数据仓库 | 数据湖 | |
成本效益 | 传统的数据仓库在扩展时可能成本高昂,并且其成本结构可能不适合存储大量原始或结构较差的数据。 | 基于云的数据湖通常采用按需付费模式,允许组织通过按使用的资源付费来更有效地管理成本。这对于存储大量原始数据特别有利。 |
写时模式与读时模式 | 遵循写时模式(schema-on-write),数据在加载到仓库之前被结构化和转换。 | 遵循读时模式(schema-on-read),允许存储原始、未经转换的数据。模式在数据分析时应用,提供了更多的数据探索灵活性。 |
表 7.4 – 数据仓库与数据湖
Lakehouse 架构的出现通过解决数据湖相关的关键挑战,并将传统上与数据仓库相关的特性引入数据湖环境,从而进一步完善了摆脱数据仓库的转变。以下是这一演变的关键方面概述:
-
Lakehouse 将 ACID 事务集成到数据湖中,提供了传统上与数据仓库相关的事务处理能力。这确保了数据的一致性和可靠性。
-
Lakehouse 支持模式演变,允许随时间对数据模式进行更改,而无需对现有数据进行完全转换。这提高了灵活性,并减少了模式更改对现有流程的影响。
-
Lakehouse 引入了管理数据质量的特性,包括模式强制执行和约束,确保存储在数据湖中的数据符合指定的标准。
-
Lakehouse 旨在通过结合数据湖和数据仓库的优势,提供一个统一的分析平台。它允许组织在一个集中式存储库中对结构化和半结构化数据进行分析。
-
Lakehouse 增强了元数据目录,提供了数据血缘、质量和转换的全面视图。这有助于更好的治理和对湖中数据的理解。
Lakehouse 概念通过数据和分析社区的讨论不断发展,多个公司为 Lakehouse 原则的发展和采用做出了贡献。
数据湖示例
让我们探索一个如何在 S3 上写入 Parquet 文件的示例,S3 是 AWS 的云存储。要设置一切,访问 AWS 文档:docs.aws.amazon.com/code-library/latest/ug/python_3_s3_code_examples.html
。现在,按照以下步骤进行操作:
注意
要运行此示例,您需要拥有一个 AWS 账户并准备好 S3 存储桶。
-
我们将开始导入所需的库:
import pandas as pd import pyarrow.parquet as pq import boto3 from io import BytesIO
-
现在,我们将创建一些模拟数据,以便将其写入 S3:
data = {'Name': ['Alice', 'Bob', 'Charlie'], 'Age': [25, 30, 22], 'City': ['New York', 'San Francisco', 'Los Angeles']} df = pd.DataFrame(data)
-
接下来,我们必须将 DataFrame 转换为 Parquet 格式:
parquet_buffer = BytesIO() pq.write_table(pq.Table.from_pandas(df), parquet_buffer)
-
更新您的认证密钥和我们将要写入数据的存储桶名称:
aws_access_key_id = 'YOUR_ACCESS_KEY_ID' aws_secret_access_key = 'YOUR_SECRET_ACCESS_KEY' bucket_name = 'your-s3-bucket' file_key = 'example_data.parquet' # The key (path) of the file in S3
-
使用前一步的连接信息连接到 S3 存储桶:
s3 = boto3.client('s3', aws_access_key_id=aws_access_key_id, aws_secret_access_key=aws_secret_access_key)
-
将 Parquet 文件上传到 S3:
s3.put_object(Body=parquet_buffer.getvalue(), Bucket=bucket_name, Key=file_key)
如前所述,Lakehouse 具有自己的优缺点。
其优势如下:
-
Lakehouse 提供了一个统一的平台,结合了数据湖和数据仓库的优势。这使得组织能够在一个环境中同时利用数据湖的灵活性和数据仓库的事务能力。
-
Lakehouse 遵循 schema-on-read 方法,允许存储原始的、未转换的数据。
-
Lakehouse 支持多种数据类型,包括结构化、半结构化和非结构化数据。
-
Lakehouse 集成了 ACID 事务,提供了事务能力,确保数据的一致性和可靠性。这对于数据完整性至关重要的使用场景尤为重要。
-
许多 Lakehouse 解决方案提供时间旅行功能,允许用户在特定时间点查询数据。数据的版本控制提供了历史背景并支持审计要求。
-
Lakehouse 通常实现优化的存储格式(例如 Delta 和 Iceberg),这有助于提高存储效率和查询性能,特别是对于大规模分析工作负载。
其缺点如下:
-
用户和管理员可能需要适应一种新的数据处理方式,同时考虑 schema-on-read 和 schema-on-write 模式。这可能需要培训和教育。
-
根据实现和云服务提供商的不同,存储、处理和管理 Lakehouse 架构中数据的成本可能会有所不同。组织需要仔细管理成本,以确保效率。
正如我们所讨论的,Lakehouse 拥有一个惊人的优势——它可以让任何类型的数据从结构化到半结构化再到非结构化数据都可以被摄取和存储。这意味着在摄取过程中我们可以看到任何文件类型,从 CSV 文件到 Parquet 和 Avro 文件。而在写入部分,我们可以利用 Lakehouse 提供的灵活性,将数据存储为优化过的开放表格文件格式。开放表格格式是一种用于存储表格数据的文件格式,能够让数据在各种数据处理和分析工具之间轻松访问和互操作。
数据湖中的文件类型
在 Lakehouse 架构中,我们有三种突出格式:Delta、Apache Iceberg 和 Apache Hudi。这些格式提供了 ACID 事务、模式演变、增量数据处理以及读写优化等特性。以下是这些格式的简要概述:
-
Delta Lake 是一个开源存储层,旨在提高数据湖中数据处理的可靠性和性能。它非常适合在 S3 或 Azure 存储等基础设施上构建数据湖,并且对 ACID 事务和数据版本控制有很强的支持。
-
Apache Iceberg 是另一种开源表格格式,专为快速查询性能优化。当需要查询效率时,它是一个不错的选择,并且它对模式演变和版本控制有出色的支持。
-
Apache Hudi(Hadoop 更新、删除和增量处理)是另一种开源数据湖存储格式,它为实时数据处理和流处理特性提供了很好的支持。尽管它可能不像 Delta Lake 或 Apache Iceberg 那样广为人知,但在 Apache Spark 和 Hadoop 生态系统中,Hudi 正在逐渐获得关注。
一般来说,这些格式都是为了解决相同的挑战而构建的,这也是它们有许多共同特性的原因。因此,在选择最适合你工作负载的格式之前,有几个因素你需要考虑,以确保你走在正确的方向:
-
考虑每种技术与现有数据处理生态系统和工具的兼容性。
-
评估每种技术在数据社区中的社区支持、持续开发和采用程度。
-
评估每种技术在特定使用案例中的性能特征,尤其是在读写操作方面。
最终,Delta Lake、Apache Iceberg 和 Apache Hudi 之间的选择应该由你的数据湖或湖仓环境的具体需求和优先事项驱动。通过实验和基准测试每个解决方案,结合你的数据和工作负载,可以做出更明智的决策。
我们将要讨论的最后一种接收器技术是流数据接收器。
流数据接收器
从批处理和微批处理到流处理技术的过渡,标志着数据处理和分析的重大进步。批处理涉及在预定的时间间隔内收集和处理大量离散数据,这可能导致数据可用性和洞察力的延迟。微批处理通过在更频繁的时间间隔内处理较小的批次来改进这一点,减少了延迟,但仍未实现实时数据处理。而流处理技术则能够实现数据的实时摄取和处理,使组织能够在数据到达时立即进行分析并采取行动。这一向流处理技术的转变,解决了当今快速变化的商业环境中对实时分析和决策的日益增长的需求,为数据管理提供了更加动态和响应迅速的方法。
流数据接收端概述
流数据接收端是实时消费和存储流数据的组件或服务。它们作为流数据被摄取、处理并持久化以供进一步分析或检索的终端。以下是流数据接收端及其主要组件的概述:
-
摄取组件:它负责接收和接纳传入的数据流
-
处理逻辑:这是一种定制的逻辑,可能包括数据丰富、转化或聚合的组件
-
存储组件:它用于持久化流数据,便于未来分析或检索
-
连接器:它们的主要作用是与各种数据处理或存储系统进行交互
我们通常在以下领域实施流数据接收端:
-
在实时分析系统中,允许组织在事件发生时实时获取数据洞察。
-
在系统监控中,流数据接收端捕获和处理实时指标、日志或事件,从而实现对问题或异常的即时告警和响应。
-
在金融交易或电子商务中,流数据接收端可用于通过分析交易数据中的模式和异常实现实时欺诈检测。
-
在物联网(IoT)场景中,流数据接收端处理来自传感器和设备的持续数据流,支持实时监控和控制。
现在,让我们来看看可用于流数据接收端的可选项。
流数据接收端解决方案
许多云平台提供托管的流数据服务,这些服务充当数据接收端,如亚马逊 Kinesis、Azure Event Hubs 和 Google Cloud Dataflow,如下表所示:
流数据接收端 | 描述 |
---|---|
亚马逊 Kinesis | AWS 中用于实时流处理的完全托管服务。它支持数据流、分析和存储。 |
Azure Event Hub | Azure 中用于处理和分析流数据的基于云的实时分析服务。 |
Google Cloud Dataflow | GCP 上的完全托管流处理和批处理服务。 |
Apache Kafka | 一个分布式流平台,既可以作为流数据的源,也可以作为流数据的接收器。 |
Apache Spark Streaming | 一个实时数据处理框架,是 Apache Spark 生态系统的一部分。 |
Apache Flink | 一种流处理框架,支持事件时间处理和各种接收器连接器。 |
表 7.5 – 不同的流数据服务
在下一节中,我们将使用最流行的流数据接收器之一 Kafka,了解在流接收器中写入数据的过程。
流数据接收器示例
首先,让我们对 Apache Kafka 的主要组件有一个初步了解:
-
代理是构成 Kafka 集群的核心服务器。它们处理消息的存储和管理。每个代理都有一个唯一的 ID。代理负责在集群中复制数据,以确保容错能力。
-
主题是 Kafka 中用于组织和分类消息的主要抽象。它们就像数据库中的表或文件系统中的文件夹。消息被发布到特定的主题,并从这些主题中读取。主题可以进行分区,以实现可扩展性和并行处理。
-
分区是 Kafka 中的并行单元。每个主题被划分为一个或多个分区,这样可以实现数据的分布式存储和处理。分区中的消息是有序且不可变的。
-
生产者是将消息发布(写入)到 Kafka 主题的客户端应用程序。它们可以选择将消息发送到哪个分区,或使用分区策略。生产者负责序列化、压缩数据,并在各个分区之间进行负载均衡。
-
消费者是从 Kafka 主题中订阅(读取)消息的客户端应用程序。它们可以从主题的一个或多个分区读取消息,并跟踪它们已经消费的消息。
-
ZooKeeper用于管理和协调 Kafka 代理。它维护关于 Kafka 集群的元数据。Kafka 的新版本正逐步去除对 ZooKeeper 的依赖。
现在我们已经对 Kafka 的主要组件有了更好的理解,接下来我们将开始我们的逐步指南。为了完成这个示例,我们需要安装几个组件,请跟随我一起完成整个过程。为了简化这个过程,我们将使用 Docker,因为它可以让你通过 docker-compose.yml
文件定义整个环境,轻松地设置 Kafka 和 ZooKeeper,并进行最小的配置。这避免了手动在本地机器上安装和配置每个组件的需要。请按照以下步骤操作:
-
按照公共文档下载 Docker:
docs.docker.com/desktop/install/mac-install/
。 -
接下来,使用 Docker 设置 Kafka。为此,让我们查看位于
github.com/PacktPublishing/Python-Data-Cleaning-and-Preparation-Best-Practices/blob/main/chapter07/setup/docker-compose.yml
的docker-compose.yml
文件。 -
此 Docker Compose 配置使用 Docker Compose 文件格式的第 3 版设置了一个简单的 Kafka 和 Zookeeper 环境。该配置定义了两个服务:
zookeeper
和kafka
。 -
Zookeeper 使用
confluentinc/cp-zookeeper:latest
镜像。它将主机机器的端口2181
映射到容器的端口2181
,用于客户端连接。ZOOKEEPER_CLIENT_PORT
环境变量设置为2181
,指定 Zookeeper 将侦听客户端请求的端口:version: '3' services: zookeeper: image: confluentinc/cp-zookeeper:latest ports: - "2181:2181" environment: ZOOKEEPER_CLIENT_PORT: 2181
-
Kafka 使用
confluentinc/cp-kafka:latest
镜像。它将主机机器的端口9092
映射到容器的端口9092
,用于外部客户端连接:kafka: image: confluentinc/cp-kafka:latest ports: - "9092:9092" environment: KAFKA_BROKER_ID: 1 KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092 KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
这里有一些配置 Kafka 的关键环境变量:
-
KAFKA_BROKER_ID
设置为1
,在 Kafka 集群中唯一标识此代理。 -
KAFKA_ZOOKEEPER_CONNECT
指向 Zookeeper 服务 (zookeeper:2181
),允许 Kafka 连接到 Zookeeper 管理集群元数据。 -
KAFKA_ADVERTISED_LISTENERS
广告两个监听器:-
PLAINTEXT://kafka:29092
用于 Docker 内部网络通信。 -
PLAINTEXT_HOST://localhost:9092
用于来自 Docker 网络外部的连接(例如,来自主机机器)
-
-
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP
确保两个公布的监听器使用PLAINTEXT
协议,意味着没有加密或认证。 -
KAFKA_INTER_BROKER_LISTENER_NAME
设置为PLAINTEXT
,指定 Kafka 代理之间将使用哪个监听器进行通信。 -
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR
设置为1
,表示偏移主题(用于存储消费者组偏移量)不会跨多个代理复制,这在单代理设置中很典型。
这种设置非常适合本地开发或测试,您需要一个简单的单节点 Kafka 环境,而无需多节点生产级集群的复杂性。现在,我们准备运行容器。
-
-
让我们运行 Docker 容器以启动 Kafka 和 Zookeeper。在您的终端中,输入以下命令:
docker-compose up –d
这将获取 Kafka 和 Zookeeper 镜像,并将它们安装在您的环境中。您应该在终端上看到类似以下的输出:
[+] Running 3/3 ✔ Network setup_default Created 0.0s ✔ Container setup-kafka-1 Started 0.7s ✔ Container setup-zookeeper-1 Started 0.6s
Kafka 生产者
现在,让我们回到 Python IDE,看看如何将数据推送到 Kafka 生产者。为此,我们将从 MongoDB 读取数据,并将其传递到 Kafka。你可以在此处找到代码:github.com/PacktPublishing/Python-Data-Cleaning-and-Preparation-Best-Practices/blob/main/chapter07/4a.kafka_producer.py
。让我们开始吧:
-
首先,让我们导入所需的库:
from pymongo import MongoClient from confluent_kafka import Producer import json
-
接下来,定义 MongoDB 连接:
mongo_client = MongoClient('mongodb://localhost:27017') db = mongo_client['no_sql_db'] collection = db['best_collection_ever']
在这里,
MongoClient('mongodb://localhost:27017')
连接到本地运行的 MongoDB 实例,默认端口为27017
。这将创建一个客户端对象,允许与数据库进行交互。然后,db = mongo_client['no_sql_db']
从 MongoDB 实例中选择no_sql_db
数据库。最后,collection = db['best_collection_ever']
从no_sql_db
数据库中选择best_collection_ever
集合。 -
让我们进行 Kafka 生产者配置,创建一个带有指定配置的 Kafka 生产者对象。该生产者将用于将消息(在此案例中为 MongoDB 文档)发送到 Kafka 主题:
kafka_config = { 'bootstrap.servers': 'localhost:9092' } producer = Producer(kafka_config)
-
以下函数是一个回调函数,当 Kafka 生产者完成发送消息时将被调用。它检查消息传递过程中是否出现错误,并打印指示成功或失败的消息。此函数提供了有关消息是否成功发送到 Kafka 的反馈,对于调试和监控非常有用:
def delivery_report(err, msg): if err is not None: print(f'Message delivery failed: {err}') else: print(f'Message delivered to {msg.topic()} [{msg.partition()}]')
-
从 MongoDB 读取并将文档通过
collection.find()
发送到 Kafka:message = json.dumps(document, default=str) producer.produce('mongodb_topic', alue=message.encode('utf-8'), callback=delivery_report) producer.poll(0)
前面的代码遍历了
best_collection_ever
集合中的每个文档。find()
方法从集合中检索所有文档。然后,message = json.dumps(document, default=str)
将每个 MongoDB 文档(一个 Python 字典)转换为 JSON 字符串。default=str
参数通过将无法序列化为 JSON 的数据类型转换为字符串,处理这些数据类型。接下来,producer.produce('mongodb_topic', value=message.encode('utf-8'), callback=delivery_report)
将 JSON 字符串作为消息发送到mongodb_topic
Kafka 主题。消息采用 UTF-8 编码,delivery_report
函数作为回调函数,用于处理投递确认。最后,producer.poll(0)
确保 Kafka 生产者处理投递报告和其他事件。这是保持生产者活跃和响应所必需的。 -
这确保生产者队列中的所有消息在脚本退出前都被发送到 Kafka。如果没有这一步骤,可能会有未发送的消息残留在队列中:
producer.flush()
运行此脚本后,你应该会看到以下打印语句:
Message delivered to mongodb_topic [0]
Message delivered to mongodb_topic [0]
Message delivered to mongodb_topic [0]
Message delivered to mongodb_topic [0]
Message delivered to mongodb_topic [0]
Message delivered to mongodb_topic [0]
Message delivered to mongodb_topic [0]
Message delivered to mongodb_topic [0]
到目前为止,我们已经连接到 MongoDB 数据库,读取了集合中的文档,并将这些文档作为消息发送到 Kafka 主题。
Kafka 消费者
接下来,让我们运行消费者,以便它能够从 Kafka 生产者消费消息。完整代码可以在 github.com/PacktPublishing/Python-Data-Cleaning-and-Preparation-Best-Practices/blob/main/chapter07/4b.kafka_consumer.py
找到:
-
让我们先导入所需的库:
from confluent_kafka import Consumer, KafkaError import json import time
-
接下来,我们必须创建 Kafka 消费者配置,指定要连接的 Kafka broker。这里,它连接到运行在本地主机上的 Kafka broker,端口为
9092
。在这种情况下,group.id
设置消费者组 ID,这使得多个消费者能够协调并共享处理来自主题消息的工作。消息将在同一组的消费者之间分发。接下来,auto.offset.reset
定义了在 Kafka 中没有初始偏移量或当前偏移量不存在时的行为。将此设置为earliest
表示消费者将从主题中最早的可用消息开始读取:consumer_config = { 'bootstrap.servers': 'localhost:9092', 'group.id': 'mongodb_consumer_group', 'auto.offset.reset': 'earliest' }
-
现在,我们将实例化一个 Kafka 消费者,并使用之前指定的配置。这里,
consumer.subscribe(['mongodb_topic'])
将消费者订阅到mongodb_topic
Kafka 主题。这意味着消费者将接收来自此主题的消息:consumer = Consumer(consumer_config) consumer.subscribe(['mongodb_topic'])
-
设置消费者运行的时长(以秒为单位):
run_duration = 10 # For example, 10 seconds start_time = time.time() print("Starting consumer...")
-
以下代码开始一个无限循环,直到显式中断。这个循环不断地轮询 Kafka 是否有新消息。这里,
if time.time() - start_time > run_duration
检查消费者是否已经运行超过了指定的run_duration
。如果是,它会打印一条消息并退出循环,停止消费者:while True: if time.time() - start_time > run_duration: print("Time limit reached, shutting down consumer.") break msg = consumer.poll(1.0) if msg is None: continue if msg.error(): if msg.error().code() == KafkaError._PARTITION_EOF: print('Reached end of partition') else: print(f'Error: {msg.error()}') else: document = json.loads(msg.value().decode('utf-8')) print(f'Received document: {document}') consumer.close() print("Consumer closed.")
运行上述代码后,您应该会看到以下打印输出:
Starting consumer...
Received document: {'_id': '66d833ec27bc08e40e0537b4', 'name': 'Alice', 'age': 25}
Received document: {'_id': '66d833ec27bc08e40e0537b5', 'name': 'Bob', 'age': 30}
Received document: {'_id': '66d833ec27bc08e40e0537b6', 'name': 'Charlie', 'age': 22}
Received document: {'_id': '66d835aa1798a2275cecaba8', 'name': 'Alice', 'age': 25, 'email': 'alice@example.com'}
Received document: {'_id': '66d835aa1798a2275cecaba9', 'name': 'Bob', 'age': 30, 'address': '123 Main St'}
Received document: {'_id': '66d835aa1798a2275cecabaa', 'name': 'Charlie', 'age': 22, 'hobbies': ['reading', 'gaming']}
Received document: {'_id': '66d835aa1798a2275cecabab', 'name': 'David', 'age': 40, 'email': 'david@example.com', 'address': '456 Elm St', 'active': True}
Received document: {'_id': '66d835aa1798a2275cecabac', 'name': 'Eve', 'age': 35, 'email': 'eve@example.com', 'phone': '555-1234'}
本示例的目标是向您展示如何从 MongoDB 等 NoSQL 数据库中持续读取数据,并通过 Kafka 实时流式传输到其他系统。Kafka 充当消息系统,允许数据生产者(例如 MongoDB)与数据消费者解耦。此示例还说明了如何分阶段处理数据,从而实现可扩展和灵活的数据管道。
从实际应用场景来看,假设我们正在构建一个拼车应用。实时处理事件,例如乘车请求、取消请求和司机状态,对于高效地将乘客与司机匹配至关重要。MongoDB 存储这些事件数据,如乘车请求和司机位置,而 Kafka 将事件流式传输到各种微服务。这些微服务随后处理这些事件以做出决策,例如将司机分配给乘客。通过使用 Kafka,系统变得高度响应、可扩展且具备弹性,因为它将事件生产者(例如乘车请求)与消费者(例如司机分配逻辑)解耦。
总结我们迄今为止所看到的内容,与涉及具有定义模式的结构化数据的关系型接收端不同,Kafka 可以作为数据摄取的缓冲区或中介,允许解耦和可扩展的数据管道。NoSQL 接收端通常处理非结构化或半结构化数据,类似于 Kafka 对消息格式的灵活性。Kafka 处理高吞吐量数据流的能力与 NoSQL 数据库的可扩展性和灵活性相辅相成。
为了清理到目前为止使用的所有资源,请执行清理脚本:github.com/PacktPublishing/Python-Data-Cleaning-and-Preparation-Best-Practices/blob/main/chapter07/setup/cleanup_script.sh
。
在下一部分,我们将深入探讨流式数据接收端所看到的文件格式。
流式数据接收端的文件类型
流式数据接收端主要处理消息或事件,而不是传统的文件存储。通过流式数据接收端传输的数据通常采用 JSON、Avro 或二进制等格式。这些格式在流式场景中被广泛用于数据的序列化和编码。它们高效并且支持模式演化。在本章的NoSQL 数据库部分,我们深入探讨了 JSON 文件格式。在这里,我们将讨论 Avro 和二进制格式。
Apache Avro 是一个在 Apache Hadoop 项目中开发的二进制序列化格式。它使用模式来定义数据结构,从而实现高效的序列化和反序列化。Avro 以其紧凑的二进制表示而著称,提供快速的序列化和高效的存储。在流式场景中,最小化数据大小对于高效的网络传输至关重要。Avro 的紧凑二进制格式减少了数据大小,提升了带宽利用率。Avro 还支持模式演化,允许数据结构随时间变化而不需要同时更新所有组件。Avro 的基于模式的方法使得不同系统和语言之间能够实现互操作性,适用于多样的生态系统。我们来看一个 Avro 文件示例:
{
"type": "record",
"name": "SensorData",
"fields": [
{"name": "sensor_id", "type": "int"},
{"name": "timestamp", "type": "long"},
{"name": "value", "type": "float"},
{"name": "status", "type": "string"}
]
}
二进制格式使用紧凑的二进制数据表示方式,从而实现高效的存储和传输。可以根据具体需求使用各种二进制协议,例如 Google 的协议缓冲区(protobuf)或 Apache Thrift。二进制格式通过最小化传输数据的大小,在流式场景中减少带宽使用。二进制序列化和反序列化通常比基于文本的格式更快,这在高吞吐量流式环境中至关重要。我们来看一个 protobuf 的二进制文件示例:
syntax = "proto3";
message SensorData {
int32 sensor_id = 1;
int64 timestamp = 2;
float value = 3;
string status = 4;
}
在流式数据接收端,选择 JSON、Avro 还是二进制格式取决于流式使用场景的具体需求,包括互操作性、模式演化和数据大小等因素。
到目前为止,我们已经讨论了数据工程师和数据科学家使用的最常见数据存储系统,以及我们通常会遇到的不同文件类型。在接下来的章节中,我们将总结所有讨论过的数据存储系统和文件类型,以及它们的优缺点和最佳使用时机。
哪种数据存储系统最适合我的使用场景?
让我们总结一下关于不同数据存储系统的知识,并深入理解何时使用哪种系统:
技术 | 优点 | 缺点 | 何时选择 | 使用场景 |
---|---|---|---|---|
关系型数据库 | ACID 属性确保数据一致性。成熟的查询语言(SQL)支持复杂查询。支持复杂的事务和连接操作。 | 对于读密集型工作负载的可扩展性有限。架构更改可能具有挑战性,并容易导致停机。可能无法水平扩展。 | 具有明确定义架构的结构化数据。需要维护数据实体之间关系的情况。 | 事务应用程序。处理结构化数据的企业应用程序。 |
NoSQL 数据库 | 灵活的架构,适用于半结构化或非结构化数据。可扩展性——水平扩展通常更容易。某些工作负载下具有高写入吞吐量。 | 缺乏标准化的查询语言,可能需要学习特定的 API。可能缺乏 ACID 一致性,而偏向最终一致性。对复杂事务的支持有限。 | 动态或不断变化的数据架构。快速开发和迭代。处理结构各异的大量数据。 | 用于内容管理的文档数据库。具有可变架构的实时应用程序。用于 Web 应用程序的 JSON 数据存储。 |
数据仓库 | 针对复杂分析和报表优化。高效的数据压缩和索引。适用于读密集型分析工作负载的可扩展性。 | 对于高吞吐量事务工作负载可能成本较高。实时查询可能存在较高延迟。可能需要专门的技能来维护和优化。 | 对大数据集进行分析处理。聚合和分析历史数据。 | 商业智能和报表工具。在 TB 级数据上运行复杂查询。 |
技术 | 优点 | 缺点 | 何时选择 | 使用场景 |
Lakehouse | 结合了数据湖和数据仓库特征的统一平台。提供可扩展的存储和计算资源。可以高效地横向扩展,以应对不断增长的数据集和处理需求。按需付费模式,使组织能够通过为所用资源付费来更有效地管理成本。这对于存储大量原始数据尤其有利。采用按需读取模式,允许存储未经转换的原始数据。 | 管理按需读取和按需写入模式的复杂性。根据实现方式和云服务提供商,Lakehouse 架构中存储、处理和管理数据的成本可能有所不同。组织需要仔细管理成本,以确保高效性。 | 在灵活性和事务能力之间的平衡。 | 实时分析与长期存储。任何工程、机器学习和分析用例 |
流数据存储 | 实现实时处理和流数据分析。水平扩展以处理大量的输入数据。构建事件驱动架构的核心组成部分。 | 实现和管理流数据存储可能很复杂。流数据的处理和持久化会引入一些延迟。根据所选解决方案,基础设施成本可能是一个考虑因素。 | 实时持续摄取和处理数据 | 物联网、实时分析应用场景、系统监控 |
表 7.6 – 所有数据存储的总结表,包括它们的优缺点和使用场景
在表 7.8中,用例列提供了更多的背景信息和实际示例,说明如何在实际场景中有效地应用每种数据存储技术。
从选择合适的数据存储技术到选择适当的文件类型,这是设计有效数据处理管道的关键步骤。一旦确定了数据存储的位置(数据存储),接下来就需要考虑数据如何存储(文件类型)。文件类型的选择会影响数据存储效率、查询性能、数据完整性以及与其他系统的互操作性。
解码文件类型以实现最佳使用
在选择数据存储时,选择合适的文件类型对优化数据存储、处理和检索至关重要。我们至今未讨论过的一种文件类型,但它非常重要,因为它作为其他文件格式的底层格式使用,那就是 Parquet 文件。
Parquet 是一种列式存储文件格式,旨在大数据和分析环境中实现高效的数据存储和处理。它是一个开放标准文件格式,提供高压缩比、列式存储和对复杂数据结构的支持等优点。Parquet 在 Apache Hadoop 生态系统中得到了广泛应用,并且被各种数据处理框架所支持。
Parquet 以列式格式存储数据,这意味着来自同一列的值会一起存储。这种设计对于分析工作负载非常有利,尤其是查询通常涉及选择一部分列时。Parquet 还支持多种压缩算法,用户可以选择最适合自己需求的算法,从而减少存储空间并提升查询性能。Parquet 文件还能处理模式演化,使得可以在不完全重写数据集的情况下添加或删除列。这一特性在数据模式演化的场景中尤为重要。由于其诸多优势,Parquet 已成为大数据生态系统中广泛采用并标准化的文件格式,为 Delta 和 Iceberg 等其他优化格式奠定了基础。
在讨论完 Parquet 文件后,我们现在可以比较常见的文件类型,以及它们的优缺点,并为不同数据存储提供选择建议:
文件类型 | 优点 | 缺点 | 何时选择 |
---|---|---|---|
JSON | 人类可读 | 与二进制格式相比文件较大,序列化/反序列化速度较慢 | 需要半结构化或人类可读数据 |
BSON | 紧凑的二进制格式,支持更丰富的数据类型 | 可能不像 JSON 那样易于人类阅读,并且在 MongoDB 以外的地方采用有限 | 存储和传输效率至关重要时选择 |
Parquet | 列式存储,适合分析,压缩和编码减少文件大小 | 不像 JSON 那样易于人类阅读,不能直接更新表格 – 需要重写 | 分析处理、数据仓储 |
Avro | 紧凑的二进制序列化基于模式,支持模式演化,跨不同系统互操作 | 与 JSON 相比,稍微不那么易于人类阅读 | 带宽高效的流处理和多语言支持 |
Delta | 提供 ACID 事务以确保数据一致性,适用于数据湖的高效存储格式,支持模式演化和时间旅行查询 | 文件比 Parquet 大 | 实时分析与长期存储 |
Hudi | 高效的增量数据处理,支持 ACID 事务以实现实时数据 | 文件比 Parquet 大 | 流数据应用和变更数据捕获 |
Iceberg | 支持模式演化、ACID 事务,优化存储格式如 Parquet | 文件比 Parquet 大 | 时间旅行查询和模式演化 |
文件类型 | 优点 | 缺点 | 何时选择 |
二进制格式 | 紧凑且高效的存储,快速序列化和反序列化 | 不易于人类阅读,支持的模式演化有限 | 在带宽使用和处理速度方面对效率要求高时选择 |
表 7.7 – 所有文件格式的汇总表,包括它们的优缺点和使用案例
在下一节中,我们将讨论分区,这是数据存储中一个重要的概念,尤其是在分布式存储系统的上下文中。尽管分区这一概念与数据湖、数据仓库和分布式文件系统密切相关,但它在更广泛的数据存储讨论中也具有重要意义。
导航分区
数据分区是一种技术,用于将大型数据集划分并组织成更小、更易管理的子集,称为分区。当将数据写入存储系统时,例如数据库或分布式存储系统,采用适当的数据分区策略对优化查询性能、数据检索和存储效率至关重要。数据存储系统中的分区,包括基于时间、地理位置和混合分区,在读操作、更新和写操作方面提供了多个好处:
-
在查询数据时,分区使得系统能够迅速跳过无关数据。例如,在基于时间的分区中,如果你只关心某一特定日期的数据,系统可以直接访问与该日期对应的分区,从而提高查询速度。它确保只扫描必要的分区,减少了需要处理的数据量。
-
分区可以简化更新,尤其是当更新集中在特定分区时。例如,如果你需要更新特定日期或地区的数据,系统可以隔离受影响的分区,从而减少更新操作的范围。
-
分区可以提高写操作的效率,特别是在附加数据时。新数据可以写入适当的分区,而不影响现有数据,从而使写入过程更简单、更快速。
-
分区支持并行处理。不同的分区可以并行读取或写入,从而更好地利用资源并加快整体处理速度。
-
分区提供了数据的逻辑组织方式。它简化了数据管理任务,如归档旧数据、删除过时记录或根据访问模式将特定分区迁移到不同的存储层次。
-
使用分区,你可以根据使用模式优化存储。例如,频繁访问的分区可以存储在高性能存储中,而不常访问的分区则可以存储在低成本存储中。
-
分区支持修剪,系统可以在查询执行过程中排除整个分区的考虑。这种修剪机制进一步加速了查询性能。
让我们深入了解不同的分区策略。
水平分区与垂直分区
在讨论数据库或分布式系统中的分区策略时,我们通常提到两种主要类型:水平分区和垂直分区。每种方法以不同的方式组织数据,以提高性能、可扩展性或可管理性。让我们先从水平分区开始。
水平分区,也称为分片,涉及将表的行分割成多个分区,每个分区包含数据的一个子集。这种方法通常用于通过将数据分布到多个服务器上来扩展数据库,其中每个分片保持相同的模式,但包含不同的行。例如,一个大型应用中的用户表可以根据用户 ID 进行分片,ID 1 到 10,000 在一个分区中,ID 10,001 到 20,000 在另一个分区中。这种策略使得系统能够处理比单台机器更大的数据集,从而提升大规模应用中的性能。
另一方面,垂直分区涉及将表的列分割成不同的分区,每个分区包含一部分列,但包含所有行。当不同的列以不同的频率被访问或更新时,这种策略是有效的,因为它通过最小化查询过程中处理的数据量来优化性能。例如,在用户资料表中,基本信息如姓名和电子邮件可以存储在一个分区中,而像用户头像这样的二进制大数据列则存储在另一个分区中。这使得针对特定列的查询可以访问更小、更高效的数据集,从而提升性能。
这两种策略可以结合使用,以满足数据库系统的特定需求,具体取决于数据结构和访问模式。实际上,在数据领域,水平分区比垂直分区更常见和广泛采用。这在需要处理大量数据、高流量或地理分散的用户的大规模分布式数据库和应用程序中特别如此。在接下来的部分,我们将看到一些水平分区的例子。
基于时间的分区
基于时间的分区涉及根据时间戳来组织数据。每个分区代表一个特定的时间区间,例如一天、一小时或一分钟。它有助于高效地检索历史数据和进行基于时间的聚合操作,同时还便于实施数据保留和归档策略。
在这个例子中,你将学习如何使用 Parquet 文件在本地笔记本电脑上创建基于时间的分区。你可以在这里找到完整的代码:github.com/PacktPublishing/Python-Data-Cleaning-and-Preparation-Best-Practices/blob/main/chapter07/5.time_based_partitioning.py
。按照以下步骤操作:
-
导入所需的库:
import os import pandas as pd import pyarrow as pa import pyarrow.parquet as pq from datetime import datetime
-
定义一个示例数据集,包含两列:
timestamp
和value
。该数据集表示带有时间戳和相应值的时间序列数据:data = { "timestamp": ["2022-01-01", "2022-01-01", "2022-01-02"], "value": [10, 15, 12] }
-
创建一个 pandas DataFrame:
df = pd.DataFrame(data)
-
将
timestamp
列转换为datetime
类型。这确保了时间戳作为日期时间对象进行处理,以便进行准确的基于时间的操作:df["timestamp"] = pd.to_datetime(df["timestamp"])
-
更新路径以存储数据。使用现有路径:
base_path = " path_to_write_data"
-
遍历 DataFrame,通过
timestamp
列的date
部分对行进行分组。将每个组转换为 PyArrow 表,并将其写入相应的分区路径,以 Parquet 格式存储:for timestamp, group in df.groupby(df["timestamp"].dt.date):
-
如果目录不存在,创建该目录:
os.makedirs(base_path, exist_ok=True) partition_path = os.path.join(base_path, str(timestamp)) table = pa.Table.from_pandas(group) pq.write_table(table, partition_path)
执行此脚本后,你将在基础目录中看到两个 Parquet 文件被创建——每周的每一天对应一个文件:
图 7.6 – 基于时间的分区输出
让我们来看一下另一种常见的分区策略,称为地理分区(geographic partitioning)。
地理分区
地理分区涉及根据地理属性(如地区、国家或城市)对数据进行划分。这种策略在处理地理空间数据或基于位置的分析时非常有价值。它能够快速、精准地检索与特定地理区域相关的数据,从而支持空间查询和分析。
这是一个示例,展示了如何在本地笔记本电脑上使用 Parquet 文件创建基于地理位置的分区。你可以在这里找到完整的代码:github.com/PacktPublishing/Python-Data-Cleaning-and-Preparation-Best-Practices/blob/main/chapter07/6.geo_partitioning.py
。按照以下步骤操作:
-
创建一个用于存储分区数据的基础目录:
base_directory = "/geo_data" os.makedirs(base_directory, exist_ok=True)
-
将每个组(特定地区的数据)转换为 PyArrow 表。然后,将表写入相应的路径:
geo_data = {"region": ["North", "South", "East"], "value": [10, 15, 12]} geo_df = pd.DataFrame(geo_data) for region, group in geo_df.groupby("region"):
-
在基础目录中为每个区域创建一个目录:
region_path = os.path.join(base_directory, region)
-
将组转换为 PyArrow 表,并将其写入分区路径:
table = pa.Table.from_pandas(group) pq.write_table(table, region_path)
-
执行此脚本后,你会在基础目录中看到三个 Parquet 文件被创建——每个文件对应数据中一个地理位置:
图 7.7 – 基于地理位置的分区输出
让我们来看一下最后一种常见的分区策略,称为混合分区。
注意
地理分区是一种基于类别分区的专门形式,它根据地理属性或空间标准来组织数据。类别分区是数据组织中的基本策略,它涉及根据特定的类别或属性对数据进行分组,例如客户人口统计、产品类型或交易特征。
混合分区
混合分区是指将多种分区策略结合起来,以优化特定使用场景的数据组织。例如,您可以先按时间进行分区,然后再根据某个键或地理位置进一步对每个时间段进行分区。它为处理复杂的查询模式和多样化的数据访问需求提供了灵活性。
下面是一个如何在本地笔记本电脑上使用 Parquet 文件创建混合分区的示例。您可以在这里找到完整的代码:github.com/PacktPublishing/Python-Data-Cleaning-and-Preparation-Best-Practices/blob/main/chapter07/7.hybrid_partitioning.py
。请按照以下步骤操作:
-
创建一个基础目录来存储分区数据:
base_directory = "/hybrid_data"
-
执行混合分区:
hybrid_data = { "timestamp": ["2022-01-01", "2022-01-01", "2022-01-02"], "region": ["North", "South", "East"], "value": [10, 15, 12]} hybrid_df = pd.DataFrame(hybrid_data) for (timestamp, region), group in hybrid_df.groupby( ["timestamp", "region"]):
-
在基础目录中为每个时间戳和地区组合创建一个目录:
timestamp_path = os.path.join(base_directory, str(timestamp)) os.makedirs(timestamp_path, exist_ok=True) timestamp_region_path = os.path.join( base_directory, str(timestamp), str(region))
-
将分组转换为 PyArrow 表并写入分区路径:
table = pa.Table.from_pandas(group) pq.write_table(table, timestamp_region_path)
-
执行此脚本后,您将在基础目录中看到创建了三个 Parquet 文件——两个是 2022 年 1 月 1 日的文件,一个是 2022 年 1 月 2 日的文件:
图 7.8 – 混合分区输出
请记住
到目前为止,我们已经探讨了多种分区类型,例如基于时间和地理位置的分区。然而,请记住,您可以根据数据、使用场景以及表的查询模式,选择任何有意义的列作为分区列。
现在我们已经讨论了不同的分区策略,接下来我们要讨论如何选择用于分区的数据列。
选择分区策略的考虑因素
为数据选择合适的分区策略需要考虑多种因素,以优化性能、查询效率和数据管理。以下是选择分区策略时的一些关键考虑因素:
-
查询模式:根据您的应用程序或分析平台最常执行的查询类型来选择分区策略。
-
数据分布:确保分区均匀分布,以防止数据热点和资源争用。
-
数据大小:考虑每个分区中存储的数据量。较小的分区可以提高查询性能,但过多的小分区可能会增加管理开销。
-
查询复杂性:某些查询可能会从混合分区中受益,特别是那些涉及多个属性的查询。
-
可扩展性:分区应支持未来的可扩展性,并能够随着时间推移适应数据增长。
数据分区是一个关键的架构决策,它可能会显著影响数据处理管道的效率和性能。通过采用适当的数据分区策略,可以确保数据按照查询模式进行组织,从而最大化所选数据接收技术的好处。
在接下来的部分,我们将通过描述一个实际案例并逐步讲解所有定义与数据接收和文件类型相关的最佳策略,将本章所学内容付诸实践。
设计一个在线零售数据平台
一家在线零售商希望创建一个分析平台,用于收集和分析其电子商务网站生成的所有数据。该平台旨在提供实时数据处理和分析能力,以改善客户体验、优化业务运营并推动在线零售业务的战略决策。
在与团队的长时间讨论后,我们确定了四个主要需求需要考虑:
-
处理大量交易数据:平台需要高效地摄取和转换大量交易数据。这需要考虑可扩展性、高吞吐量和成本效益。
-
提供实时洞察:业务分析师需要即时访问从交易数据中得出的实时洞察。平台应支持实时数据处理和分析,以支持及时决策。
-
需要结合批处理和流数据摄取,以处理实时网站数据和慢速更新的批量客户数据。
-
使用 AWS 作为云服务提供商:选择 AWS 作为云服务提供商,源于该零售商目前正在使用其他 AWS 服务,且希望继续使用同一提供商。
让我们快速看一下如何解决这些需求:
-
选择合适的数据接收技术:
-
思考过程:由于 Lakehouse 架构能够处理大规模数据,具备可扩展性、高吞吐量和成本效益,因此它是满足数据平台需求的理想解决方案。它利用分布式存储和计算资源,实现高效的数据摄取和转换。此外,该架构支持实时数据处理和分析,使业务分析师能够即时访问交易数据,以便及时决策。通过结合批处理和流数据摄取,Lakehouse 可以将实时网站数据与批量更新的客户数据无缝集成。
-
选择:选择 AWS 上的 Lakehouse 解决方案,因为其具有可扩展性、成本效益,并能够与其他 AWS 服务无缝集成。AWS 与 Lakehouse 架构兼容。
-
-
评估并选择数据文件格式:
-
数据特征:客户数据包括结构化的交易记录,包括客户 ID、产品 ID、购买金额、时间戳和地理位置。流数据包括客户 ID 和其他网站指标,例如每个客户当前在网站上浏览的内容。
-
选择:选择 Delta 文件格式是因为其事务能力和 ACID 合规性。它还支持批量和流式工作负载。
-
-
实现批量和流数据的摄取:
-
数据摄取:ETL 过程旨在将传入的交易数据转换为 Delta 文件。实时交易数据通过 AWS Kinesis 进行流式传输,以便即时处理,并作为 Delta 文件存储,同时来自不同系统的批量数据也被整合。
-
分区逻辑:批量和流数据正在处理并存储为 Delta 文件。流数据在写入时按日期进行分区。接下来,进行转换和数据整合,然后将其存储为最终的分析表。
-
-
为分析表定义分区策略:
-
查询模式:分析师通常基于特定时间段或基于产品类别的某些表进行数据查询。
-
选择:正如我们在选择分区策略的考虑因素一节中所学,我们需要考虑用户查询表的方式。为了获得最佳的查询读取性能,必须实现基于时间和类别的分区。在用户经常查询的分析表中,数据按日期分区,并进一步按产品类别进行分区。
-
-
监控与优化:
-
性能监控:定期使用 AWS 监控和日志服务监控查询性能、流式传输吞吐量和资源利用率。
-
优化:根据观察到的性能和变化的数据模式,持续优化批量和流组件。
-
架构演变:确保 Delta 架构能够适应流数据的变化,并与现有的批量数据保持兼容。
-
通过这种架构,在线零售分析平台能够以有效且具有成本优化的方式处理批量和实时数据。
总结
在本章中,我们重点讨论了数据写入操作的设计和优化组件。我们讨论了如何选择合适的数据接收技术,文件格式如何显著影响存储效率和查询性能,以及为什么选择正确的文件格式对你的使用案例至关重要。最后,我们讨论了为什么数据分区对于优化查询性能和资源利用率至关重要。
在下一章中,我们将开始转换已写入数据接收器的数据,通过检测和处理异常值和缺失值,为下游分析做好准备。
第二部分:下游数据清洗——消费结构化数据
本部分深入探讨了为分析准备结构化数据所需的清理过程,重点处理更精细数据集中常见的数据挑战。它提供了处理缺失值和离群值的实用技巧,通过归一化和标准化确保数据的一致性,并有效处理类别特征。此外,还介绍了处理时间序列数据的专业方法,这是一种常见但复杂的数据类型。通过掌握这些下游清理和准备技巧,读者将能够将结构化数据转化为可操作的见解,供高级分析使用。
本部分包含以下章节:
-
第八章*,检测和处理缺失值与离群值*
-
第九章*,归一化与标准化*
-
第十章*,处理类别特征*
-
第十一章*,处理时间序列数据*