Pandas 秘籍第三版(二)
原文:
annas-archive.org/md5/dbf45b033e25cfae0fd6c82aa3a4578a译者:飞龙
第四章:pandas I/O 系统
到目前为止,我们一直在用数据创建pd.Series和pd.DataFrame对象,内联处理数据。虽然这样做有助于建立理论基础,但在生产代码中,很少有用户会这样做。相反,用户会使用 pandas 的 I/O 函数来从各种格式读取/写入数据。
I/O,指的是输入/输出,通常指从常见的数据格式(如 CSV、Microsoft Excel、JSON 等)中读取和写入数据的过程。当然,数据存储并不是只有一种格式,许多选项在性能、存储大小、第三方集成、可访问性和/或普及性之间进行权衡。有些格式假设数据是结构化且严格定义的(SQL 可能是最极端的例子),而其他格式则可以用于表示半结构化数据,这些数据不局限于二维结构(JSON 就是一个很好的例子)。
pandas 能够与多种数据格式进行交互,这也是它的最大优势之一,使得 pandas 成为数据分析工具中的瑞士军刀。无论是与 SQL 数据库、Microsoft Excel 文件集、HTML 网页,还是通过 JSON 传输数据的 REST API 端点交互,pandas 都能够胜任帮助你构建数据的统一视图。因此,pandas 被认为是 ETL 领域中的一个流行工具。
在本章中,我们将介绍以下操作方法:
-
CSV – 基本的读写操作
-
CSV – 读取大型文件的策略
-
Microsoft Excel – 基本的读写操作
-
Microsoft Excel – 在非默认位置查找表格
-
Microsoft Excel – 层次化数据
-
使用 SQLAlchemy 的 SQL
-
使用 ADBC 的 SQL
-
Apache Parquet
-
JSON
-
HTML
-
Pickle
-
第三方 I/O 库
CSV – 基本的读写操作
CSV,代表逗号分隔值,是最常见的数据交换格式之一。虽然没有正式的标准来定义什么是 CSV 文件,但大多数开发者和用户通常认为它是一个纯文本文件,其中文件中的每一行表示一条数据记录,每条记录的字段之间有分隔符,用于表示一条记录的结束和下一条记录的开始。最常用的分隔符是逗号(因此叫做逗号分隔值),但这并不是硬性要求;有时我们也会看到使用管道符(|)、波浪符(~)或反引号(`)作为分隔符的 CSV 文件。如果期望分隔符字符出现在某条记录内,通常会对单个记录(或所有记录)加上引号,以确保正确解析。
例如,假设一个 CSV 文件使用管道分隔符,其内容如下:
`column1|column2 a|b|c`
第一行将只读取两列数据,而第二行将包含三列数据。假设我们希望记录 ["a|b", "c"] 出现在第二行,就需要进行适当的引号处理:
`column1|column2 "a|b"|c`
上述规则相对简单,可以轻松地写入 CSV 文件,但反过来这也使得读取 CSV 文件变得更加困难。CSV 格式没有提供元数据(例如,什么分隔符、引号规则等),也没有提供关于数据类型的任何信息(例如,哪些数据应位于 X 列)。这使得 CSV 读取器必须自己搞清楚这些内容,从而增加了性能开销,并且很容易导致数据误解。作为一种基于文本的格式,与像 Apache Parquet 这样的二进制格式相比,CSV 也是一种低效的数据存储方式。通过压缩 CSV 文件(以牺牲读/写性能为代价),可以在一定程度上弥补这些问题,但通常来说,CSV 在 CPU 效率、内存使用和无损性方面是最差的格式之一。
尽管存在这些缺点,CSV 格式已经存在很长时间,并且不会很快消失,因此了解如何使用 pandas 读取和写入此类文件是很有帮助的。
如何做到这一点
让我们从一个简单的 pd.DataFrame 开始。基于我们在第三章中学到的数据类型,我们知道 pandas 默认使用的数据类型并不理想,因此我们将使用 pd.DataFrame.convert_dtypes 方法,并使用 dtype_backend="numpy_nullable" 参数来构建这个以及以后所有的 pd.DataFrame 对象。
`df = pd.DataFrame([ ["Paul", "McCartney", 1942], ["John", "Lennon", 1940], ["Richard", "Starkey", 1940], ["George", "Harrison", 1943], ], columns=["first", "last", "birth"]) df = df.convert_dtypes(dtype_backend="numpy_nullable") df`
`first last birth 0 Paul McCartney 1942 1 John Lennon 1940 2 Richard Starkey 1940 3 George Harrison 1943`
要将这个 pd.DataFrame 写入到 CSV 文件中,我们可以使用 pd.DataFrame.to_csv 方法。通常,您提供的第一个参数是文件名,但在这个例子中,我们将使用 io.StringIO 对象来代替。io.StringIO 对象类似于一个文件,但不会将任何内容保存到磁盘上。相反,它完全在内存中管理文件内容,无需清理,也不会在文件系统中留下任何东西:
`import io buf = io.StringIO() df.to_csv(buf) print(buf.getvalue())`
`,first,last,birth 0,Paul,McCartney,1942 1,John,Lennon,1940 2,Richard,Starkey,1940 3,George,Harrison,1943`
现在我们有了一个包含 CSV 数据的“文件”,我们可以使用 pd.read_csv 函数将这些数据读回来。然而,默认情况下,pandas 中的 I/O 函数将使用与 pd.DataFrame 构造函数相同的默认数据类型。幸运的是,我们仍然可以使用 dtype_backend="numpy_nullable" 参数与 I/O 读取函数一起使用,从而避免这个问题:
`buf.seek(0) pd.read_csv(buf, dtype_backend="numpy_nullable")`
`Unnamed: 0 first last birth 0 0 Paul McCartney 1942 1 1 John Lennon 1940 2 2 Richard Starkey 1940 3 3 George Harrison 1943`
有趣的是,pd.read_csv 的结果并不完全与我们最初的 pd.DataFrame 匹配,因为它包含了一个新增的 Unnamed: 0 列。当你调用 pd.DataFrame.to_csv 时,它会将行索引和列一起写入到 CSV 文件中。CSV 格式不允许你存储任何额外的元数据来指示哪些列应映射到行索引,哪些列应表示 pd.DataFrame 中的列,因此 pd.read_csv 假设所有内容都是列。
您可以通过让 pd.read_csv 知道 CSV 文件中的第一列数据应该形成行索引,并使用 index_col=0 参数来纠正这种情况:
`buf.seek(0) pd.read_csv(buf, dtype_backend="numpy_nullable", index_col=0)`
`first last birth 0 Paul McCartney 1942 1 John Lennon 1940 2 Richard Starkey 1940 3 George Harrison 1943`
或者,您可以通过 pd.DataFrame.to_csv 的 index=False 参数避免一开始就写入索引:
`buf = io.StringIO() df.to_csv(buf, index=False) print(buf.getvalue())`
`first,last,birth Paul,McCartney,1942 John,Lennon,1940 Richard,Starkey,1940 George,Harrison,1943`
还有更多……
正如本节开头提到的,CSV 文件使用引号来防止字段中出现的分隔符与其预期用途(即指示新记录的开始)混淆。幸运的是,pandas 默认以一种相当理智的方式处理这一点,我们可以通过一些新示例数据来看到这一点:
`df = pd.DataFrame([ ["McCartney, Paul", 1942], ["Lennon, John", 1940], ["Starkey, Richard", 1940], ["Harrison, George", 1943], ], columns=["name", "birth"]) df = df.convert_dtypes(dtype_backend="numpy_nullable") df`
`name birth 0 McCartney, Paul 1942 1 Lennon, John 1940 2 Starkey, Richard 1940 3 Harrison, George 1943`
现在我们只有一个包含逗号的name列,可以看到 pandas 会对该字段加上引号,表示逗号的使用是数据的一部分,而不是新记录的开始:
`buf = io.StringIO() df.to_csv(buf, index=False) print(buf.getvalue())`
`name,birth "McCartney, Paul",1942 "Lennon, John",1940 "Starkey, Richard",1940 "Harrison, George",1943`
我们也可以选择使用不同的分隔符,这可以通过 sep= 参数进行切换:
`buf = io.StringIO() df.to_csv(buf, index=False, sep="|") print(buf.getvalue())`
`name|birth McCartney, Paul|1942 Lennon, John|1940 Starkey, Richard|1940 Harrison, George|1943`
我们还提到,尽管 CSV 文件本质上是纯文本格式,您也可以通过压缩它们来节省存储空间。最简单的方法是提供带有常见压缩文件扩展名的文件名参数,例如通过 df.to_csv("data.csv.zip")。如果需要更明确的控制,您可以使用 compression= 参数。
为了看到这一点的实际效果,让我们使用一个更大的 pd.DataFrame:
`df = pd.DataFrame({ "col1": ["a"] * 1_000, "col2": ["b"] * 1_000, "col3": ["c"] * 1_000, }) df = df.convert_dtypes(dtype_backend="numpy_nullable") df.head()`
`col1 col2 col3 0 a b c 1 a b c 2 a b c 3 a b c 4 a b c`
请注意将文件写出为纯文本 CSV 文件时所使用的字节数:
`buf = io.StringIO() df.to_csv(buf, index=False) len(buf.getvalue())`
`6015`
使用 compression="gzip",我们可以生成一个占用存储空间更少的文件:
`buf = io.BytesIO() df.to_csv(buf, index=False, compression="gzip") len(buf.getvalue())`
`69`
这里的权衡是,虽然压缩文件需要更少的磁盘存储空间,但它们需要更多的 CPU 工作来压缩或解压缩文件内容。
CSV – 读取大文件的策略
处理大型 CSV 文件可能具有挑战性,尤其是在它们耗尽计算机内存时。在许多现实世界的数据分析场景中,您可能会遇到无法通过单次读取操作处理的数据集。这可能导致性能瓶颈和 MemoryError 异常,使分析变得困难。不过,不必担心!有很多方法可以提高处理文件的效率。
在这个例子中,我们将展示如何使用 pandas 查看 CSV 文件的部分内容,以了解正在推断的数据类型。通过这个理解,我们可以指示 pd.read_csv 使用更高效的数据类型,从而大大提高内存使用效率。
如何操作
在这个例子中,我们将查看钻石数据集。这个数据集对于现代计算机来说并不算特别大,但让我们假设这个文件比实际要大得多,或者假设我们的机器内存有限,以至于正常的 read_csv 调用会引发 MemoryError。
首先,我们将查看数据集中的前 1,000 行,通过 nrows=1_000 来了解文件中的内容:
`df = pd.read_csv("data/diamonds.csv", dtype_backend="numpy_nullable", nrows=1_000) df`
`carat cut color clarity depth table price x y z 0 0.23 Ideal E SI2 61.5 55.0 326 3.95 3.98 2.43 1 0.21 Premium E SI1 59.8 61.0 326 3.89 3.84 2.31 2 0.23 Good E VS1 56.9 65.0 327 4.05 4.07 2.31 3 0.29 Premium I VS2 62.4 58.0 334 4.2 4.23 2.63 4 0.31 Good J SI2 63.3 58.0 335 4.34 4.35 2.75 … … … … … … … … … … … 995 0.54 Ideal D VVS2 61.4 52.0 2897 5.3 5.34 3.26 996 0.72 Ideal E SI1 62.5 55.0 2897 5.69 5.74 3.57 997 0.72 Good F VS1 59.4 61.0 2897 5.82 5.89 3.48 998 0.74 Premium D VS2 61.8 58.0 2897 5.81 5.77 3.58 999 1.12 Premium J SI2 60.6 59.0 2898 6.68 6.61 4.03 1000 rows × 10 columns`
pd.DataFrame.info 方法应该能让我们了解这个子集使用了多少内存:
`df.info()`
`<class 'pandas.core.frame.DataFrame'> RangeIndex: 1000 entries, 0 to 999 Data columns (total 10 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 carat 1000 non-null Float64 1 cut 1000 non-null string 2 color 1000 non-null string 3 clarity 1000 non-null string 4 depth 1000 non-null Float64 5 table 1000 non-null Float64 6 price 1000 non-null Int64 7 x 1000 non-null Float64 8 y 1000 non-null Float64 9 z 1000 non-null Float64 dtypes: Float64(6), Int64(1), string(3) memory usage: 85.1 KB`
您看到的具体内存使用量可能取决于您使用的 pandas 版本和操作系统,但假设我们使用的 pd.DataFrame 大约需要 85 KB 的内存。如果我们有 10 亿行数据,而不是只有 1,000 行,那仅仅存储这个 pd.DataFrame 就需要 85 GB 的内存。
那么我们如何解决这个问题呢?首先,值得更仔细地查看已经推断出的数据类型。price列可能是一个立即引起我们注意的列;它被推断为pd.Int64Dtype(),但我们很可能不需要 64 位来存储这些信息。关于汇总统计的更多细节将在第五章中讨论,算法及其应用,但现在,让我们先看看pd.Series.describe,看看 pandas 可以为我们提供关于这个列的信息:
`df["price"].describe()`
`count 1000.0 mean 2476.54 std 839.57562 min 326.0 25% 2777.0 50% 2818.0 75% 2856.0 max 2898.0 Name: price, dtype: Float64`
最小值为 326,最大值为 2,898。这些值可以安全地适配pd.Int16Dtype(),与pd.Int64Dtype()相比,这将节省大量内存。
让我们还来看看一些浮点类型,从指数开始:
`df["carat"].describe()`
`count 1000.0 mean 0.68928 std 0.195291 min 0.2 25% 0.7 50% 0.71 75% 0.79 max 1.27 Name: carat, dtype: Float64`
这些值的范围从 0.2 到 1.27,除非我们预计要进行许多小数点计算,否则 32 位浮点数据类型提供的 6 到 9 位小数精度应该足够使用。
对于这个配方,我们假设 32 位浮动类型可以用于所有其他浮动类型。告诉pd.read_csv我们希望使用更小的数据类型的一个方法是使用dtype=参数,并通过字典将列名映射到所需的数据类型。由于我们的dtype=参数将覆盖所有列,因此我们也可以省略dtype_backend="numpy_nullable",因为它是多余的:
`df2 = pd.read_csv( "data/diamonds.csv", nrows=1_000, dtype={ "carat": pd.Float32Dtype(), "cut": pd.StringDtype(), "color": pd.StringDtype(), "clarity": pd.StringDtype(), "depth": pd.Float32Dtype(), "table": pd.Float32Dtype(), "price": pd.Int16Dtype(), "x": pd.Float32Dtype(), "y": pd.Float32Dtype(), "z": pd.Float32Dtype(), } ) df2.info()`
`<class 'pandas.core.frame.DataFrame'> RangeIndex: 1000 entries, 0 to 999 Data columns (total 10 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 carat 1000 non-null Float32 1 cut 1000 non-null string 2 color 1000 non-null string 3 clarity 1000 non-null string 4 depth 1000 non-null Float32 5 table 1000 non-null Float32 6 price 1000 non-null Int16 7 x 1000 non-null Float32 8 y 1000 non-null Float32 9 z 1000 non-null Float32 dtypes: Float32(6), Int16(1), string(3) memory usage: 55.8 KB`
仅仅这些步骤可能就会将内存使用量减少到大约 55KB,相较于最初的 85KB,已经是一个不错的减少!为了更安全起见,我们可以使用pd.DataFrame.describe()方法获取汇总统计信息,并确保这两个pd.DataFrame对象相似。如果这两个pd.DataFrame对象的数字相同,那是一个很好的迹象,表明我们的转换没有实质性地改变数据:
`df.describe()`
`carat depth table price x y z count 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 mean 0.68928 61.7228 57.7347 2476.54 5.60594 5.59918 3.45753 std 0.195291 1.758879 2.467946 839.57562 0.625173 0.611974 0.389819 min 0.2 53.0 52.0 326.0 3.79 3.75 2.27 25% 0.7 60.9 56.0 2777.0 5.64 5.63 3.45 50% 0.71 61.8 57.0 2818.0 5.77 5.76 3.55 75% 0.79 62.6 59.0 2856.0 5.92 5.91 3.64 max 1.27 69.5 70.0 2898.0 7.12 7.05 4.33`
`df2.describe()`
`carat depth table price x y z count 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 mean 0.68928 61.722801 57.734699 2476.54 5.60594 5.59918 3.45753 std 0.195291 1.758879 2.467946 839.57562 0.625173 0.611974 0.389819 min 0.2 53.0 52.0 326.0 3.79 3.75 2.27 25% 0.7 60.900002 56.0 2777.0 5.64 5.63 3.45 50% 0.71 61.799999 57.0 2818.0 5.77 5.76 3.55 75% 0.79 62.599998 59.0 2856.0 5.92 5.91 3.64 max 1.27 69.5 70.0 2898.0 7.12 7.05 4.33`
到目前为止,一切看起来不错,但我们仍然可以做得更好。首先,似乎cut列有相对较少的唯一值:
`df2["cut"].unique()`
`<StringArray> ['Ideal', 'Premium', 'Good', 'Very Good', 'Fair'] Length: 5, dtype: string`
对color列也可以说同样的事情:
`df2["color"].unique()`
`<StringArray> ['E', 'I', 'J', 'H', 'F', 'G', 'D'] Length: 7, dtype: string`
以及clarity列:
`df2["clarity"].unique()`
`<StringArray> ['SI2', 'SI1', 'VS1', 'VS2', 'VVS2', 'VVS1', 'I1', 'IF'] Length: 8, dtype: string`
从 1,000 行抽样数据来看,cut列有 5 个不同的值,color列有 7 个不同的值,clarity列有 8 个不同的值。我们认为这些列具有低基数,即与行数相比,独特值的数量非常少。
这使得这些列非常适合使用分类类型。然而,我建议不要将pd.CategoricalDtype()作为dtype=的参数,因为默认情况下,它使用np.nan作为缺失值指示符(如果你需要回顾这个警告,可以重新查看在第三章中提到的分类类型配方)。相反,最佳的做法是首先将列读取为pd.StringDtype(),然后在适当的列上使用pd.DataFrame.astype:
`df3 = pd.read_csv( "data/diamonds.csv", nrows=1_000, dtype={ "carat": pd.Float32Dtype(), "cut": pd.StringDtype(), "color": pd.StringDtype(), "clarity": pd.StringDtype(), "depth": pd.Float32Dtype(), "table": pd.Float32Dtype(), "price": pd.Int16Dtype(), "x": pd.Float32Dtype(), "y": pd.Float32Dtype(), "z": pd.Float32Dtype(), } ) cat_cols = ["cut", "color", "clarity"] df3[cat_cols] = df3[cat_cols].astype(pd.CategoricalDtype()) df3.info()`
`<class 'pandas.core.frame.DataFrame'> RangeIndex: 1000 entries, 0 to 999 Data columns (total 10 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 carat 1000 non-null Float32 1 cut 1000 non-null category 2 color 1000 non-null category 3 clarity 1000 non-null category 4 depth 1000 non-null Float32 5 table 1000 non-null Float32 6 price 1000 non-null Int16 7 x 1000 non-null Float32 8 y 1000 non-null Float32 9 z 1000 non-null Float32 dtypes: Float32(6), Int16(1), category(3) memory usage: 36.2 KB`
为了进一步节省内存,我们可能会决定不读取 CSV 文件中的某些列。如果希望 pandas 跳过这些数据以节省更多内存,可以使用usecols=参数:
`dtypes = { # does not include x, y, or z "carat": pd.Float32Dtype(), "cut": pd.StringDtype(), "color": pd.StringDtype(), "clarity": pd.StringDtype(), "depth": pd.Float32Dtype(), "table": pd.Float32Dtype(), "price": pd.Int16Dtype(), } df4 = pd.read_csv( "data/diamonds.csv", nrows=1_000, dtype=dtypes, usecols=dtypes.keys(), ) cat_cols = ["cut", "color", "clarity"] df4[cat_cols] = df4[cat_cols].astype(pd.CategoricalDtype()) df4.info()`
`<class 'pandas.core.frame.DataFrame'> RangeIndex: 1000 entries, 0 to 999 Data columns (total 7 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 carat 1000 non-null Float32 1 cut 1000 non-null category 2 color 1000 non-null category 3 clarity 1000 non-null category 4 depth 1000 non-null Float32 5 table 1000 non-null Float32 6 price 1000 non-null Int16 dtypes: Float32(3), Int16(1), category(3) memory usage: 21.5 KB`
如果前面的步骤不足以创建足够小的pd.DataFrame,你可能还是有机会的。如果你可以一次处理一部分数据,而不需要将所有数据都加载到内存中,你可以使用chunksize=参数来控制从文件中读取数据块的大小:
`dtypes = { # does not include x, y, or z "carat": pd.Float32Dtype(), "cut": pd.StringDtype(), "color": pd.StringDtype(), "clarity": pd.StringDtype(), "depth": pd.Float32Dtype(), "table": pd.Float32Dtype(), "price": pd.Int16Dtype(), } df_iter = pd.read_csv( "data/diamonds.csv", nrows=1_000, dtype=dtypes, usecols=dtypes.keys(), chunksize=200 ) for df in df_iter: cat_cols = ["cut", "color", "clarity"] df[cat_cols] = df[cat_cols].astype(pd.CategoricalDtype()) print(f"processed chunk of shape {df.shape}")`
`processed chunk of shape (200, 7) processed chunk of shape (200, 7) processed chunk of shape (200, 7) processed chunk of shape (200, 7) processed chunk of shape (200, 7)`
还有更多...
这里介绍的usecols参数也可以接受一个可调用对象,当它在每个遇到的列标签上求值时,如果该列应被读取,则返回True,如果应跳过,则返回False。如果我们只想读取carat、cut、color和clarity列,可能会像这样:
`def startswith_c(column_name: str) -> bool: return column_name.startswith("c") pd.read_csv( "data/diamonds.csv", dtype_backend="numpy_nullable", usecols=startswith_c, )`
`carat cut color clarity 0 0.23 Ideal E SI2 1 0.21 Premium E SI1 2 0.23 Good E VS1 3 0.29 Premium I VS2 4 0.31 Good J SI2 … … … … … 53935 0.72 Ideal D SI1 53936 0.72 Good D SI1 53937 0.7 Very Good D SI1 53938 0.86 Premium H SI2 53939 0.75 Ideal D SI2 53940 rows × 4 columns`
Microsoft Excel – 基本的读写操作
Microsoft Excel 是一个极其流行的数据分析工具,因其易用性和普及性。Microsoft Excel 提供了一个相当强大的工具包,帮助清洗、转换、存储和可视化数据,而无需了解编程语言。许多成功的分析师可能会认为它是他们永远需要的唯一工具。尽管如此,Microsoft Excel 在性能和可扩展性上确实存在困难,作为存储介质时,它甚至可能在意想不到的方式上改变你的数据。
如果你以前使用过 Microsoft Excel,现在开始学习 pandas,你会发现 pandas 是一个互补工具。使用 pandas 时,你将放弃 Microsoft Excel 的点选操作便捷性,但你将轻松解锁性能,超越 Microsoft Excel 的限制。
在我们进入这个方法之前,值得注意的是,Microsoft Excel 支持并不是 pandas 的一部分,因此你需要安装第三方包来使这些方法生效。虽然这不是唯一的选择,但鼓励用户安装openpyxl,因为它非常适合读取和写入各种 Microsoft Excel 格式。如果你还没有安装openpyxl,可以通过以下命令进行安装:
`python -m pip install openpyxl`
如何做到这一点
让我们再次从一个简单的pd.DataFrame开始:
`df = pd.DataFrame([ ["Paul", "McCartney", 1942], ["John", "Lennon", 1940], ["Richard", "Starkey", 1940], ["George", "Harrison", 1943], ], columns=["first", "last", "birth"]) df = df.convert_dtypes(dtype_backend="numpy_nullable") df`
`first last birth 0 Paul McCartney 1942 1 John Lennon 1940 2 Richard Starkey 1940 3 George Harrison 1943`
你可以使用pd.DataFrame.to_excel方法将其写入文件,通常第一个参数是文件名,例如myfile.xlsx,但在这里,我们将再次使用io.BytesIO,它像文件一样工作,但将二进制数据存储在内存中,而不是磁盘上:
`import io buf = io.BytesIO() df.to_excel(buf)`
对于读取操作,使用pd.read_excel函数。我们将继续使用dtype_backend="numpy_nullable",以防止 pandas 执行默认的类型推断:
`buf.seek(0) pd.read_excel(buf, dtype_backend="numpy_nullable")`
`Unnamed: 0 first last birth 0 0 Paul McCartney 1942 1 1 John Lennon 1940 2 2 Richard Starkey 1940 3 3 George Harrison 1943`
许多函数参数与 CSV 共享。为了去除上面提到的Unnamed: 0列,我们可以指定index_col=参数:
`buf.seek(0) pd.read_excel(buf, dtype_backend="numpy_nullable", index_col=0)`
`first last birth 0 Paul McCartney 1942 1 John Lennon 1940 2 Richard Starkey 1940 3 George Harrison 1943`
或者选择根本不写索引:
`buf = io.BytesIO() df.to_excel(buf, index=False) buf.seek(0) pd.read_excel(buf, dtype_backend="numpy_nullable")`
`first last birth 0 Paul McCartney 1942 1 John Lennon 1940 2 Richard Starkey 1940 3 George Harrison 1943`
数据类型可以通过dtype=参数进行控制:
`buf.seek(0) dtypes = { "first": pd.StringDtype(), "last": pd.StringDtype(), "birth": pd.Int16Dtype(), } df = pd.read_excel(buf, dtype=dtypes) df.dtypes`
`first string[python] last string[python] birth Int16 dtype: object`
Microsoft Excel – 在非默认位置查找表格
在上一个教程中,Microsoft Excel – 基本读写,我们使用了 Microsoft Excel 的 I/O 函数,而没有考虑数据在工作表中的位置。默认情况下,pandas 会从/写入数据的第一个工作表的第一个单元格开始读取,但通常会收到数据位于文档其他位置的 Microsoft Excel 文件。
对于这个示例,我们有一个 Microsoft Excel 工作簿,其中第一个选项卡Sheet1用作封面页:
图 4.1:Sheet1 中不包含有用数据的工作簿
第二个选项卡是我们有用的信息所在:
图 4.2:另一个工作表包含相关数据的工作簿
如何操作
为了仍然能够读取这些数据,您可以使用pd.read_excel的sheet_name=、skiprows=和usecols=参数的组合:
`pd.read_excel( "data/beatles.xlsx", dtype_backend="numpy_nullable", sheet_name="the_data", skiprows=4, usecols="C:E", )`
`first last birth 0 Paul McCartney 1942 1 John Lennon 1940 2 Richard Starkey 1940 3 George Harrison 1943`
通过传递sheet_name="the_data",pd.read_excel函数能够准确定位 Microsoft Excel 文件中要开始查找数据的特定工作表。或者,我们也可以使用sheet_name=1按选项卡位置搜索。在找到正确的工作表后,pandas 查看skiprows=参数,并知道要忽略工作表上的第 1-4 行。然后查看usecols=参数,仅选择 C 到 E 列。
还有更多…
我们可以提供我们想要的标签,而不是usecols="C:E":
`pd.read_excel( "data/beatles.xlsx", dtype_backend="numpy_nullable", sheet_name="the_data", skiprows=4, usecols=["first", "last", "birth"], )`
`first last birth 0 Paul McCartney 1942 1 John Lennon 1940 2 Richard Starkey 1940 3 George Harrison 1943`
将这样的参数传递给usecols=是在处理 CSV 格式时选择文件中特定列的要求。然而,pandas 在读取 Microsoft Excel 文件时提供了特殊行为,允许像"C:E"或"C,D,E"这样的字符串引用列。
微软 Excel – 分层数据
数据分析的主要任务之一是将非常详细的信息汇总为易于消化的摘要。大多数公司的高管不想要翻阅成千上万的订单,他们只想知道,“过去 X 个季度我的销售情况如何?”
使用 Microsoft Excel,用户通常会在类似图 4.3所示的视图中总结这些信息,该视图代表了行上的区域/子区域层次结构和列上的年份/季度:
图 4.3:具有分层数据的工作簿 – 按区域和季度销售
尽管这个总结似乎并不太离谱,但许多分析工具很难正确呈现这种类型的信息。以传统的 SQL 数据库为例,没有直接的方法来在表中表示这种Year/Quarter层次结构 - 您唯一的选择是将所有层次结构字段连接在一起,并生成像2024/Q1、2024/Q2、2025/Q1和2025/Q2这样的列。虽然这样可以轻松选择任何单独的列,但您失去了轻松选择诸如“所有 2024 年销售额”之类的内容而不需要额外努力的能力。
幸运的是,pandas 可以比 SQL 数据库更理智地处理这个问题,直接支持行和列索引中的这种层次关系。如果您回忆起第二章,选择和赋值,我们介绍了pd.MultiIndex;能够保持这些关系使用户能够高效地从任何层次的层次结构中进行选择。
如何做到这一点
仔细检查图 4.3,您会看到第 1 行和第 2 行包含标签Year和Quarter,这些标签可以形成我们想要在pd.DataFrame的列中形成的pd.MultiIndex的级别。Microsoft Excel 使用每行基于 1 的编号,因此行[1, 2]转换为 Python 实际上是[0, 1];我们将使用这个作为我们的header=参数,以确立我们希望前两行形成我们的列pd.MultiIndex。
将我们的注意力转向 Microsoft Excel 中的 A 列和 B 列,我们现在可以看到标签Region和Sub-Region,这将帮助我们在行中塑造pd.MultiIndex。回到CSV - 基本读取/写入部分,我们介绍了index_col=参数,它可以告诉 pandas 实际上应该使用哪些列数据来生成行索引。Microsoft Excel 文件中的 A 列和 B 列代表第一列和第二列,因此我们可以再次使用[0, 1]来告诉 pandas 我们的意图:
`df = pd.read_excel( "data/hierarchical.xlsx", dtype_backend="numpy_nullable", index_col=[0, 1], header=[0, 1], ) df`
`Year 2024 2025 Quarter Q1 Q2 Q1 Q2 Region Sub-Region America East 1 2 4 8 West 16 32 64 128 South 256 512 1024 4096 Europe West 8192 16384 32768 65536 East 131072 262144 524288 1048576`
大功告成!我们成功读取了数据并保持了行和列的层次结构,这使我们可以使用所有原生 pandas 功能从这些数据中进行选择,甚至回答诸如“每个东部子区域的 Q2 业绩在年度上看起来如何?”这样的问题。
`df.loc[(slice(None), "East"), (slice(None), "Q2")]`
`Year 2024 2025 Quarter Q2 Q2 Region Sub-Region America East 2 8 Europe East 262144 1048576`
使用 SQLAlchemy 的 SQL
pandas 库提供了与 SQL 数据库交互的强大功能,使您可以直接在关系数据库中存储的数据上进行数据分析。
当然,存在着无数的数据库(而且还会有更多!),每个数据库都有自己的特点、认证方案、方言和怪癖。为了与它们交互,pandas 依赖于另一个伟大的 Python 库 SQLAlchemy,它在核心上充当 Python 和数据库世界之间的桥梁。理论上,pandas 可以与 SQLAlchemy 可以连接的任何数据库一起使用。
要开始,您应该首先将 SQLAlchemy 安装到您的环境中:
`python -m pip install sqlalchemy`
SQLAlchemy 支持所有主要的数据库,如 MySQL、PostgreSQL、MS SQL Server 等,但设置和正确配置这些数据库本身需要不少努力,这部分内容超出了本书的范围。为了尽可能简化,我们将专注于使用 SQLite 作为我们的数据库,因为它不需要任何设置,且可以完全在计算机内存中运行。一旦你熟悉了 SQLite 的使用,你只需更改连接的凭据来指向目标数据库;否则,所有功能保持不变。
操作步骤
我们需要做的第一件事是创建一个 SQLAlchemy 引擎,使用sa.create_engine。这个函数的参数是一个 URL,取决于你试图连接的数据库(更多信息请参见 SQLAlchemy 文档)。在这些示例中,我们将使用内存中的 SQLite:
`import sqlalchemy as sa engine = sa.create_engine("sqlite:///:memory:")`
使用pd.DataFrame.to_sql方法,你可以将一个现有的pd.DataFrame写入数据库表中。第一个参数是你想创建的表的名称,第二个参数是引擎/可连接对象:
`df = pd.DataFrame([ ["dog", 4], ["cat", 4], ], columns=["animal", "num_legs"]) df = df.convert_dtypes(dtype_backend="numpy_nullable") df.to_sql("table_name", engine, index=False)`
`2`
pd.read_sql函数可以用于反向操作,从数据库表中读取数据:
`pd.read_sql("table_name", engine, dtype_backend="numpy_nullable")`
`animal num_legs 0 dog 4 1 cat 4`
另外,如果你想要的不是仅仅复制表,而是某个不同的内容,你可以将 SQL 查询传递给pd.read_sql:
`pd.read_sql( "SELECT SUM(num_legs) AS total_legs FROM table_name", engine, dtype_backend="numpy_nullable" )`
`total_legs 0 8`
当数据库中已经存在表时,再次向同一表写入数据将会引发错误。你可以传递if_exists="replace"来覆盖此行为并替换表:
`df = pd.DataFrame([ ["dog", 4], ["cat", 4], ["human", 2], ], columns=["animal", "num_legs"]) df = df.convert_dtypes(dtype_backend="numpy_nullable") df.to_sql("table_name", engine, index=False, if_exists="replace")`
`3`
你还可以使用if_exists="append"将数据添加到表中:
`new_data = pd.DataFrame([["centipede", 100]], columns=["animal", "num_legs"]) new_data.to_sql("table_name", engine, index=False, if_exists="append") pd.read_sql("table_name", engine, dtype_backend="numpy_nullable")`
`animal num_legs 0 dog 4 1 cat 4 2 human 2 3 centipede 100`
绝大部分繁重的工作都由 SQLAlchemy 引擎在幕后完成,该引擎使用dialect+driver://username:password@host:port/database形式的 URL 构建。并非所有的字段都是必需的——该字符串会根据你使用的数据库及其配置有所不同。
在我们的具体示例中,sa.create_engine("sqlite:///:memory:")在我们计算机的内存空间中创建并连接到一个 SQLite 数据库。这个特性是 SQLite 特有的;我们也可以传递一个文件路径,比如sa.create_engine("sqlite:///tmp/adatabase.sql"),而不是使用:memory:。
欲了解更多关于 SQLAlchemy URL 的信息,以及如何搭配其他数据库使用驱动程序,请参考 SQLAlchemy 的后端特定 URL 文档。
使用 ADBC 的 SQL
尽管使用 SQLAlchemy 连接数据库是一种可行的选择,并且多年来一直帮助着 pandas 的用户,但来自 Apache Arrow 项目的一项新技术已经出现,它能进一步扩展 SQL 交互。这项新技术被称为Arrow 数据库连接,简称ADBC。从 2.2 版本开始,pandas 增加了对使用 ADBC 驱动程序与数据库交互的支持。
使用 ADBC 在与 SQL 数据库交互时,比上述基于 SQLAlchemy 的方法能提供更好的性能和类型安全。权衡之下,SQLAlchemy 支持更多的数据库,因此根据你的数据库,SQLAlchemy 可能是唯一的选择。ADBC 会记录其驱动实现状态;我建议在回退到 SQLAlchemy 之前,首先查看该记录,以确保你所使用的数据库有一个稳定的驱动实现。
和上一节一样,我们将使用 SQLite 作为数据库,因为它易于设置和配置。请确保为 SQLite 安装适当的 ADBC Python 包:
`python -m pip install adbc-driver-sqlite`
如何实现
首先,我们从我们的 SQLite ADBC 驱动中导入 dbapi 对象,并创建一些示例数据:
`from adbc_driver_sqlite import dbapi df = pd.DataFrame([ ["dog", 4], ["cat", 4], ["human", 2], ], columns=["animal", "num_legs"]) df = df.convert_dtypes(dtype_backend="numpy_nullable") df`
`animal num_legs 0 dog 4 1 cat 4 2 human 2`
术语 dbapi 来自 Python 数据库 API 规范(PEP-249),该规范标准化了 Python 模块和库如何与数据库交互。调用 .connect 方法并提供凭据是 Python 中打开数据库连接的标准化方式。我们将再次通过 dbapi.connect("file::memory:") 使用内存中的 SQLite 应用程序。
通过在 Python 中使用 with ... as: 语法来使用上下文管理器,我们可以连接到一个数据库,并将其赋值给一个变量,这样 Python 就会在块执行完毕后自动清理连接。在块内连接处于打开状态时,我们可以使用 pd.DataFrame.to_sql / pd.read_sql 分别向数据库写入和从数据库读取数据:
`with dbapi.connect("file::memory:") as conn: df.to_sql("table_name", conn, index=False, if_exists="replace") df = pd.read_sql( "SELECT * FROM table_name", conn, dtype_backend="numpy_nullable", ) df`
`animal num_legs 0 dog 4 1 cat 4 2 human 2`
对于较小的数据集,你可能看不出太大差别,但在较大的数据集上,ADBC 的性能提升会非常明显。让我们比较一下使用 SQLAlchemy 写入一个 10,000 行、10 列的 pd.DataFrame 所需的时间:
`import timeit import sqlalchemy as sa np.random.seed(42) df = pd.DataFrame( np.random.randn(10_000, 10), columns=list("abcdefghij") ) with sa.create_engine("sqlite:///:memory:").connect() as conn: func = lambda: df.to_sql("test_table", conn, if_exists="replace") print(timeit.timeit(func, number=100))`
`4.898935955003253`
使用 ADBC 的等效代码:
`from adbc_driver_sqlite import dbapi with dbapi.connect("file::memory:") as conn: func = lambda: df.to_sql("test_table", conn, if_exists="replace") print(timeit.timeit(func, number=100))`
`0.7935214300014195`
你的结果会有所不同,具体取决于你的数据和数据库,但通常情况下,ADBC 应该会表现得更快。
还有更多…
为了理解 ADBC 的作用以及它为何重要,首先值得简单回顾一下数据库标准的历史以及它们如何发展。在 1990 年代,开放数据库连接(ODBC)和 Java 数据库连接(JDBC)标准被引入,这帮助标准化了不同客户端如何与各种数据库进行通信。在这些标准引入之前,如果你开发了一个需要与两个或更多不同数据库交互的应用程序,那么你的应用程序就必须使用每个数据库能理解的语言来与其交互。
假设这个应用程序只想获取每个数据库中可用表格的列表。PostgreSQL 数据库将这些信息存储在名为 pg_catalog.pg_tables 的表中,而 SQLite 数据库则将其存储在一个 sqlite_schema 表中,条件是 type='table'。这个应用程序需要根据这些特定的信息来开发,并且每当数据库更改了存储这些信息的方式,或者当应用程序想要支持新数据库时,都需要重新发布。
使用像 ODBC 这样的标准时,应用程序只需要与驱动程序进行通信,告知驱动程序它需要系统中的所有表格。这将数据库交互的责任从应用程序转移到驱动程序,给应用程序提供了一个抽象层。随着新数据库或新版本的发布,应用程序本身不再需要改变;它只需与新的 ODBC/JDBC 驱动程序配合工作,继续正常运作。事实上,SQLAlchemy 就像这个理论中的应用程序;它通过 ODBC/JDBC 驱动程序与数据库交互,而不是试图独立管理无尽的数据库交互。
尽管这些标准在许多用途上非常出色,但值得注意的是,1990 年代的数据库与今天的数据库差异很大。许多这些标准试图解决的问题是针对当时盛行的行式数据库的。列式数据库是在十多年后才出现的,并且它们已经主导了数据分析领域。不幸的是,在没有列式数据传输标准的情况下,许多数据库不得不重新设计,使其兼容 ODBC/JDBC。这使得它们能够与今天存在的无数数据库客户端工具兼容,但也需要在性能和效率上做出一定的妥协。
ADBC 是解决这个问题的列式规范。pandas 库以及许多类似的产品在设计上明确地(或至少非常接近)采用了列式设计。当与列式数据库(如 BigQuery、Redshift 或 Snowflake)交互时,拥有一个列式驱动程序来交换信息可以带来数量级更好的性能。即使你没有与列式数据库交互,ADBC 驱动程序也经过精细优化,专为与 Apache Arrow 配合的分析工作,因此它 仍然 比 SQLAlchemy 使用的任何 ODBC/JDBC 驱动程序都要好。
对于想要了解更多 ADBC 的用户,我建议观看我在 PyData NYC 2023 上的演讲,标题为《使用 pandas 和 Apache Arrow 加速 SQL》,可以在 YouTube 上观看 (youtu.be/XhnfybpWOgA?si=RBrM7UUvpNFyct0L)。
Apache Parquet
就 pd.DataFrame 的通用存储格式而言,Apache Parquet 是最佳选择。Apache Parquet 允许:
-
元数据存储 —— 这使得格式能够追踪数据类型等特性
-
分区 – 不是所有数据都需要在一个文件中
-
查询支持 – Parquet 文件可以在磁盘上进行查询,因此你不必将所有数据都加载到内存中
-
并行化 – 读取数据可以并行化以提高吞吐量
-
紧凑性 – 数据被压缩并以高效的方式存储
除非你在处理遗留系统,否则 Apache Parquet 格式应该取代在工作流中使用 CSV 文件的方式,从本地持久化数据、与其他团队成员共享,到跨系统交换数据。
如何操作
读取/写入 Apache Parquet 的 API 与我们到目前为止看到的所有 pandas API 一致;读取使用 pd.read_parquet,写入使用 pd.DataFrame.to_parquet 方法。
让我们从一些示例数据和 io.BytesIO 对象开始:
`import io buf = io.BytesIO() df = pd.DataFrame([ ["Paul", "McCartney", 1942], ["John", "Lennon", 1940], ["Richard", "Starkey", 1940], ["George", "Harrison", 1943], ], columns=["first", "last", "birth"]) df = df.convert_dtypes(dtype_backend="numpy_nullable") df`
`first last birth 0 Paul McCartney 1942 1 John Lennon 1940 2 Richard Starkey 1940 3 George Harrison 1943`
这是如何写入文件句柄的方式:
`df.to_parquet(buf, index=False)`
下面是如何从文件句柄中读取数据。注意我们故意没有提供 dtype_backend="numpy_nullable":
`buf.seek(0) pd.read_parquet(buf)`
`first last birth 0 Paul McCartney 1942 1 John Lennon 1940 2 Richard Starkey 1940 3 George Harrison 1943`
为什么我们在 pd.read_parquet 中不需要 dtype_backend= 参数?与像 CSV 这样的格式只存储数据不同,Apache Parquet 格式同时存储数据和元数据。在元数据中,Apache Parquet 能够追踪正在使用的数据类型,所以你写入的数据类型应该和你读取的数据类型完全一致。
你可以通过将 birth 列的数据类型更改为不同类型来进行测试:
`df["birth"] = df["birth"].astype(pd.UInt16Dtype()) df.dtypes`
`first string[python] last string[python] birth UInt16 dtype: object`
通过 Apache Parquet 格式进行回环操作会返回你最初使用的相同数据类型:
`buf = io.BytesIO() df.to_parquet(buf, index=False) buf.seek(0) pd.read_parquet(buf).dtypes`
`first string[python] last string[python] birth UInt16 dtype: object`
当然,如果你想更加防守型,在这里使用 dtype_backend="numpy_nullable" 也没有坏处。我们一开始故意没有使用它,是为了展示 Apache Parquet 格式的强大,但如果你从其他来源和开发者那里接收文件,他们没有使用我们在第三章《数据类型》中推荐的类型系统,那么确保使用 pandas 提供的最佳类型可能会对你有帮助:
`suboptimal_df = pd.DataFrame([ [0, "foo"], [1, "bar"], [2, "baz"], ], columns=["int_col", "str_col"]) buf = io.BytesIO() suboptimal_df.to_parquet(buf, index=False) buf.seek(0) pd.read_parquet(buf, dtype_backend="numpy_nullable").dtypes`
`int_col Int64 str_col string[python] dtype: object`
Apache Parquet 格式的另一个伟大特点是它支持 分区,这打破了所有数据必须位于一个文件中的要求。通过能够将数据分割到不同的目录和文件中,分区使得用户可以轻松地组织内容,同时也使程序能够更高效地优化可能需要或不需要读取的文件来解决分析查询。
有许多方法可以对数据进行分区,每种方法都有实际的空间/时间权衡。为了演示,我们假设使用 基于时间的分区,即为不同的时间段生成单独的文件。考虑到这一点,让我们使用以下数据布局,其中我们为每个年份创建不同的目录,并在每个年份内,为每个销售季度创建单独的文件:
`Partitions 2022/ q1_sales.parquet q2_sales.parquet 2023/ q1_sales.parquet q2_sales.parquet`
本书附带的每个示例 Apache Parquet 文件都已经使用我们在第三章(数据类型)中推荐的 pandas 扩展类型创建,因此我们进行的 pd.read_parquet 调用故意不包括 dtype_backend="numpy_nullable" 参数。在任何文件中,你都会看到我们存储了关于 year(年份)、quarter(季度)、region(地区)和总的 sales(销售额)等信息:
`pd.read_parquet( "data/partitions/2022/q1_sales.parquet", )`
`year quarter region sales 0 2022 Q1 America 1 1 2022 Q1 Europe 2`
如果我们想查看所有数据的汇总,最直接的方法是遍历每个文件并累积结果。然而,使用 Apache Parquet 格式,pandas 可以本地有效地处理这个问题。与其将单个文件名传递给 pd.read_parquet,不如传递目录路径:
`pd.read_parquet("data/partitions/")`
`year quarter region sales 0 2022 Q1 America 1 1 2022 Q1 Europe 2 2 2022 Q2 America 4 3 2022 Q2 Europe 8 4 2023 Q1 America 16 5 2023 Q1 Europe 32 6 2023 Q2 America 64 7 2023 Q2 Europe 128`
由于我们的示例数据非常小,我们没有问题先将所有数据读取到 pd.DataFrame 中,然后从那里进行操作。然而,在生产环境中,你可能会遇到存储量达到吉字节或太字节的 Apache Parquet 文件。试图将所有数据读取到 pd.DataFrame 中可能会导致 MemoryError 错误。
幸运的是,Apache Parquet 格式使你能够在读取文件时动态过滤记录。在 pandas 中,你可以通过传递 filters= 参数来启用此功能,方法是使用 pd.read_parquet。该参数应该是一个列表,其中每个列表元素是一个包含三个元素的元组:
-
列名
-
逻辑运算符
-
值
例如,如果我们只想读取 region 列值为 Europe 的数据,可以这样写:
`pd.read_parquet( "data/partitions/", filters=[("region", "==", "Europe")], )`
`year quarter region sales 0 2022 Q1 Europe 2 1 2022 Q2 Europe 8 2 2023 Q1 Europe 32 3 2023 Q2 Europe 128`
JSON
JavaScript 对象表示法(JSON)是一种常用于通过互联网传输数据的格式。JSON 规范可以在 www.json.org 上找到。尽管名称中有 JavaScript,但它不需要 JavaScript 来读取或创建。
Python 标准库附带了 json 库,它可以将 Python 对象序列化为 JSON,或者从 JSON 反序列化回 Python 对象:
`import json beatles = { "first": ["Paul", "John", "Richard", "George",], "last": ["McCartney", "Lennon", "Starkey", "Harrison",], "birth": [1942, 1940, 1940, 1943], } serialized = json.dumps(beatles) print(f"serialized values are: {serialized}") deserialized = json.loads(serialized) print(f"deserialized values are: {deserialized}")`
`serialized values are: {"first": ["Paul", "John", "Richard", "George"], "last": ["McCartney", "Lennon", "Starkey", "Harrison"], "birth": [1942, 1940, 1940, 1943]} deserialized values are: {'first': ['Paul', 'John', 'Richard', 'George'], 'last': ['McCartney', 'Lennon', 'Starkey', 'Harrison'], 'birth': [1942, 1940, 1940, 1943]}`
然而,标准库并不知道如何处理 pandas 对象,因此 pandas 提供了自己的 I/O 函数,专门用于处理 JSON。
如何实现
在最简单的形式下,pd.read_json 可以用于读取 JSON 数据:
`import io data = io.StringIO(serialized) pd.read_json(data, dtype_backend="numpy_nullable")`
`first last birth 0 Paul McCartney 1942 1 John Lennon 1940 2 Richard Starkey 1940 3 George Harrison 1943`
pd.DataFrame.to_json 方法可以用于写入:
`df = pd.DataFrame(beatles) print(df.to_json())`
`{"first":{"0":"Paul","1":"John","2":"Richard","3":"George"},"last":{"0":"McCartney","1":"Lennon","2":"Starkey","3":"Harrison"},"birth":{"0":1942,"1":1940,"2":1940,"3":1943}}`
然而,在实际应用中,存在无数种将表格数据表示为 JSON 的方式。有些用户可能希望看到 pd.DataFrame 中的每一行作为 JSON 数组,而另一些用户可能希望看到每一列作为数组。还有一些用户可能希望查看行索引、列索引和数据作为独立的 JSON 对象列出,而其他用户可能根本不关心是否看到行或列标签。
对于这些用例以及更多用例,pandas 允许你传递一个参数给 orient=,其值决定了要读取或写入的 JSON 布局:
-
columns(默认值):生成 JSON 对象,其中键是列标签,值是另一个对象,该对象将行标签映射到数据点。 -
records:pd.DataFrame的每一行都表示为一个 JSON 数组,其中包含将列名映射到数据点的对象。 -
split:映射到{"columns": […], "index": […], "data": […]}。列/索引值是标签的数组,数据包含数组的数组。 -
index:与列类似,不同之处在于行和列标签作为键的使用被反转。 -
values:将pd.DataFrame的数据映射到数组的数组中。行/列标签被丢弃。 -
table:遵循 JSON 表格模式。
JSON 是一种有损的数据交换格式,因此上述每种 orient 都是在损失、冗长性和最终用户需求之间的权衡。orient="table" 会最少损失数据,但会产生最大负载,而 orient="values" 完全位于该范围的另一端。
为了突出每种 orient 之间的差异,让我们从一个相当简单的 pd.DataFrame 开始:
`df = pd.DataFrame(beatles, index=["row 0", "row 1", "row 2", "row 3"]) df = df.convert_dtypes(dtype_backend="numpy_nullable") df`
`first last birth row 0 Paul McCartney 1942 row 1 John Lennon 1940 row 2 Richard Starkey 1940 row 3 George Harrison 1943`
传递 orient="columns" 会生成使用以下模式的数据:{"column":{"row": value, "row": value, ...}, ...}:
`serialized = df.to_json(orient="columns") print(f'Length of orient="columns": {len(serialized)}') serialized[:100]`
`Length of orient="columns": 221 {"first":{"row 0":"Paul","row 1":"John","row 2":"Richard","row 3":"George"},"last":{"row 0":"McCartn`
这是一种相当冗长的存储数据方式,因为它会为每一列重复行索引标签。优点是,pandas 可以相对较好地从这种 orient 中重建正确的 pd.DataFrame:
`pd.read_json( io.StringIO(serialized), orient="columns", dtype_backend="numpy_nullable" )`
`first last birth row 0 Paul McCartney 1942 row 1 John Lennon 1940 row 2 Richard Starkey 1940 row 3 George Harrison 1943`
使用 orient="records" 时,你会得到每一行 pd.DataFrame 的表示,不带行索引标签,形成一个模式 [{"col": value, "col": value, ...}, ...]:
`serialized = df.to_json(orient="records") print(f'Length of orient="records": {len(serialized)}') serialized[:100]`
`Length of orient="records": 196 [{"first":"Paul","last":"McCartney","birth":1942},{"first":"John","last":"Lennon","birth":1940},{"fi`
尽管这种表示方式比 orient="columns" 更紧凑,但它不存储任何行标签,因此在重建时,你将得到一个带有新生成 pd.RangeIndex 的 pd.DataFrame:
`pd.read_json( io.StringIO(serialized), orient="orient", dtype_backend="numpy_nullable" )`
`first last birth 0 Paul McCartney 1942 1 John Lennon 1940 2 Richard Starkey 1940 3 George Harrison 1943`
使用 orient="split" 时,行索引标签、列索引标签和数据会分别存储:
`serialized = df.to_json(orient="split") print(f'Length of orient="split": {len(serialized)}') serialized[:100]`
`Length of orient="split": 190 {"columns":["first","last","birth"],"index":["row 0","row 1","row 2","row 3"],"data":[["Paul","McCar`
这种格式使用的字符比 orient="columns" 少,而且你仍然可以相对较好地重建一个 pd.DataFrame,因为它与你使用构造函数(如 pd.DataFrame(data, index=index, columns=columns))构建 pd.DataFrame 的方式相似:
`pd.read_json( io.StringIO(serialized), orient="split", dtype_backend="numpy_nullable", )`
`first last birth row 0 Paul McCartney 1942 row 1 John Lennon 1940 row 2 Richard Starkey 1940 row 3 George Harrison 1943`
虽然这是一个很好的格式,用于双向转换 pd.DataFrame,但与其他格式相比,在“野外”遇到这种 JSON 格式的可能性要低得多。
orient="index" 与 orient="columns" 非常相似,但它反转了行和列标签的角色:
`serialized = df.to_json(orient="index") print(f'Length of orient="index": {len(serialized)}') serialized[:100]`
`Length of orient="index": 228 {"row 0":{"first":"Paul","last":"McCartney","birth":1942},"row 1":{"first":"John","last":"Lennon","b`
再次强调,你可以合理地重建你的 pd.DataFrame:
`pd.read_json( io.StringIO(serialized), orient="index", dtype_backend="numpy_nullable", )`
`first last birth row 0 Paul McCartney 1942 row 1 John Lennon 1940 row 2 Richard Starkey 1940 row 3 George Harrison 1943`
一般来说,orient="index" 会比 orient="columns" 占用更多空间,因为大多数 pd.DataFrame 对象使用的列标签比索引标签更冗长。我建议仅在列标签不那么冗长,或者有其他系统强制要求特定格式的情况下使用此格式。
对于最简洁的表示方式,你可以选择 orient="values"。使用此 orient,既不保存行标签,也不保存列标签:
`serialized = df.to_json(orient="values") print(f'Length of orient="values": {len(serialized)}') serialized[:100]`
`Length of orient="values": 104 [["Paul","McCartney",1942],["John","Lennon",1940],["Richard","Starkey",1940],["George","Harrison",19`
当然,由于它们没有在 JSON 数据中表示,当使用 orient="values" 读取时,你将无法保留行/列标签:
`pd.read_json( io.StringIO(serialized), orient="values", dtype_backend="numpy_nullable", )`
`0 1 2 0 Paul McCartney 1942 1 John Lennon 1940 2 Richard Starkey 1940 3 George Harrison 1943`
最后,我们有了 orient="table"。这是所有输出中最冗长的一种,但它是唯一一个有实际标准支持的输出,标准叫做 JSON 表格模式:
`serialized = df.to_json(orient="table") print(f'Length of orient="table": {len(serialized)}') serialized[:100]`
`Length of orient="table": 524 {"schema":{"fields":[{"name":"index","type":"string"},{"name":"first","type":"any","extDtype":"strin`
表格模式更冗长,因为它存储了关于数据序列化的元数据,类似于我们在 Apache Parquet 格式中看到的内容(尽管功能不如 Apache Parquet)。对于所有其他的 orient= 参数,pandas 必须在读取时推断数据的类型,但 JSON 表格格式会为你保留这些信息。因此,假设你一开始就使用了 pandas 扩展类型,你甚至不需要 dtype_backend="numpy_nullable" 参数:
`df["birth"] = df["birth"].astype(pd.UInt16Dtype()) serialized = df.to_json(orient="table") pd.read_json( io.StringIO(serialized), orient="table", ).dtypes`
`first string[python] last string[python] birth UInt16 dtype: object`
还有更多……
当尝试读取 JSON 时,你可能会发现以上格式仍然无法充分表达你想要实现的目标。幸运的是,仍然有 pd.json_normalize,它可以作为一个功能强大的函数,将你的 JSON 数据转换为表格格式。
想象一下,正在处理来自一个理论上的带有分页的 REST API 的以下 JSON 数据:
`data = { "records": [{ "name": "human", "characteristics": { "num_leg": 2, "num_eyes": 2 } }, { "name": "dog", "characteristics": { "num_leg": 4, "num_eyes": 2 } }, { "name": "horseshoe crab", "characteristics": { "num_leg": 10, "num_eyes": 10 } }], "type": "animal", "pagination": { "next": "23978sdlkusdf97234u2io", "has_more": 1 } }`
虽然 "pagination" 键对于导航 API 很有用,但对于我们来说报告价值不大,而且它可能会导致 JSON 序列化器出现问题。我们真正关心的是与 "records" 键相关的数组。你可以指示 pd.json_normalize 只查看这些数据,使用 record_path= 参数。请注意,pd.json_normalize 不是一个真正的 I/O 函数,因为它处理的是 Python 对象而不是文件句柄,因此没有 dtype_backend= 参数;相反,我们将链接一个 pd.DataFrame.convert_dtypes 调用,以获得所需的 pandas 扩展类型:
`pd.json_normalize( data, record_path="records" ).convert_dtypes(dtype_backend="numpy_nullable")`
`name characteristics.num_leg characteristics.num_eyes 0 human 2 2 1 dog 4 2 2 horseshoe crab 10 10`
通过提供 record_path= 参数,我们能够忽略不需要的 "pagination" 键,但不幸的是,我们现在有了一个副作用,就是丢失了包含每条记录重要元数据的 "type" 键。为了保留这些信息,你可以使用 meta= 参数:
`pd.json_normalize( data, record_path="records", meta="type" ).convert_dtypes(dtype_backend="numpy_nullable")`
`name characteristics.num_leg characteristics.num_eyes type 0 human 2 2 animal 1 dog 4 2 animal 2 horseshoe crab 10 10 animal`
HTML
你可以使用 pandas 从网站读取 HTML 表格。这使得获取像 Wikipedia 上的表格变得容易。
在这个教程中,我们将从 Wikipedia 上关于 The Beatles Discography 的页面抓取表格 (en.wikipedia.org/wiki/The_Beatles_discography)。特别是,我们想要抓取 2024 年 Wikipedia 上图片中展示的表格:
图 4.4:The Beatles Discography 的 Wikipedia 页面
在尝试读取 HTML 之前,用户需要安装一个第三方库。在本节的示例中,我们将使用 lxml:
`python -m pip install lxml`
如何做到这一点
pd.read_html 允许你从网站读取表格:
`url = "https://en.wikipedia.org/wiki/The_Beatles_discography" dfs = pd.read_html(url, dtype_backend="numpy_nullable") len(dfs)`
`60`
与我们到目前为止看到的其他 I/O 方法不同,pd.read_html不会返回一个pd.DataFrame,而是返回一个pd.DataFrame对象的列表。让我们看看第一个列表元素是什么样子的:
`dfs[0]`
`The Beatles discography The Beatles discography.1 0 The Beatles in 1965 The Beatles in 1965 1 Studio albums 12 (UK), 17 (US) 2 Live albums 5 3 Compilation albums 51 4 Video albums 22 5 Music videos 53 6 EPs 36 7 Singles 63 8 Mash-ups 2 9 Box sets 17`
上述表格是工作室专辑、现场专辑、合辑专辑等的统计摘要。这不是我们想要的表格。我们可以循环遍历pd.read_html创建的每个表格,或者我们可以给它一个提示,以找到特定的表格。
获取我们想要的表格的一种方法是利用pd.read_html的attrs=参数。此参数接受一个将 HTML 属性映射到值的字典。因为 HTML 中的id属性应该在文档中是唯一的,尝试使用attrs={"id": ...}来查找表格通常是一个安全的方法。让我们看看我们能否在这里做到这一点。
使用你的网络浏览器检查网页的 HTML(如果你不知道如何做到这一点,请在网上搜索诸如Firefox inspector、Safari Web Inspector或Google Chrome DevTools之类的术语;不幸的是,术语并不标准化)。寻找任何id字段、唯一字符串或帮助我们识别所需表格的表格元素属性。
这是原始 HTML 的一部分:
`<table class="wikipedia plainrowheaders" style="text-align:center;"> <caption>List of studio albums, with selected chart positions and certification </caption> <tbody> <tr> <th rowspan="2" scope="col" style="width:20em;">Title</th> <th rowspan="2" scope="col" style="width:20em;">Album details<sup id="cite_ref-8" class="reference"><a href="#cite_note-8">[A]</a></sup></th> ... </tr> </tbody>`
不幸的是,我们正在寻找的表格没有id属性。我们可以尝试使用上述 HTML 片段中看到的class或style属性,但这些可能不会是唯一的。
我们可以尝试的另一个参数是match=,它可以是一个字符串或正则表达式,用来匹配表格内容。在上述 HTML 的<caption>标签中,你会看到文本"List of studio albums";让我们将其作为一个参数试一试。为了提高可读性,我们只需查看每张专辑及其在英国、澳大利亚和加拿大的表现:
`url = "https://en.wikipedia.org/wiki/The_Beatles_discography" dfs = pd.read_html( url, match=r"List of studio albums", dtype_backend="numpy_nullable", ) print(f"Number of tables returned was: {len(dfs)}") dfs[0].filter(regex=r"Title|UK|AUS|CAN").head()`
`Title Peak chart positions Title UK [8][9] AUS [10] CAN [11] 0 Please Please Me 1 — — 1 With the Beatles[B] 1 — — 2 A Hard Day's Night 1 1 — 3 Beatles for Sale 1 1 — 4 Help! 1 1 —`
虽然我们现在能够找到表格,但列名不太理想。如果你仔细观察维基百科的表格,你会注意到它在Peak chart positions文本和下面国家名称之间部分创建了一个层次结构,这些内容会被 pandas 转换为pd.MultiIndex。为了使我们的表格更易读,我们可以传递header=1来忽略生成的pd.MultiIndex的第一个级别:
`url = "https://en.wikipedia.org/wiki/The_Beatles_discography" dfs = pd.read_html( url, match="List of studio albums", header=1, dtype_backend="numpy_nullable", ) dfs[0].filter(regex=r"Title|UK|AUS|CAN").head()`
`Title UK [8][9] AUS [10] CAN [11] 0 Please Please Me 1 — — 1 With the Beatles[B] 1 — — 2 A Hard Day's Night 1 1 — 3 Beatles for Sale 1 1 — 4 Help! 1 1 —`
当我们更仔细地查看数据时,我们可以看到维基百科使用—来表示缺失值。如果我们将其作为参数传递给pd.read_html的na_values=参数,我们将看到=—=值被转换为缺失值:
`url = "https://en.wikipedia.org/wiki/The_Beatles_discography" dfs = pd.read_html( url, match="List of studio albums", header=1, na_values=["—"], dtype_backend="numpy_nullable", ) dfs[0].filter(regex=r"Title|UK|AUS|CAN").head()`
`Title UK [8][9] AUS [10] CAN [11] 0 Please Please Me 1 <NA> <NA> 1 With the Beatles[B] 1 <NA> <NA> 2 A Hard Day's Night 1 1 <NA> 3 Beatles for Sale 1 1 <NA> 4 Help! 1 1 <NA>`
Pickle
Pickle 格式是 Python 的内置序列化格式。Pickle 文件通常以.pkl扩展名结尾。
与之前遇到的其他格式不同,pickle 格式不应该用于在机器间传输数据。它的主要使用场景是将包含 Python 对象的 pandas 对象保存在本地机器上,并在以后重新加载。如果你不确定是否应该使用这个格式,我建议你首先尝试 Apache Parquet 格式,它涵盖了更广泛的使用场景。
不要从不可信来源加载 pickle 文件。我通常只建议将 pickle 用于自己的分析;不要分享数据,也不要指望从别人那里收到 pickle 格式的数据。
如何操作
为了强调 pickle 格式应该仅在 pandas 对象包含 Python 对象时使用,假设我们决定将 Beatles 数据存储为一个包含 namedtuple 类型的 pd.Series。人们可能会质疑为什么要这么做,因为它更适合表示为 pd.DataFrame……但不论如何,这样做是有效的:
`from collections import namedtuple Member = namedtuple("Member", ["first", "last", "birth"]) ser = pd.Series([ Member("Paul", "McCartney", 1942), Member("John", "Lennon", 1940), Member("Richard", "Starkey", 1940), Member("George", "Harrison", 1943), ]) ser`
`0 (Paul, McCartney, 1942) 1 (John, Lennon, 1940) 2 (Richard, Starkey, 1940) 3 (George, Harrison, 1943) dtype: object`
我们在本章讨论的其他 I/O 方法都无法准确表示namedtuple,因为它是纯 Python 构造。然而,pd.Series.to_pickle却能够顺利写出这个内容:
`import io buf = io.BytesIO() ser.to_pickle(buf)`
当你调用 pd.read_pickle 时,你将获得你开始时所使用的确切表示:
`buf.seek(0) ser = pd.read_pickle(buf) ser`
`0 (Paul, McCartney, 1942) 1 (John, Lennon, 1940) 2 (Richard, Starkey, 1940) 3 (George, Harrison, 1943) dtype: object`
你可以通过检查单个元素进一步验证这一点:
`ser.iloc[0]`
`Member(first='Paul', last='McCartney', birth=1942)`
再次强调,Apache Parquet 格式应优先于 pickle,只有在 pd.Series 或 pd.DataFrame 中包含 Python 特定对象且需要回传时,才应作为最后的选择使用。务必不要从不可信来源加载 pickle 文件;除非你自己创建了该 pickle 文件,否则强烈建议不要尝试处理它。
第三方 I/O 库
虽然 pandas 支持大量格式,但它无法涵盖所有重要格式。为此,第三方库应运而生,填补了这个空白。
以下是一些你可能感兴趣的库——它们的工作原理超出了本书的范围,但它们通常遵循的模式是:具有读取函数返回pd.DataFrame对象,并且写入方法接受pd.DataFrame参数:
-
pandas-gbq 让你与 Google BigQuery 交换数据
-
AWS SDK for pandas 可以与 Redshift 以及更广泛的 AWS 生态系统一起使用
-
Snowflake Connector for Python 帮助与 Snowflake 数据库交换数据
-
pantab 让你将
pd.DataFrame对象在 Tableau 的 Hyper 数据库格式中进出(注意:我也是 pantab 的作者)
加入我们社区的 Discord
加入我们社区的 Discord 讨论区,与作者和其他读者互动:
第五章:算法及其应用
在本书中,我们已经看过了多种创建 pandas 数据结构、选择/赋值数据以及将这些结构存储为常见格式的方法。这些功能单独来看已经足以使 pandas 在数据交换领域成为一个强大的工具,但我们仍然只是触及了 pandas 所能提供的一小部分。
数据分析和计算的核心组成部分是算法的应用,它描述了计算机处理数据时应采取的步骤序列。在简单的形式下,常见的数据算法基于基本的算术运算(例如,“对这一列求和”),但它们也可以扩展到你可能需要的任何步骤序列,以进行自定义计算。
正如你将在本章中看到的,pandas 提供了许多常见的数据算法,但同时也为你提供了一个强大的框架,通过它你可以构建和应用自己的算法。pandas 提供的这些算法通常比你在 Python 中手动编写的任何算法都要快,随着你在数据处理的旅程中不断进步,你会发现这些算法的巧妙应用可以涵盖大量的数据处理需求。
在本章中,我们将涵盖以下几种方法:
-
基本的
pd.Series算术运算 -
基本的
pd.DataFrame算术运算 -
聚合
-
转换
-
映射
-
应用
-
摘要统计
-
分箱算法
-
使用
pd.get_dummies进行独热编码 -
使用
.pipe进行链式操作 -
从前 100 部电影中选择预算最低的电影
-
计算尾部止损订单价格
-
寻找棒球运动员最擅长…
-
理解每个团队中得分最高的位置
基本的 pd.Series 算术运算
探索 pandas 算法的最佳起点是使用pd.Series,因为它是 pandas 库提供的最基本的数据结构。基本的算术运算包括加法、减法、乘法和除法,正如你将在本节中看到的,pandas 提供了两种执行这些操作的方式。第一种方法允许 pandas 使用 Python 语言内置的+、-、*和/运算符,这对于初次接触该库的新用户来说是一种直观的学习工具。然而,为了涵盖 Python 语言未涵盖的数据分析特定功能,并支持稍后在本章中将介绍的使用.pipe 进行链式操作方法,pandas 还提供了pd.Series.add、pd.Series.sub、pd.Series.mul和pd.Series.div方法,分别对应着这些运算符。
pandas 库极力保持其 API 在所有数据结构中的一致性,因此你将会看到本节中的知识可以轻松地转移到pd.DataFrame结构中,唯一的区别是pd.Series是一维的,而pd.DataFrame是二维的。
如何做到这一点
让我们从 Python 的range表达式创建一个简单的pd.Series:
`ser = pd.Series(range(3), dtype=pd.Int64Dtype()) ser`
`0 0 1 1 2 2 dtype: Int64`
为了确立术语,让我们简单地考虑一个像 a + b 这样的表达式。在这种表达式中,我们使用了一个 二元操作符(+)。术语 二元 是指你需要将两个东西加在一起才能使这个表达式有意义,也就是说,像 a + 这样的表达式是不合逻辑的。这两个“东西”在技术上被视为 操作数;因此,在 a + b 中,我们有一个左操作数 a 和一个右操作数 b。
当其中一个操作数是 pd.Series 时,pandas 中最基本的算法表达式会包含另一个操作数是 标量,也就是说,只有一个值。当发生这种情况时,标量值会被 广播 到 pd.Series 的每个元素上,从而应用该算法。
例如,如果我们想将数字 42 加到 pd.Series 中的每一个元素,我们可以简单地这样表达:
`ser + 42`
`0 42 1 43 2 44 dtype: Int64`
pandas 库能够以 向量化 方式处理加法表达式(即数字 42 会一次性应用到所有值上,而无需用户在 Python 中使用 for 循环)。
减法可以自然地用 - 操作符来表示:
`ser - 42`
`0 -42 1 -41 2 -40 dtype: Int64`
类似地,乘法可以通过 * 操作符来表示:
`ser * 2`
`0 0 1 2 2 4 dtype: Int64`
到现在为止,你可能已经猜到,除法是用 / 操作符来表示的:
`ser / 2`
`0 0.0 1 0.5 2 1.0 dtype: Float64`
两个操作数都是 pd.Series 也是完全有效的:
`ser2 = pd.Series(range(10, 13), dtype=pd.Int64Dtype()) ser + ser2`
`0 10 1 12 2 14 dtype: Int64`
正如本节介绍中所提到的,虽然内置的 Python 操作符在大多数情况下是常用且可行的,pandas 仍然提供了专门的方法,如 pd.Series.add、pd.Series.sub、pd.Series.mul 和 pd.Series.div:
`ser1 = pd.Series([1., 2., 3.], dtype=pd.Float64Dtype()) ser2 = pd.Series([4., pd.NA, 6.], dtype=pd.Float64Dtype()) ser1.add(ser2)`
`0 5.0 1 <NA> 2 9.0 dtype: Float64`
pd.Series.add 相较于内置操作符的优势在于,它接受一个可选的 fill_value= 参数来处理缺失数据:
`ser1.add(ser2, fill_value=0.)`
`0 5.0 1 2.0 2 9.0 dtype: Float64`
本章稍后你还将接触到使用 .pipe 进行链式操作,这与 pandas 方法链式操作最为自然,而不是与内置的 Python 操作符链式操作。
还有更多内容……
当表达式中的两个操作数都是 pd.Series 对象时,重要的是要注意,pandas 会对齐行标签。这种对齐行为被视为一种特性,但对新手来说可能会令人惊讶。
为了了解为什么这很重要,我们先从两个具有相同行索引的 pd.Series 对象开始。当我们尝试将它们相加时,结果并不令人意外:
`ser1 = pd.Series(range(3), dtype=pd.Int64Dtype()) ser2 = pd.Series(range(3), dtype=pd.Int64Dtype()) ser1 + ser2`
`0 0 1 2 2 4 dtype: Int64`
那么当行索引值不相同时,会发生什么呢?一个简单的例子是将两个 pd.Series 对象相加,其中一个 pd.Series 使用的行索引是另一个的子集。你可以通过以下代码中的 ser3 来看到这一点,它只有两个值,并且使用默认的 pd.RangeIndex,值为 [0, 1]。当与 ser1 相加时,我们仍然得到一个包含三个元素的 pd.Series,但只有当两个 pd.Series 对象的行索引标签能够对齐时,值才会被相加:
`ser3 = pd.Series([2, 4], dtype=pd.Int64Dtype()) ser1 + ser3`
`0 2 1 5 2 <NA> dtype: Int64`
现在让我们看看当两个相同长度的pd.Series对象相加时会发生什么,但它们的行索引值不同:
`ser4 = pd.Series([2, 4, 8], index=[1, 2, 3], dtype=pd.Int64Dtype()) ser1 + ser4`
`0 <NA> 1 3 2 6 3 <NA> dtype: Int64`
对于一个更极端的例子,让我们考虑一个情况,其中一个pd.Series的行索引值是非唯一的:
`ser5 = pd.Series([2, 4, 8], index=[0, 1, 1], dtype=pd.Int64Dtype()) ser1 + ser5`
`0 2 1 5 1 9 2 <NA> dtype: Int64`
如果你有 SQL 的背景,pandas 在这里的行为类似于数据库中的FULL OUTER JOIN。每个行索引的标签都会被包含在输出中,pandas 会将可以在两个pd.Series对象中看到的标签进行匹配。这可以在像 PostgreSQL 这样的数据库中直接复制:
`WITH ser1 AS ( SELECT * FROM ( VALUES (0, 0), (1, 1), (2, 2) ) AS t(index, val1) ), ser5 AS ( SELECT * FROM ( VALUES (0, 2), (1, 4), (1, 8) ) AS t(index, val2) ) SELECT * FROM ser1 FULL OUTER JOIN ser5 USING(index);`
如果你直接在 PostgreSQL 中运行这段代码,你将得到以下结果:
`index | val1 | val2 ------+------+------ 0 | 0 | 2 1 | 1 | 8 1 | 1 | 4 2 | 2 | (4 rows)`
忽略顺序差异,你可以看到数据库返回了从[0, 1, 2]和[0, 1, 1]的组合中得到的所有唯一index值,以及任何相关的val1和val2值。尽管ser1只有一个index值为1,但这个值在ser5的index列中出现了两次。因此,FULL OUTER JOIN显示了来自ser5的两个val2值(4和8),同时重复了源自ser1的val1值(1)。
如果你接着在数据库中将val1和val2相加,你将得到一个结果,该结果与ser1 + ser5的输出相匹配,唯一的区别是数据库可能会选择不同的输出顺序:
`WITH ser1 AS ( SELECT * FROM ( VALUES (0, 0), (1, 1), (2, 2) ) AS t(index, val1) ), ser5 AS ( SELECT * FROM ( VALUES (0, 2), (1, 4), (1, 8) ) AS t(index, val2) ) SELECT index, val1 + val2 AS value FROM ser1 FULL OUTER JOIN ser5 USING(index);`
`index | value ------+------- 0 | 2 1 | 9 1 | 5 2 | (4 rows)`
基本的pd.DataFrame算术运算
在介绍了基本的pd.Series算术运算后,你会发现,相应的pd.DataFrame算术运算几乎是完全相同的,唯一的区别是我们的算法现在在二维数据上工作,而不仅仅是单维数据。这样,pandas API 使得无论数据的形状如何,都能轻松地解释数据,而且无需用户编写循环来与数据交互。这大大减少了开发人员的工作量,帮助你编写更快的代码——对开发人员来说是双赢。
它是如何工作的
让我们使用随机数创建一个小的 3x3pd.DataFrame:
`np.random.seed(42) df = pd.DataFrame( np.random.randn(3, 3), columns=["col1", "col2", "col3"], index=["row1", "row2", "row3"], ).convert_dtypes(dtype_backend="numpy_nullable") df`
`col1 col2 col3 row1 0.496714 -0.138264 0.647689 row2 1.52303 -0.234153 -0.234137 row3 1.579213 0.767435 -0.469474`
就像pd.Series一样,pd.DataFrame也支持带有标量参数的内置二进制运算符。这里是一个简化的加法操作:
`df + 1`
`col1 col2 col3 row1 1.496714 0.861736 1.647689 row2 2.52303 0.765847 0.765863 row3 2.579213 1.767435 0.530526`
下面是一个简化的乘法操作:
`df * 2`
`col1 col2 col3 row1 0.993428 -0.276529 1.295377 row2 3.04606 -0.468307 -0.468274 row3 3.158426 1.534869 -0.938949`
你还可以对pd.Series执行算术运算。默认情况下,pd.Series中的每一行标签都会被查找并与pd.DataFrame的列进行对齐。为了说明这一点,让我们创建一个小的pd.Series,它的索引标签与df的列标签匹配:
`ser = pd.Series( [20, 10, 0], index=["col1", "col2", "col3"], dtype=pd.Int64Dtype(), ) ser`
`col1 20 col2 10 col3 0 dtype: Int64`
如果你尝试将其添加到我们的pd.DataFrame中,它将取pd.Series中的col1值并将其添加到pd.DataFrame中col1列的每个元素,针对每个索引条目重复执行:
`df + ser`
`col1 col2 col3 row1 20.496714 9.861736 0.647689 row2 21.52303 9.765847 -0.234137 row3 21.579213 10.767435 -0.469474`
在pd.Series的行标签与pd.DataFrame的列标签不匹配的情况下,你可能会遇到缺失数据:
`ser = pd.Series( [20, 10, 0, 42], index=["col1", "col2", "col3", "new_column"], dtype=pd.Int64Dtype(), ) ser + df`
`col1 col2 col3 new_column row1 20.496714 9.861736 0.647689 NaN row2 21.52303 9.765847 -0.234137 NaN row3 21.579213 10.767435 -0.469474 NaN`
如果你希望控制pd.Series和pd.DataFrame的对齐方式,可以使用像pd.DataFrame.add、pd.DataFrame.sub、pd.DataFrame.mul和pd.DataFrame.div等方法的axis=参数。
让我们通过创建一个新的pd.Series来查看这个过程,使用的行标签与我们pd.DataFrame的行标签更好地对齐:
`ser = pd.Series( [20, 10, 0, 42], index=["row1", "row2", "row3", "row4"], dtype=pd.Int64Dtype(), ) ser`
`row1 20 row2 10 row3 0 row4 42 dtype: Int64`
指定df.add(ser, axis=0)将会匹配pd.Series和pd.DataFrame中的行标签:
`df.add(ser, axis=0)`
`col1 col2 col3 row1 20.496714 19.861736 20.647689 row2 11.52303 9.765847 9.765863 row3 1.579213 0.767435 -0.469474 row4 <NA> <NA> <NA>`
你还可以将两个pd.DataFrame作为加法、减法、乘法和除法的操作数。以下是如何将两个pd.DataFrame对象相乘:
`df * df`
`col1 col2 col3 row1 0.246725 0.019117 0.4195 row2 2.31962 0.054828 0.05482 row3 2.493913 0.588956 0.220406`
当然,在执行此操作时,你仍然需要注意索引对齐规则——项目总是按标签对齐,而不是按位置对齐!
让我们创建一个新的 3x3 pd.DataFrame,具有不同的行和列标签,以展示这一点:
`np.random.seed(42) df2 = pd.DataFrame(np.random.randn(3, 3)) df2 = df2.convert_dtypes(dtype_backend="numpy_nullable") df2`
`0 1 2 0 0.496714 -0.138264 0.647689 1 1.52303 -0.234153 -0.234137 2 1.579213 0.767435 -0.469474`
尝试将其添加到我们之前的pd.DataFrame中,将生成一个行索引,标签为["row1", "row2", "row3", 0, 1, 2],列索引,标签为["col1", "col2", "col3", 0, 1, 2]。因为无法对齐标签,所有数据都会返回缺失值:
`df + df2`
`col1 col2 col3 0 1 2 row1 <NA> <NA> <NA> <NA> <NA> <NA> row2 <NA> <NA> <NA> <NA> <NA> <NA> row3 <NA> <NA> <NA> <NA> <NA> <NA> 0 <NA> <NA> <NA> <NA> <NA> <NA> 1 <NA> <NA> <NA> <NA> <NA> <NA> 2 <NA> <NA> <NA> <NA> <NA> <NA>`
聚合
聚合(也称为归约)帮助你将多个值从一个值的序列中减少为单个值。即使这个技术术语对你来说比较新,你无疑在数据过程中已经遇到过许多聚合。诸如记录的计数、总和或销售额、平均价格等,都是非常常见的聚合。
在本食谱中,我们将探索 pandas 内置的许多聚合方法,同时形成对这些聚合如何应用的理解。在你的数据旅程中,大多数分析都涉及将大型数据集进行聚合,转化为你的观众可以理解的结果。大多数公司高层并不感兴趣接收一大堆事务数据,他们只关心这些事务中数值的总和、最小值、最大值、平均值等。因此,有效地使用和应用聚合方法是将复杂的数据转换管道转化为他人可以使用和采取行动的简单输出的关键组成部分。
如何操作
许多基础聚合作为方法直接实现于pd.Series对象,这使得计算常见的输出(如count、sum、max等)变得非常简单。
为了开始这个食谱,我们再次从一个包含随机数的pd.Series开始:
`np.random.seed(42) ser = pd.Series(np.random.rand(10_000), dtype=pd.Float64Dtype())`
pandas 库提供了许多常用的聚合方法,如pd.Series.count、pd.Series.mean、pd.Series.std、pd.Series.min、pd.Series.max和pd.Series.sum:
`print(f"Count is: {ser.count()}") print(f"Mean value is: {ser.mean()}") print(f"Standard deviation is: {ser.std()}") print(f"Minimum value is: {ser.min()}") print(f"Maximum value is: {ser.max()}") print(f"Summation is: {ser.sum()}")`
`Count is: 10000 Mean value is: 0.49415955768429964 Standard deviation is: 0.2876301265269928 Minimum value is: 1.1634755366141114e-05 Maximum value is: 0.9997176732861306 Summation is: 4941.595576842997`
与直接调用这些方法不同,调用这些聚合方法的一个更通用的方式是使用pd.Series.agg,并将你想执行的聚合名称作为字符串传递:
`print(f"Count is: {ser.agg('count')}") print(f"Mean value is: {ser.agg('mean')}") print(f"Standard deviation is: {ser.agg('std')}") print(f"Minimum value is: {ser.agg('min')}") print(f"Maximum value is: {ser.agg('max')}") print(f"Summation is: {ser.agg('sum')}")`
`Count is: 10000 Mean value is: 0.49415955768429964 Standard deviation is: 0.2876301265269928 Minimum value is: 1.1634755366141114e-05 Maximum value is: 0.9997176732861306 Summation is: 4941.595576842997`
使用pd.Series.agg的一个优点是它可以为你执行多个聚合操作。例如,如果你想要在一步中计算一个字段的最小值和最大值,你可以通过将一个列表传递给pd.Series.agg来实现:
`ser.agg(["min", "max"])`
`min 0.000012 max 0.999718 dtype: float64`
聚合pd.Series是直接的,因为只有一个维度需要聚合。对于pd.DataFrame来说,有两个可能的维度需要聚合,因此作为库的最终用户,你需要考虑更多因素。
为了演示这一点,让我们创建一个包含随机数的pd.DataFrame:
`np.random.seed(42) df = pd.DataFrame( np.random.randn(10_000, 6), columns=list("abcdef"), ).convert_dtypes(dtype_backend="numpy_nullable") df`
`a b c d e f 0 0.496714 -0.138264 0.647689 1.523030 -0.234153 -0.234137 1 1.579213 0.767435 -0.469474 0.542560 -0.463418 -0.465730 2 0.241962 -1.913280 -1.724918 -0.562288 -1.012831 0.314247 3 -0.908024 -1.412304 1.465649 -0.225776 0.067528 -1.424748 4 -0.544383 0.110923 -1.150994 0.375698 -0.600639 -0.291694 … … … … … … … 9995 1.951254 0.324704 1.937021 -0.125083 0.589664 0.869128 9996 0.624062 -0.317340 -1.636983 2.390878 -0.597118 2.670553 9997 -0.470192 1.511932 0.718306 0.764051 -0.495094 -0.273401 9998 -0.259206 0.274769 -0.084735 -0.406717 -0.815527 -0.716988 9999 0.533743 -0.701856 -1.099044 0.141010 -2.181973 -0.006398 10000 rows × 6 columns`
默认情况下,使用像pd.DataFrame.sum这样的内置方法进行聚合时,会沿着列进行操作,也就是说,每一列都会单独进行聚合。然后,pandas 会将每一列的聚合结果显示为pd.Series中的一项:
`df.sum()`
`a -21.365908 b -7.963987 c 152.032992 d -180.727498 e 29.399311 f 25.042078 dtype: Float64`
如果你想要对每一行的数据进行聚合,可以指定axis=1参数,值得注意的是,pandas 在axis=0操作上进行了更多优化,因此这可能比聚合列要显著慢。尽管如此,这是 pandas 的一个独特功能,当性能不是主要关注点时,它还是非常有用的:
`df.sum(axis=1)`
`0 2.060878 1 1.490586 2 -4.657107 3 -2.437675 4 -2.101088 ... 9995 5.54669 9996 3.134053 9997 1.755601 9998 -2.008404 9999 -3.314518 Length: 10000, dtype: Float64`
就像pd.Series一样,pd.DataFrame也有一个.agg方法,可以用于一次性应用多个聚合操作:
`df.agg(["min", "max"])`
`a b c d e f min -4.295391 -3.436062 -3.922400 -4.465604 -3.836656 -4.157734 max 3.602415 3.745379 3.727833 4.479084 3.691625 3.942331`
还有更多…
在如何做到这一点部分的例子中,我们将像min和max这样的函数作为字符串传递给.agg。对于简单的函数来说,这很好用,但对于更复杂的情况,你也可以传入可调用的参数。每个可调用对象应该接受一个pd.Series作为参数,并将其归约为标量:
`def mean_and_add_42(ser: pd.Series): return ser.mean() + 42 def mean_and_sub_42(ser: pd.Series): return ser.mean() - 42 np.random.seed(42) ser = pd.Series(np.random.rand(10_000), dtype=pd.Float64Dtype()) ser.agg([mean_and_add_42, mean_and_sub_42])`
`mean_and_add_42 42.49416 mean_and_sub_42 -41.50584 dtype: float64`
变换
与聚合不同,变换不会将一组值压缩为单一值,而是保持调用对象的形状。这个特定的例子可能看起来很平凡,因为它来自于前一节的聚合内容,但变换和聚合最终会成为非常互补的工具,用于像“群体的百分比总和”之类的计算,这些将在后续的手册中展示。
如何做到这一点
让我们创建一个小的pd.Series:
`ser = pd.Series([-1, 0, 1], dtype=pd.Int64Dtype())`
就像我们之前在pd.Series.agg中看到的那样,pd.Series.transform也可以接受一个要应用的函数列表。然而,pd.Series.agg期望这些函数返回一个单一值,而pd.Series.transform期望这些函数返回一个具有相同索引和形状的pd.Series:
`def adds_one(ser: pd.Series) -> pd.Series: return ser + 1 ser.transform(["abs", adds_one])`
`abs adds_one 0 1 0 1 0 1 2 1 2`
就像pd.DataFrame.agg默认会聚合每一列一样,pd.DataFrame.transform默认会变换每一列。让我们创建一个小的pd.DataFrame来看看这个过程:
`df = pd.DataFrame( np.arange(-5, 4, 1).reshape(3, -1) ).convert_dtypes(dtype_backend="numpy_nullable") df`
`0 1 2 0 -5 -4 -3 1 -2 -1 0 2 1 2 3`
抛开实现细节,像df.transform("abs")这样的调用将对每一列单独应用绝对值函数,然后将结果拼接回一个pd.DataFrame:
`df.transform("abs")`
`0 1 2 0 5 4 3 1 2 1 0 2 1 2 3`
如果你将多个变换函数传递给pd.DataFrame.transform,你将得到一个pd.MultiIndex:
`def add_42(ser: pd.Series): return ser + 42 df.transform(["abs", add_42])`
`0 1 2 abs add_42 abs add_42 abs add_42 0 5 37 4 38 3 39 1 2 40 1 41 0 42 2 1 43 2 44 3 45`
还有更多…
正如本食谱介绍中提到的,转换和聚合可以与GroupBy概念自然地结合使用,这将在第八章中介绍,分组方法。特别是,我们的分组基础食谱将有助于比较和对比聚合与转换,并强调如何使用转换来简洁而富有表现力地计算“分组百分比”。
映射
到目前为止,我们看到的.agg和.transform方法一次性作用于整个值序列。通常在 pandas 中,这是一个好事;它允许 pandas 执行向量化操作,速度快且计算高效。
但是,有时,作为最终用户,你可能决定愿意牺牲性能以换取定制或更细粒度的控制。这时,.map方法可以派上用场;.map帮助你将函数逐一应用到 pandas 对象的每个元素。
如何做到
假设我们有一个包含数字和数字列表混合的数据pd.Series:
`ser = pd.Series([123.45, [100, 113], 142.0, [110, 113, 119]]) ser`
`0 123.45 1 [100, 113] 2 142.0 3 [110, 113, 119] dtype: object`
.agg或.transform在这里不适用,因为我们没有统一的数据类型——我们实际上需要检查每个元素,决定如何处理它。
对于我们的分析,假设当我们遇到一个数字时,我们愿意直接返回该值。如果我们遇到一个值的列表,我们希望计算该列表中的所有值的平均值并返回它。实现这个功能的函数如下所示:
`def custom_average(value): if isinstance(value, list): return sum(value) / len(value) return value`
然后我们可以使用pd.Series.map将其应用到pd.Series的每个元素:
`ser.map(custom_average)`
`0 123.45 1 106.50 2 142.00 3 114.00 dtype: float64`
如果我们有一个包含这种数据类型的pd.DataFrame,那么pd.DataFrame.map也能够很好地应用这个函数:
`df = pd.DataFrame([ [2., [1, 2], 3.], [[4, 5], 5, 7.], [1, 4, [1, 1, 5.5]], ]) df`
`0 1 2 0 2.0 [1, 2] 3.0 1 [4, 5] 5 7.0 2 1 4 [1, 1, 5.5]`
`df.map(custom_average)`
`0 1 2 0 2.0 1.5 3.0 1 4.5 5.0 7.0 2 1.0 4.0 2.5`
还有更多…
在上述示例中,你也可以使用pd.Series.transform,而不是使用pd.Series.map:
`ser.transform(custom_average)`
`0 123.45 1 106.50 2 142.00 3 114.00 dtype: float64`
然而,你不会得到与pd.DataFrame.transform相同的结果:
`df.transform(custom_average)`
`0 1 2 0 2.0 [1, 2] 3.0 1 [4, 5] 5 7.0 2 1 4 [1, 1, 5.5]`
为什么会这样呢?记住,.map会明确地对每个元素应用一个函数,无论你是操作pd.Series还是pd.DataFrame。pd.Series.transform也很乐意对它包含的每个元素应用一个函数,但pd.DataFrame.transform本质上是遍历每一列,并将该列作为参数传递给可调用的函数。
因为我们的函数是这样实现的:
`def custom_average(value): if isinstance(value, list): return sum(value) / len(value) return value`
当传入一个pd.Series时,isinstance(value, list)检查会失败,结果你只是返回了pd.Series本身。如果我们稍微调整一下我们的函数:
`def custom_average(value): if isinstance(value, (pd.Series, pd.DataFrame)): raise TypeError("Received a pandas object - expected a single value!") if isinstance(value, list): return sum(value) / len(value) return value`
那么pd.DataFrame.transform的行为就更清晰了:
`df.transform(custom_average)`
`TypeError: Received a pandas object - expected a single value!`
虽然可能存在概念上的重叠,但通常,在代码中,你应该把.map看作是逐元素操作,而.agg和.transform则尽可能一次性处理更大范围的数据序列。
应用
apply是一个常用的方法,我甚至认为它被过度使用。到目前为止,我们看到的.agg、.transform和.map方法都有相对明确的语义(.agg用于汇总,.transform保持形状不变,.map逐元素应用函数),但是当你使用.apply时,它几乎可以模拟所有这些功能。一开始,这种灵活性可能看起来很不错,但由于.apply让 pandas 来“做正确的事情”,通常你最好选择最明确的方法,以避免意外结果。
即使如此,你仍然会在实际代码中看到很多代码(尤其是那些没有阅读这本书的用户写的代码);因此,理解它的功能和局限性是非常有价值的。
如何操作
调用pd.Series.apply会使.apply像.map一样工作(即,函数应用于pd.Series的每个单独元素)。
让我们来看一个稍微有点牵强的函数,它会打印出每个元素:
`def debug_apply(value): print(f"Apply was called with value:\n{value}")`
通过.apply来传递:
`ser = pd.Series(range(3), dtype=pd.Int64Dtype()) ser.apply(debug_apply)`
`Apply was called with value: 0 Apply was called with value: 1 Apply was called with value: 2 0 None 1 None 2 None dtype: object`
会得到与pd.Series.map完全相同的行为:
`ser.map(debug_apply)`
`Apply was called with value: 0 Apply was called with value: 1 Apply was called with value: 2 0 None 1 None 2 None dtype: object`
pd.Series.apply的工作方式类似于 Python 循环,对每个元素调用函数。因为我们的函数没有返回任何值,所以我们得到的pd.Series是一个索引相同的None值数组。
而pd.Series.apply是逐元素应用的,pd.DataFrame.apply则按列工作,将每一列视为一个pd.Series。让我们用一个形状为(3, 2)的pd.DataFrame来看看它的实际应用:
`df = pd.DataFrame( np.arange(6).reshape(3, -1), columns=list("ab"), ).convert_dtypes(dtype_backend="numpy_nullable") df`
`a b 0 0 1 1 2 3 2 4 5`
`df.apply(debug_apply)`
`Apply was called with value: 0 0 1 2 2 4 Name: a, dtype: Int64 Apply was called with value: 0 1 1 3 2 5 Name: b, dtype: Int64 a None b None dtype: object`
如上所示,在给定的两列数据中,函数仅被调用了两次,但在包含三行的pd.Series中,它被应用了三次。
除了pd.DataFrame.apply实际应用函数的次数外,返回值的形状也可能有所不同,可能与.agg和.transform的功能相似。我们之前的例子更接近.agg,因为它返回了一个单一的None值,但如果我们返回我们打印的元素,那么行为更像是.transform:
`def debug_apply_and_return(value): print(value) return value df.apply(debug_apply_and_return)`
`0 0 1 2 2 4 Name: a, dtype: Int64 0 1 1 3 2 5 Name: b, dtype: Int64 a b 0 0 1 1 2 3 2 4 5`
如果你觉得这很困惑,你不是一个人。相信 pandas 在使用.apply时“做正确的事情”可能是一个有风险的选择;我强烈建议用户在使用.apply之前,先尝试使用.agg、.transform或.map,直到这些方法无法满足需求为止。
汇总统计
汇总统计提供了一种快速了解数据基本属性和分布的方式。在这一部分,我们介绍了两个强大的 pandas 方法:pd.Series.value_counts和pd.Series.describe,它们可以作为探索的有用起点。
如何操作
pd.Series.value_counts方法为每个不同的数据点附加了频率计数,使得查看每个值出现的频率变得简单。这对离散数据特别有用:
`ser = pd.Series(["a", "b", "c", "a", "c", "a"], dtype=pd.StringDtype()) ser.value_counts()`
`a 3 c 2 b 1 Name: count, dtype: Int64`
对于连续数据,pd.Series.describe是将一堆计算打包成一个方法调用。通过调用这个方法,你可以轻松查看数据的计数、均值、最小值、最大值,以及数据的高层次分布:
`ser = pd.Series([0, 42, 84], dtype=pd.Int64Dtype()) ser.describe()`
`count 3.0 mean 42.0 std 42.0 min 0.0 25% 21.0 50% 42.0 75% 63.0 max 84.0 dtype: Float64`
默认情况下,我们会看到通过 25%、50%、75% 和最大值(或 100%)四分位数来概述我们的分布。如果您的数据分析集中在分布的某一特定部分,您可以通过提供 percentiles= 参数来控制此方法返回的内容:
`ser.describe(percentiles=[.10, .44, .67])`
`count 3.0 mean 42.0 std 42.0 min 0.0 10% 8.4 44% 36.96 50% 42.0 67% 56.28 max 84.0 dtype: Float64`
分箱算法
分箱(Binning)是将连续变量分成离散区间的过程。这对于将可能是无限的数值转化为有限的“区间”以进行分析是非常有用的。
如何实现
假设我们已经从一个系统的用户那里收集了调查数据。调查中的一个问题询问用户的年龄,产生的数据如下所示:
`df = pd.DataFrame([ ["Jane", 34], ["John", 18], ["Jamie", 22], ["Jessica", 36], ["Jackie", 33], ["Steve", 40], ["Sam", 30], ["Stephanie", 66], ["Sarah", 55], ["Aaron", 22], ["Erin", 28], ["Elsa", 37], ], columns=["name", "age"]) df = df.convert_dtypes(dtype_backend="numpy_nullable") df.head()`
`name age 0 Jane 34 1 John 18 2 Jamie 22 3 Jessica 36 4 Jackie 33`
我们不打算将每个年龄当作一个独立的数值,而是使用 pd.cut 将每条记录分到一个年龄组。作为第一次尝试,我们将 pd.Series 和我们想要生成的区间数量作为参数传递:
`pd.cut(df["age"], 4)`
`0 (30.0, 42.0] 1 (17.952, 30.0] 2 (17.952, 30.0] 3 (30.0, 42.0] 4 (30.0, 42.0] 5 (30.0, 42.0] 6 (17.952, 30.0] 7 (54.0, 66.0] 8 (54.0, 66.0] 9 (17.952, 30.0] 10 (17.952, 30.0] 11 (30.0, 42.0] Name: age, dtype: category Categories (4, interval[float64, right]): [(17.952, 30.0] < (30.0, 42.0] < (42.0, 54.0] < (54.0, 66.0]]`
这会生成一个具有 4 个不同区间的 pd.CategoricalDtype —— (17.952, 30.0]、(30.0, 42.0]、(42.0, 54.0] 和 (54.0, 66.0]。除了第一个区间从 17.952 开始有一些意外的小数位外,这些区间都覆盖了 12 年的等距范围,得出这个范围的原因是最大值(66)减去最小值(18)得到的年龄差为 48 年,然后将其平分成 4 个区间,每个区间的跨度为 12 年。
我们在第一个区间看到的年龄17.952可能对于 pandas 内部使用的任何算法在确定区间时有意义,但对于我们来说并不重要,因为我们知道我们处理的是整数。幸运的是,可以通过 precision= 关键字参数来控制,去除任何小数部分:
`pd.cut(df["age"], 4, precision=0)`
`0 (30.0, 42.0] 1 (18.0, 30.0] 2 (18.0, 30.0] 3 (30.0, 42.0] 4 (30.0, 42.0] 5 (30.0, 42.0] 6 (18.0, 30.0] 7 (54.0, 66.0] 8 (54.0, 66.0] 9 (18.0, 30.0] 10 (18.0, 30.0] 11 (30.0, 42.0] Name: age, dtype: category Categories (4, interval[float64, right]): [(18.0, 30.0] < (30.0, 42.0] < (42.0, 54.0] < (54.0, 66.0]]`
pd.cut 并不限于生成像上面那样大小相等的区间。如果我们想要将每个人按 10 年一组进行分箱,可以将这些范围作为第二个参数提供:
`pd.cut(df["age"], [10, 20, 30, 40, 50, 60, 70])`
`0 (30, 40] 1 (10, 20] 2 (20, 30] 3 (30, 40] 4 (30, 40] 5 (30, 40] 6 (20, 30] 7 (60, 70] 8 (50, 60] 9 (20, 30] 10 (20, 30] 11 (30, 40] Name: age, dtype: category Categories (6, interval[int64, right]): [(10, 20] < (20, 30] < (30, 40] < (40, 50] < (50, 60] < (60, 70]]`
然而,这种方式有点过于严格,因为它没有考虑到 70 岁以上的用户。为了解决这个问题,我们可以将最后一个区间的边界从 70 改为 999,并将其视为“其他”区间:
`pd.cut(df["age"], [10, 20, 30, 40, 50, 60, 999])`
`0 (30, 40] 1 (10, 20] 2 (20, 30] 3 (30, 40] 4 (30, 40] 5 (30, 40] 6 (20, 30] 7 (60, 999] 8 (50, 60] 9 (20, 30] 10 (20, 30] 11 (30, 40] Name: age, dtype: category Categories (6, interval[int64, right]): [(10, 20] < (20, 30] < (30, 40] < (40, 50] < (50, 60] < (60, 999]]`
反过来,这生成了标签 (60, 999),从显示角度来看,这并不令人满意。如果我们对默认生成的标签不满意,可以通过 labels= 参数控制它们的输出:
`pd.cut( df["age"], [10, 20, 30, 40, 50, 60, 999], labels=["10-20", "20-30", "30-40", "40-50", "50-60", "60+"], )`
`0 30-40 1 10-20 2 20-30 3 30-40 4 30-40 5 30-40 6 20-30 7 60+ 8 50-60 9 20-30 10 20-30 11 30-40 Name: age, dtype: category Categories (6, object): ['10-20' < '20-30' < '30-40' < '40-50' < '50-60' < '60+']`
然而,上面的标签并不完全正确。请注意,我们提供了 30-40 和 40-50,但是如果某人恰好是 40 岁呢?他们会被放入哪个区间?
幸运的是,我们可以通过数据中 Steve 的记录看到这一点,他恰好符合这个标准。如果查看他所在的默认区间,它显示为 (30, 40]:
`df.assign(age_bin=lambda x: pd.cut(x["age"], [10, 20, 30, 40, 50, 60, 999]))`
`name age age_bin 0 Jane 34 (30, 40] 1 John 18 (10, 20] 2 Jamie 22 (20, 30] 3 Jessica 36 (30, 40] 4 Jackie 33 (30, 40] 5 Steve 40 (30, 40] 6 Sam 30 (20, 30] 7 Stephanie 66 (60, 999] 8 Sarah 55 (50, 60] 9 Aaron 22 (20, 30] 10 Erin 28 (20, 30] 11 Elsa 37 (30, 40]`
默认情况下,分箱是右闭的,这意味着每个区间可以被认为是包括特定值。如果我们想要的是不包括特定值的行为,可以通过 right 参数来控制:
`df.assign( age_bin=lambda x: pd.cut(x["age"], [10, 20, 30, 40, 50, 60, 999], right=False) )`
`name age age_bin 0 Jane 34 [30, 40) 1 John 18 [10, 20) 2 Jamie 22 [20, 30) 3 Jessica 36 [30, 40) 4 Jackie 33 [30, 40) 5 Steve 40 [40, 50) 6 Sam 30 [30, 40) 7 Stephanie 66 [60, 999) 8 Sarah 55 [50, 60) 9 Aaron 22 [20, 30) 10 Erin 28 [20, 30) 11 Elsa 37 [30, 40)`
这将 Steve 的区间从 (30, 40] 改为 [40, 50)。在默认的字符串表示中,方括号表示该边界是包含某个特定值的,而圆括号则是不包含的。
使用 pd.get_dummies 进行独热编码
在数据分析和机器学习应用中,将类别型数据转换为 0/1 值的情况并不罕见,因为后者能更容易地被数值算法解释。这一过程通常称为 独热编码,输出通常被称为 虚拟指示符。
如何实现
让我们从一个包含离散颜色集的小 pd.Series 开始:
`ser = pd.Series([ "green", "brown", "blue", "amber", "hazel", "amber", "green", "blue", "green", ], name="eye_colors", dtype=pd.StringDtype()) ser`
`0 green 1 brown 2 blue 3 amber 4 hazel 5 amber 6 green 7 blue 8 green Name: eye_colors, dtype: string`
将其作为参数传递给 pd.get_dummies 将创建一个具有布尔列的 pd.DataFrame,每个颜色对应一列。每一行会有一个 True 的列,将其映射回原始值;该行中的其他所有列将是 False:
`pd.get_dummies(ser)`
`amber blue brown green hazel 0 False False False True False 1 False False True False False 2 False True False False False 3 True False False False False 4 False False False False True 5 True False False False False 6 False False False True False 7 False True False False False 8 False False False True False`
如果我们不满意默认的列名,可以通过添加前缀来修改它们。在数据建模中,一个常见的约定是将布尔列的前缀加上 is_:
`pd.get_dummies(ser, prefix="is")`
`is_amber is_blue is_brown is_green is_hazel 0 False False False True False 1 False False True False False 2 False True False False False 3 True False False False False 4 False False False False True 5 True False False False False 6 False False False True False 7 False True False False False 8 False False False True False`
使用 .pipe 链式调用
在编写 pandas 代码时,开发者通常遵循两种主要的风格形式。第一种方法是大量使用变量,无论是创建新的变量,像这样:
`df = pd.DataFrame(...) df1 = do_something(df) df2 = do_another_thing(df1) df3 = do_yet_another_thing(df2)`
或者只是反复重新赋值给同一个变量:
`df = pd.DataFrame(...) df = do_something(df) df = do_another_thing(df) df = do_yet_another_thing(df)`
另一种方法是将代码表达为 管道,每个步骤接受并返回一个 pd.DataFrame:
`( pd.DataFrame(...) .pipe(do_something) .pipe(do_another_thing) .pipe(do_yet_another_thing) )`
使用基于变量的方法,你必须在程序中创建多个变量,或者在每次重新赋值时改变 pd.DataFrame 的状态。相比之下,管道方法不会创建任何新变量,也不会改变 pd.DataFrame 的状态。
虽然管道方法从理论上讲可以更好地由查询优化器处理,但截至写作时,pandas 并没有提供此类功能,且很难猜测未来可能会是什么样子。因此,选择这两种方法几乎对性能没有影响;这真的是风格上的问题。
我鼓励你熟悉这两种方法。你可能会发现有时将代码表达为管道更容易;而其他时候,可能会觉得那样做很繁琐。没有强制要求使用其中任何一种方法,因此你可以在代码中自由混合和匹配这两种风格。
如何实现
让我们从一个非常基础的 pd.DataFrame 开始。列及其内容暂时不重要:
`df = pd.DataFrame({ "col1": pd.Series([1, 2, 3], dtype=pd.Int64Dtype()), "col2": pd.Series(["a", "b", "c"], dtype=pd.StringDtype()), }) df`
`col1 col2 0 1 a 1 2 b 2 3 c`
现在让我们创建一些示例函数,这些函数将改变列的内容。这些函数应该接受并返回一个 pd.DataFrame,你可以从代码注释中看到这一点:
`def change_col1(df: pd.DataFrame) -> pd.DataFrame: return df.assign(col1=pd.Series([4, 5, 6], dtype=pd.Int64Dtype())) def change_col2(df: pd.DataFrame) -> pd.DataFrame: return df.assign(col2=pd.Series(["X", "Y", "Z"], dtype=pd.StringDtype()))`
正如在本教程开头提到的,应用这些函数的最常见方法之一是将它们列为程序中的单独步骤,并将每个步骤的结果分配给一个新变量:
`df2 = change_col1(df) df3 = change_col2(df2) df3`
`col1 col2 0 4 X 1 5 Y 2 6 Z`
如果我们希望完全避免使用中间变量,我们也可以尝试将函数调用嵌套在一起:
`change_col2(change_col1(df))`
`col1 col2 0 4 X 1 5 Y 2 6 Z`
然而,这并不会使代码更加易读,特别是考虑到change_col1在change_col2之前执行。
通过将这个过程表达为管道,我们可以避免使用变量,并更容易表达应用的操作顺序。为了实现这一点,我们将使用pd.DataFrame.pipe方法:
`df.pipe(change_col1).pipe(change_col2)`
`col1 col2 0 4 X 1 5 Y 2 6 Z`
如你所见,我们得到了与之前相同的结果,但没有使用变量,而且以一种可以说更易读的方式呈现。
如果你想在管道中应用的某些函数需要接受更多参数,pd.DataFrame.pipe可以将它们转发给你。例如,让我们看看如果我们向change_col2函数添加一个新的str_case参数会发生什么:
`from typing import Literal def change_col2( df: pd.DataFrame, str_case: Literal["upper", "lower"] ) -> pd.DataFrame: if str_case == "upper": values = ["X", "Y", "Z"] else: values = ["x", "y", "z"] return df.assign(col2=pd.Series(values, dtype=pd.StringDtype()))`
正如你在pd.DataFrame.pipe中看到的,你可以简单地将参数作为位置参数或关键字参数传递,就像你直接调用change_col2函数一样:
`df.pipe(change_col2, str_case="lower")`
`col1 col2 0 1 x 1 2 y 2 3 z`
重申一下我们在本食谱介绍中提到的,这些风格之间几乎没有功能差异。我鼓励你同时学习这两种风格,因为你不可避免地会看到代码同时以这两种方式编写。为了你自身的开发,你甚至可能会发现混合使用这两种方法是最有效的。
从前 100 部电影中选择最低预算的电影
现在我们已经从理论层面覆盖了许多核心的 pandas 算法,我们可以开始看看一些“真实世界”的数据集,并探讨常见的探索方法。
Top N 分析是一种常见技术,通过该技术,你可以根据数据在单一变量上的表现来筛选数据。大多数分析工具都能帮助你筛选数据,以回答类似销售额最高的前 10 个客户是哪些? 或者 库存最少的 10 个产品是哪些? 这样的问题。当这些方法链式调用时,你甚至可以形成引人注目的新闻标题,比如在前 100 所大学中,这 5 所学费最低,或在前 50 个宜居城市中,这 10 个最实惠。
由于这些类型的分析非常常见,pandas 提供了内置功能来帮助你轻松执行这些分析。在本食谱中,我们将查看pd.DataFrame.nlargest和pd.DataFrame.nsmallest,并看看如何将它们结合使用,以回答类似*从前 100 部电影中,哪些电影预算最低?*的问题。
如何做
我们从读取电影数据集并选择movie_title、imdb_score、budget和gross列开始:
`df = pd.read_csv( "data/movie.csv", usecols=["movie_title", "imdb_score", "budget", "gross"], dtype_backend="numpy_nullable", ) df.head()`
`gross movie_title budget imdb_score 0 760505847.0 Avatar 237000000.0 7.9 1 309404152.0 Pirates of the Caribbean: At World's End 300000000.0 7.1 2 200074175.0 Spectre 245000000.0 6.8 3 448130642.0 The Dark Knight Rises 250000000.0 8.5 4 <NA> Star Wars: Episode VII - The Force Awakens <NA> 7.1`
pd.DataFrame.nlargest方法可以用来选择按imdb_score排序的前 100 部电影:
`df.nlargest(100, "imdb_score").head()`
`gross movie_title budget imdb_score 2725 <NA> Towering Inferno <NA> 9.5 1920 28341469.0 The Shawshank Redemption 25000000.0 9.3 3402 134821952.0 The Godfather 6000000.0 9.2 2779 447093.0 Dekalog <NA> 9.1 4312 <NA> Kickboxer: Vengeance 17000000.0 9.1`
现在我们已经选择了前 100 部电影,我们可以链式调用pd.DataFrame.nsmallest,从中返回五部预算最低的电影:
`df.nlargest(100, "imdb_score").nsmallest(5, "budget")`
`gross movie_title budget imdb_score 4804 <NA> Butterfly Girl 180000.0 8.7 4801 925402.0 Children of Heaven 180000.0 8.5 4706 <NA> 12 Angry Men 350000.0 8.9 4550 7098492.0 A Separation 500000.0 8.4 4636 133778.0 The Other Dream Team 500000.0 8.4`
还有更多…
可以将列名列表作为columns=参数传递给pd.DataFrame.nlargest和pd.DataFrame.nsmallest方法。这在遇到第一列中有重复值共享第 n 名排名时才有用,以此来打破平局。
为了看到这一点的重要性,让我们尝试按imdb_score选择前 10 部电影:
`df.nlargest(10, "imdb_score")`
`gross movie_title budget imdb_score 2725 <NA> Towering Inferno <NA> 9.5 1920 28341469.0 The Shawshank Redemption 25000000.0 9.3 3402 134821952.0 The Godfather 6000000.0 9.2 2779 447093.0 Dekalog <NA> 9.1 4312 <NA> Kickboxer: Vengeance 17000000.0 9.1 66 533316061.0 The Dark Knight 185000000.0 9.0 2791 57300000.0 The Godfather: Part II 13000000.0 9.0 3415 <NA> Fargo <NA> 9.0 335 377019252.0 The Lord of the Rings: The Return of the King 94000000.0 8.9 1857 96067179.0 Schindler's List 22000000.0 8.9`
正如你所看到的,前 10 名中的最低imdb_score是8.9。然而,实际上有超过 10 部电影的评分为8.9或更高:
`df[df["imdb_score"] >= 8.9]`
`gross movie_title budget imdb_score 66 533316061.0 The Dark Knight 185000000.0 9.0 335 377019252.0 The Lord of the Rings: The Return of the King 94000000.0 8.9 1857 96067179.0 Schindler's List 22000000.0 8.9 1920 28341469.0 The Shawshank Redemption 25000000.0 9.3 2725 <NA> Towering Inferno <NA> 9.5 2779 447093.0 Dekalog <NA> 9.1 2791 57300000.0 The Godfather: Part II 13000000.0 9.0 3295 107930000.0 Pulp Fiction 8000000.0 8.9 3402 134821952.0 The Godfather 6000000.0 9.2 3415 <NA> Fargo <NA> 9.0 4312 <NA> Kickboxer: Vengeance 17000000.0 9.1 4397 6100000.0 The Good, the Bad and the Ugly 1200000.0 8.9 4706 <NA> 12 Angry Men 350000.0 8.9`
那些出现在前 10 名的电影,恰好是 pandas 遇到的前两部评分为该分数的电影。但是,你可以使用gross列作为平局的决胜者:
`df.nlargest(10, ["imdb_score", "gross"])`
`gross movie_title budget imdb_score 2725 <NA> Towering Inferno <NA> 9.5 1920 28341469.0 The Shawshank Redemption 25000000.0 9.3 3402 134821952.0 The Godfather 6000000.0 9.2 2779 447093.0 Dekalog <NA> 9.1 4312 <NA> Kickboxer: Vengeance 17000000.0 9.1 66 533316061.0 The Dark Knight 185000000.0 9.0 2791 57300000.0 The Godfather: Part II 13000000.0 9.0 3415 <NA> Fargo <NA> 9.0 335 377019252.0 The Lord of the Rings: The Return of the King 94000000.0 8.9 3295 107930000.0 Pulp Fiction 8000000.0 8.9`
因为《低俗小说》票房更高,所以你可以看到它取代了《辛德勒的名单》成为我们前 10 名分析中的一部分。
计算追踪止损单价格
有许多股票交易策略。许多投资者采用的一种基本交易类型是止损单。止损单是投资者在市场价格达到某个点时执行的买入或卖出股票的订单。止损单有助于防止巨大的亏损并保护收益。
在典型的止损单中,价格在整个订单生命周期内不会变化。例如,如果你以每股90 设置止损单,以限制你的最大损失为 10%。
一种更高级的策略是不断调整止损单的售价,以跟踪股票价值的变化,如果股票上涨。这被称为追踪止损单。具体来说,如果一只价格为120,那么一个低于当前市场价 10%的追踪止损单会将售价调整为$108。
追踪止损单从不下调,总是与购买时的最高价值挂钩。如果股票从110,止损单仍然会保持在120 时,止损单才会上调。
这段代码通过pd.Series.cummax方法,根据任何股票的初始购买价格来确定追踪止损单价格,并展示了如何使用pd.Series.cummin来处理短仓头寸。我们还将看到如何使用pd.Series.idxmax方法来识别止损单被触发的那一天。
操作步骤
为了开始,我们将以 Nvidia(NVDA)股票为例,假设在 2020 年第一交易日进行购买:
`df = pd.read_csv( "data/NVDA.csv", usecols=["Date", "Close"], parse_dates=["Date"], index_col=["Date"], dtype_backend="numpy_nullable", ) df.head()`
`ValueError: not all elements from date_cols are numpy arrays`
在 pandas 2.2 版本中,存在一个 bug,导致前面的代码块无法运行,反而抛出ValueError错误。如果遇到这个问题,可以选择不使用dtype_backend参数运行pd.read_csv,然后改为调用pd.DataFrame.convert_dtypes:
`df = pd.read_csv( "data/NVDA.csv", usecols=["Date", "Close"], parse_dates=["Date"], index_col=["Date"], ).convert_dtypes(dtype_backend="numpy_nullable") df.head()`
`Close Date 2020-01-02 59.977501 2020-01-03 59.017502 2020-01-06 59.264999 2020-01-07 59.982498 2020-01-08 60.095001`
更多信息请参见 pandas 的 bug 问题#57930(github.com/pandas-dev/pandas/issues/57930)。
无论您采取了哪条路径,请注意,pd.read_csv返回一个pd.DataFrame,但对于本分析,我们只需要一个pd.Series。为了进行转换,您可以调用pd.DataFrame.squeeze,如果可能的话,它将把对象从二维减少到一维:
`ser = df.squeeze() ser.head()`
`Date 2020-01-02 59.977501 2020-01-03 59.017502 2020-01-06 59.264999 2020-01-07 59.982498 2020-01-08 60.095001 Name: Close, dtype: float64`
这样,我们可以使用pd.Series.cummax方法来跟踪到目前为止观察到的最高收盘价:
`ser_cummax = ser.cummax() ser_cummax.head()`
`Date 2020-01-02 59.977501 2020-01-03 59.977501 2020-01-06 59.977501 2020-01-07 59.982498 2020-01-08 60.095001 Name: Close, dtype: float64`
为了创建一个将下行风险限制在 10%的止损订单,我们可以链式操作,将其乘以0.9:
`ser.cummax().mul(0.9).head()`
`Date 2020-01-02 53.979751 2020-01-03 53.979751 2020-01-06 53.979751 2020-01-07 53.984248 2020-01-08 54.085501 Name: Close, dtype: float64`
pd.Series.cummax方法通过保留截至当前值为止遇到的最大值来工作。将该系列乘以 0.9,或者使用您希望的任何保护系数,即可创建止损订单。在这个特定的例子中,NVDA 的价值增加,因此其止损也随之上升。
另一方面,假设我们对 NVDA 股票在这段时间内持悲观看法,并且我们想要做空该股票。然而,我们仍然希望设置一个止损订单,以限制下跌幅度不超过 10%。
为此,我们只需将pd.Series.cummax的使用替换为pd.Series.cummin,并将0.9改为1.1即可:
`ser.cummin().mul(1.1).head()`
`Date 2020-01-02 65.975251 2020-01-03 64.919252 2020-01-06 64.919252 2020-01-07 64.919252 2020-01-08 64.919252 Name: Close, dtype: float64`
还有更多内容…
通过计算我们的止损订单,我们可以轻松地确定在哪些日子我们会跌破累计最大值,超过我们的设定阈值。
`stop_prices = ser.cummax().mul(0.9) ser[ser <= stop_prices]`
`Date 2020-02-24 68.320000 2020-02-25 65.512497 2020-02-26 66.912498 2020-02-27 63.150002 2020-02-28 67.517502 ... 2023-10-27 405.000000 2023-10-30 411.609985 2023-10-31 407.799988 2023-11-01 423.250000 2023-11-02 435.059998 Name: Close, Length: 495, dtype: float64`
如果我们只关心找出我们第一次跌破累计最大值的那一天,我们可以使用pd.Series.idxmax方法。该方法通过首先计算pd.Series中的最大值,然后返回出现该最大值的第一行索引:
`(ser <= stop_prices).idxmax()`
`Timestamp('2020-02-24 00:00:00')`
表达式ser <= stop_prices会返回一个布尔类型的pd.Series,其中包含True/False值,每个True记录表示股票价格在我们已经计算出的止损价格或以下。pd.Series.idxmax会将True视为该pd.Series中的最大值;因此,返回第一次遇到True的索引标签,它告诉我们应该触发止损订单的第一天。
这个示例让我们初步了解了 pandas 在证券交易中的应用价值。
寻找最擅长的棒球选手...
美国棒球运动长期以来一直是激烈分析研究的对象,数据收集可以追溯到 20 世纪初。对于美国职业棒球大联盟的球队,先进的数据分析帮助回答诸如我应该为 X 球员支付多少薪水?以及在当前局势下,我应该在比赛中做什么?这样的问题。对于球迷来说,同样的数据也可以作为无尽辩论的素材,讨论谁是历史上最伟大的球员。
对于这个示例,我们将使用从retrosheet.org收集的数据。根据 Retrosheet 的许可要求,您应注意以下法律免责声明:
此处使用的信息是免费获取的,并由 Retrosheet 版权所有。有兴趣的各方可以联系 Retrosheet,网址是www.retrosheet.org。
从原始形式中,这些数据被汇总以显示 2020 年至 2023 年间职业球员的常见棒球统计指标,包括打数(ab)、安打(h)、得分(r)和全垒打(hr)。
如何做到
让我们从读取我们的汇总数据和将id列(表示唯一球员)设置为索引开始:
`df = pd.read_parquet( "data/mlb_batting_summaries.parquet", ).set_index("id") df`
`ab r h hr id abadf001 0 0 0 0 abboa001 0 0 0 0 abboc001 3 0 1 0 abrac001 847 116 208 20 abrea001 0 0 0 0 … … … … … zimmk001 0 0 0 0 zimmr001 255 27 62 14 zubet001 1 0 0 0 zunig001 0 0 0 0 zunim001 572 82 111 41 2183 rows × 4 columns`
在棒球中,一个球员很少能在所有统计类别中占据主导地位。通常情况下,一位具有许多全垒打(hr)的球员更具威力,能够将球打得更远,但可能比一个更专注于收集大量安打(h)的球员频率较低。有了 pandas,我们幸运地不必深入研究每个指标;只需简单调用pd.DataFrame.idxmax就可以查看每一列,找到最大值,并返回与该最大值关联的行索引值:
`df.idxmax()`
`ab semim001 r freef001 h freef001 hr judga001 dtype: string`
正如您所看到的,球员semim001(Marcus Semien)在上场次数方面表现最佳,freef001(Freddie Freeman)在得分和安打方面表现最佳,而judga001(Aaron Judge)在这段时间内敲出了最多的全垒打。
如果您想深入了解这些优秀球员在所有类别中的表现,可以使用pd.DataFrame.idxmax的输出,随后对这些值调用pd.Series.unique作为整体pd.DataFrame的掩码:
`best_players = df.idxmax().unique() mask = df.index.isin(best_players) df[mask]`
`ab r h hr id freef001 1849 368 590 81 judga001 1487 301 433 138 semim001 1979 338 521 100`
这还不止于此…
要为这些数据提供良好的视觉增强效果,您可以使用pd.DataFrame.style.highlight_max来非常具体地显示这些球员在哪个类别表现最佳:
`df[mask].style.highlight_max()`
图 5.1:Jupyter Notebook 输出的 DataFrame,突出显示每列的最大值
了解哪个位置在每支球队中得分最高
在棒球中,每支球队可以有 9 名“打击阵容”球员,1 代表第一个击球手,9 代表最后一个。在比赛过程中,球队按顺序循环轮换击球手,第一位击球手在最后一位击球手击球后重新开始。
通常,球队会把一些最佳击球手放在“阵容的前面”(即较低的位置),以最大化他们的得分机会。然而,这并不总是意味着第一位击球手总是第一个得分的人。
在这个示例中,我们将查看从 2000 年到 2023 年的所有大联盟棒球队,并找出在每个赛季中为球队得分最多的位置。
如何做到
就像我们在*寻找棒球运动员最擅长…*的示例中所做的那样,我们将使用从retrosheet.org获取的数据。对于这个特定的数据集,我们将把year和team列设置为行索引,剩余的列用于显示击球顺序中的位置:
`df = pd.read_parquet( "data/runs_scored_by_team.parquet", ).set_index(["year", "team"]) df`
`1 2 3 … 7 8 9 year team 2000 ANA 124 107 100 … 77 76 54 ARI 110 106 109 … 72 68 40 ATL 113 125 124 … 77 74 39 BAL 106 106 92 … 83 78 74 BOS 99 107 99 … 75 66 62 … … … … … … … … … 2023 SLN 105 91 85 … 70 55 74 TBA 121 120 93 … 78 95 98 TEX 126 115 91 … 80 87 81 TOR 91 97 85 … 64 70 79 WAS 110 90 87 … 63 67 64 720 rows × 9 columns`
使用pd.DataFrame.idxmax,我们可以查看每年和每个队伍中哪个位置得分最高。然而,在这个数据集中,我们希望pd.DataFrame.idxmax识别的索引标签实际上是在列中,而不是行中。幸运的是,pandas 仍然可以通过axis=1参数轻松计算这个:
`df.idxmax(axis=1)`
`year team 2000 ANA 1 ARI 1 ATL 2 BAL 1 BOS 4 ... 2023 SLN 1 TBA 1 TEX 1 TOR 2 WAS 1 Length: 720, dtype: object`
从那里,我们可以使用pd.Series.value_counts来了解在队伍中,某一位置代表得分最多的次数。我们还将使用normalize=True参数,它将为我们提供频率而不是总数:
`df.idxmax(axis=1).value_counts(normalize=True)`
`1 0.480556 2 0.208333 3 0.202778 4 0.088889 5 0.018056 6 0.001389 Name: proportion, dtype: float64`
不出所料,得分最多的首位打者通常会占据得分最多的位置,约 48%的队伍如此。
还有更多……
我们可能想深入探索并回答这个问题:对于首位打者得分最多的队伍,谁得分第二多?
为了计算这个,我们可以创建一个掩码来筛选出首位打者得分最多的队伍,然后从数据集中删除该列,然后重复相同的pd.DataFrame.idxmax方法来识别下一个位置:
`mask = df.idxmax(axis=1).eq("1") df[mask].drop(columns=["1"]).idxmax(axis=1).value_counts(normalize=True)`
`2 0.497110 3 0.280347 4 0.164740 5 0.043353 6 0.014451 Name: proportion, dtype: float64`
正如你所看到的,如果一个队伍的首位打者并没有得分最多,第二位打者几乎 50%的情况下会成为得分最多的人。
加入我们的 Discord 社区
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:
第六章:可视化
可视化是探索性数据分析、演示和应用中的关键组成部分。在进行探索性数据分析时,你通常是单独工作或在小组中,需要快速创建图表来帮助你更好地理解数据。可视化可以帮助你识别异常值和缺失数据,或者激发其他有趣的问题,进而进行进一步分析和更多可视化。这种类型的可视化通常不是为了最终用户而设计的,它仅仅是为了帮助你更好地理解当前的数据。图表不必是完美的。
在为报告或应用程序准备可视化时,必须采用不同的方法。你应该关注细节。而且,通常你需要将所有可能的可视化方式缩小到少数几个最能代表数据的方式。好的数据可视化能够让观众享受提取信息的过程。就像让观众沉浸其中的电影一样,好的可视化会包含大量能够激发兴趣的信息。
默认情况下,pandas 提供了 pd.Series.plot 和 pd.DataFrame.plot 方法,帮助你快速生成图表。这些方法会调度到一个绘图后端,默认是 Matplotlib (matplotlib.org/)。
我们将在本章稍后讨论不同的后端,但现在,让我们先安装 Matplotlib 和 PyQt5,Matplotlib 用它们来绘制图表:
`python -m pip install matplotlib pyqt5`
本章中的所有代码示例假设前面已经导入以下内容:
`import matplotlib.pyplot as plt plt.ion()`
上述命令启用了 Matplotlib 的交互模式,每次执行绘图命令时,它会自动创建和更新图表。如果出于某种原因你运行了绘图命令但图表没有出现,你可能处于非交互模式(你可以通过 matplotlib.pyplot.isinteractive() 来检查),此时你需要显式调用 matplotlib.pyplot.show() 来显示图表。
本章我们将讨论以下几个案例:
-
从聚合数据中创建图表
-
绘制非聚合数据的分布
-
使用 Matplotlib 进行进一步的图表定制
-
探索散点图
-
探索分类数据
-
探索连续数据
-
使用 seaborn 绘制高级图表
从聚合数据中创建图表
pandas 库使得可视化 pd.Series 和 pd.DataFrame 对象中的数据变得容易,分别使用 pd.Series.plot 和 pd.DataFrame.plot 方法。在本案例中,我们将从相对基础的折线图、条形图、面积图和饼图开始,同时了解 pandas 提供的高级定制选项。虽然这些图表类型较为简单,但有效地使用它们对于探索数据、识别趋势以及与非技术人员分享你的研究结果都非常有帮助。
需要注意的是,这些图表类型期望你的数据已经被聚合,我们在本教程中的示例数据也反映了这一点。如果你正在处理的数据尚未聚合,你将需要使用在第七章《重塑数据框》与第八章《分组》所涉及的技术,或者使用本章后续“使用 Seaborn 进行高级绘图”教程中展示的技术。
如何操作
让我们创建一个简单的pd.Series,显示 7 天内的书籍销售数据。我们故意使用类似Day n的行索引标签,这将为我们创建的不同图表类型提供良好的视觉提示:
`ser = pd.Series( (x ** 2 for x in range(7)), name="book_sales", index=(f"Day {x + 1}" for x in range(7)), dtype=pd.Int64Dtype(), ) ser`
`Day 1 0 Day 2 1 Day 3 4 Day 4 9 Day 5 16 Day 6 25 Day 7 36 Name: book_sales, dtype: Int64`
如果调用pd.Series.plot而不传递任何参数,将会生成一张折线图,x轴上的标签来自行索引,而Y轴上的数值对应pd.Series中的数据:
`ser.plot()`
折线图将我们的数据视为完全连续的,产生的可视化效果似乎展示了每一天之间的数值,尽管我们的数据中并没有这些值。对于我们的pd.Series,更好的可视化是条形图,它将每一天离散地展示,我们只需将kind="bar"参数传递给pd.Series.plot方法即可获得:
`ser.plot(kind="bar")`
再次提醒,行索引标签出现在X轴上,数值出现在Y轴上。这有助于你从左到右阅读可视化,但在某些情况下,你可能会发现从上到下阅读数值更容易。在 pandas 中,这种可视化被认为是横向条形图,可以通过使用kind="barh"参数来渲染:
`ser.plot(kind="barh")`
kind="area"参数会生成一个区域图,它像折线图一样,但填充了线下的区域:
`ser.plot(kind="area")`
最后但同样重要的是,我们有饼图。与之前介绍的所有可视化不同,饼图没有 x 轴和 y 轴。相反,每个来自行索引的标签代表饼图中的一个不同切片,其大小由我们pd.Series中相关的数值决定:
`ser.plot(kind="pie")`
在使用pd.DataFrame时,生成图表的 API 保持一致,尽管你可能需要提供更多的关键字参数来获得期望的可视化效果。
为了看到这一点,让我们扩展数据,展示book_sales和book_returns:
`df = pd.DataFrame({ "book_sales": (x ** 2 for x in range(7)), "book_returns": [3, 2, 1, 0, 1, 2, 3], }, index=(f"Day {x + 1}" for x in range(7))) df = df.convert_dtypes(dtype_backend="numpy_nullable") df`
`book_sales book_returns Day 1 0 3 Day 2 1 2 Day 3 4 1 Day 4 9 0 Day 5 16 1 Day 6 25 2 Day 7 36 3`
就像我们在pd.Series.plot中看到的那样,默认调用pd.DataFrame.plot会给我们一张折线图,每一列由自己的线表示:
`df.plot()`
再次强调,要将其转为条形图,你只需向绘图方法传递kind="bar"参数:
`df.plot(kind="bar")`
默认情况下,pandas 会将每一列作为单独的条形图呈现。如果您想将这些列堆叠在一起,请传递stacked=True:
`df.plot(kind="bar", stacked=True)`
使用水平条形图时也可以看到相同的行为。默认情况下,列不会堆叠:
`df.plot(kind="barh")`
但是传递stacked=True会将条形图堆叠在一起:
`df.plot(kind="barh", stacked=True)`
当使用pd.DataFrame绘制面积图时,默认行为是将列堆叠在一起:
`df.plot(kind="area")`
要取消堆叠,传递stacked=False并添加alpha=参数以引入透明度。此参数的值应在 0 和 1 之间,值越接近 0,图表的透明度越高:
`df.plot(kind="area", stacked=False, alpha=0.5)`
还有更多……
本食谱中的示例使用了最少的参数来生成可视化图形。然而,绘图方法接受更多的参数,以控制标题、标签、颜色等内容。
如果您想为可视化添加标题,只需将其作为title=参数传递:
`ser.plot( kind="bar", title="Book Sales by Day", )`
color=参数可用于更改图表中线条、条形和标记的颜色。颜色可以通过 RGB 十六进制代码(例如,#00008B表示深蓝色)或使用 Matplotlib 命名颜色(如seagreen)来表示(matplotlib.org/stable/gallery/color/named_colors.html):
`ser.plot( kind="bar", title="Book Sales by Day", color="seagreen", )`
当使用pd.DataFrame时,您可以将字典传递给pd.DataFrame.plot,以控制哪些列使用哪些颜色:
`df.plot( kind="bar", title="Book Metrics", color={ "book_sales": "slateblue", "book_returns": "#7D5260", } )`
grid=参数控制是否显示网格线:
`ser.plot( kind="bar", title="Book Sales by Day", color="teal", grid=False, )`
您可以使用xlabel=和ylabel=参数来控制您的x轴和y轴的标签:
`ser.plot( kind="bar", title="Book Sales by Day", color="darkgoldenrod", grid=False, xlabel="Day Number", ylabel="Book Sales", )`
当使用pd.DataFrame时,pandas 默认将每一列的数据放在同一张图表上。然而,您可以通过subplots=True轻松生成独立的图表:
`df.plot( kind="bar", title="Book Performance", grid=False, subplots=True, )`
对于独立的图表,图例变得多余。要关闭图例,只需传递legend=False:
`df.plot( kind="bar", title="Book Performance", grid=False, subplots=True, legend=False, )`
在使用子图时,值得注意的是,默认情况下,x轴的标签是共享的,但y轴的数值范围可能不同。如果您希望y轴也共享,只需在方法调用中添加sharey=True:
`df.plot( kind="bar", title="Book Performance", grid=False, subplots=True, legend=False, sharey=True, )`
当使用pd.DataFrame.plot时,y=参数可以控制哪些列需要可视化,这在您不希望所有列都显示时非常有用:
`df.plot( kind="barh", y=["book_returns"], title="Book Returns", legend=False, grid=False, color="seagreen", )`
如你所见,pandas 提供了丰富的选项来控制显示内容和方式。尽管 pandas 会尽最大努力确定如何在可视化中布置这些元素,但它不一定总能做到完美。在本章后面,使用 Matplotlib 进一步定制图表的示例将向你展示如何更精细地控制你的可视化布局。
绘制非聚合数据的分布
可视化在识别数据中的模式和趋势时非常有帮助。你的数据是正态分布的吗?是否偏左?是否偏右?是多峰分布吗?虽然你可能能自己得出这些问题的答案,但可视化能够轻松地突出这些模式,从而深入洞察你的数据。
在这个示例中,我们将看到 pandas 如何轻松地帮助我们可视化数据的分布。直方图是绘制分布的非常流行的选择,因此我们将从它们开始,然后展示更强大的核密度估计(KDE)图。
如何实现
我们先用 10,000 个随机记录创建一个pd.Series,这些数据已知遵循正态分布。NumPy 可以方便地生成这些数据:
`np.random.seed(42) ser = pd.Series( np.random.default_rng().normal(size=10_000), dtype=pd.Float64Dtype(), ) ser`
`0 0.049174 1 -1.577584 2 -0.597247 3 -0.0198 4 0.938997 ... 9995 -0.141285 9996 1.363863 9997 -0.738816 9998 -0.373873 9999 -0.070183 Length: 10000, dtype: Float64`
可以使用直方图来绘制这些数据,方法是使用kind="hist"参数:
`ser.plot(kind="hist")`
直方图并不是尝试绘制每一个单独的点,而是将我们的值放入一个自动生成的数量的“箱子”中。每个箱子的范围绘制在可视化的 X 轴上,而每个箱子内的出现次数显示在直方图的 Y 轴上。
由于我们已经创建了可视化的数据,并且知道它是一个正态分布的数字集合,因此前面的直方图也显示了这一点。不过,我们可以通过提供bins=参数给pd.Series.plot来选择不同的箱数,这会显著影响可视化效果及其解释方式。
举例来说,如果我们传递 bins=2,我们将得到极少的箱子,以至于我们的正态分布不再明显:
`ser.plot(kind="hist", bins=2)`
另一方面,传递 bins=100 可以清楚地看到,我们通常有一个正态分布:
`ser.plot(kind="hist", bins=100)`
当使用 pd.DataFrame 绘制直方图时,同样的问题也会出现。为说明这一点,让我们创建一个包含两列的 pd.DataFrame,其中一列是正态分布的,另一列则使用三角分布:
`np.random.seed(42) df = pd.DataFrame({ "normal": np.random.default_rng().normal(size=10_000), "triangular": np.random.default_rng().triangular(-2, 0, 2, size=10_000), }) df = df.convert_dtypes(dtype_backend="numpy_nullable") df.head()`
`normal triangular 0 -0.265525 -0.577042 1 0.327898 -0.391538 2 -1.356997 -0.110605 3 0.004558 0.71449 4 1.03956 0.676207`
对 pd.DataFrame.plot 的基本绘图调用将生成如下图表:
`df.plot(kind="hist")`
不幸的是,一个分布的箱子与另一个分布的箱子重叠了。你可以通过引入一些透明度来解决这个问题:
`df.plot(kind="hist", alpha=0.5)`
或生成子图:
`df.plot(kind="hist", subplots=True)`
初看这些分布似乎差不多,但使用更多的分箱后就会发现它们并不相同:
`df.plot(kind="hist", alpha=0.5, bins=100)`
虽然直方图很常用,但分箱选择对数据解释的影响确实是个不幸之处;你不希望因为选择了“错误”的分箱数量而改变对数据的解释!
幸运的是,你可以使用一个类似但更强大的可视化方式,它不需要你选择任何类型的分箱策略,这就是 核密度估计(或 KDE)图。要使用此图,你需要安装 SciPy:
`python -m pip install scipy`
安装 SciPy 后,你可以简单地将 kind="kde" 传递给 pd.Series.plot:
`ser.plot(kind="kde")`
对于我们的 pd.DataFrame,KDE 图清晰地表明我们有两个不同的分布:
`df.plot(kind="kde")`
使用 Matplotlib 进一步自定义图表
对于非常简单的图表,默认布局可能足够用,但你不可避免地会遇到需要进一步调整生成的可视化的情况。为了超越 pandas 的开箱即用功能,了解一些 Matplotlib 的术语是很有帮助的。在 Matplotlib 中,figure 是指绘图区域,而 axes 或 subplot 是指你可以绘制的区域。请小心不要将 axes(用于绘制数据的区域)与 axis(指的是 X 或 Y 轴)混淆。
如何操作
让我们从我们的图书销售数据的 pd.Series 开始,尝试在同一张图表上三种不同方式绘制它——一次作为线图,一次作为柱状图,最后一次作为饼图。为了设置绘图区域,我们将调用 plt.subplots(nrows=1, ncols=3),基本上告诉 matplotlib 我们希望绘制区域有多少行和列。这将返回一个包含图形本身和一个 Axes 对象序列的二元组,我们可以在这些对象上进行绘制。我们将其解包为 fig 和 axes 两个变量。
因为我们要求的是一行三列,返回的 axes 序列长度将为三。我们可以将 pandas 绘图时使用的单独 Axes 对象传递给 pd.DataFrame.plot 的 ax= 参数。我们第一次尝试绘制这些图表的效果应该如下所示,结果是,嗯,简直丑陋:
`ser = pd.Series( (x ** 2 for x in range(7)), name="book_sales", index=(f"Day {x + 1}" for x in range(7)), dtype=pd.Int64Dtype(), ) fig, axes = plt.subplots(nrows=1, ncols=3) ser.plot(ax=axes[0]) ser.plot(kind="bar", ax=axes[1]) ser.plot(kind="pie", ax=axes[2])`
因为我们没有告诉它其他设置,Matplotlib 给了我们三个大小相等的 axes 对象来绘制。然而,这使得上面的线形图和柱状图非常高而窄,最终我们在饼图上下产生了大量的空白区域。
为了更精细地控制这一点,我们可以使用 Matplotlib 的 GridSpec 来创建一个 2x2 的网格。这样,我们可以将柱状图和线形图并排放置在第一行,然后让饼图占据整个第二行:
`from matplotlib.gridspec import GridSpec fig = plt.figure() gs = GridSpec(2, 2, figure=fig) ax0 = fig.add_subplot(gs[0, 0]) ax1 = fig.add_subplot(gs[0, 1]) ax2 = fig.add_subplot(gs[1, :]) ser.plot(ax=ax0) ser.plot(kind="bar", ax=ax1) ser.plot(kind="pie", ax=ax2)`
这样看起来好一些,但现在我们依然有一个问题:饼图的标签与条形图的X轴标签重叠。幸运的是,我们仍然可以单独修改每个坐标轴对象来旋转标签、移除标签、修改标题等。
`from matplotlib.gridspec import GridSpec fig = plt.figure() fig.suptitle("Book Sales Visualized in Different Ways") gs = GridSpec(2, 2, figure=fig, hspace=.5) ax0 = fig.add_subplot(gs[0, 0]) ax1 = fig.add_subplot(gs[0, 1]) ax2 = fig.add_subplot(gs[1, :]) ax0 = ser.plot(ax=ax0) ax0.set_title("Line chart") ax1 = ser.plot(kind="bar", ax=ax1) ax1.set_title("Bar chart") ax1.set_xticklabels(ax1.get_xticklabels(), rotation=45) # Remove labels from chart and show in custom legend instead ax2 = ser.plot(kind="pie", ax=ax2, labels=None) ax2.legend( ser.index, bbox_to_anchor=(1, -0.2, 0.5, 1), # put legend to right of chart prop={"size": 6}, # set font size for legend ) ax2.set_title("Pie Chart") ax2.set_ylabel(None) # remove book_sales label`
使用 Matplotlib 绘制图表的定制化程度是没有限制的,遗憾的是,在本书中我们无法触及这一话题的表面。如果你对可视化非常感兴趣,我强烈建议你阅读 Matplotlib 的文档或找到一本专门的书籍来深入了解。然而,许多仅仅想查看自己数据的用户可能会觉得过多的定制化处理是一个负担。对于这些用户(包括我自己),幸运的是,还有像 seaborn 这样的高级绘图库,可以用最小的额外努力制作出更美观的图表。本章后面关于使用 seaborn 绘制高级图表的章节将让你了解这个库有多么有用。
探索散点图
散点图是你可以创建的最强大的可视化类型之一。在一个非常紧凑的区域内,散点图可以帮助你可视化两个变量之间的关系,衡量单个数据点的规模,甚至看到这些关系和规模如何在不同类别中变化。能够有效地在散点图中可视化数据,代表着分析能力的一大飞跃,相较于我们到目前为止看到的一些更常见的可视化方式。
在本章节中,我们将探索如何仅在一个散点图上同时衡量所有这些内容。
如何实现
散点图从定义上讲,衡量至少两个变量之间的关系。因此,散点图只能通过pd.DataFrame创建。pd.Series简单来说没有足够的变量。
话虽如此,让我们创建一个示例pd.DataFrame,其中包含四列不同的数据。三列是连续变量,第四列是颜色,我们最终将用它来对不同的数据点进行分类:
`df = pd.DataFrame({ "var_a": [1, 2, 3, 4, 5], "var_b": [1, 2, 4, 8, 16], "var_c": [500, 200, 600, 100, 400], "var_d": ["blue", "orange", "gray", "blue", "gray"], }) df = df.convert_dtypes(dtype_backend="numpy_nullable") df`
`var_a var_b var_c var_d 0 1 1 500 blue 1 2 2 200 orange 2 3 4 600 gray 3 4 8 100 blue 4 5 16 400 gray`
除了kind="scatter"之外,我们还需要明确控制绘制在X轴上的内容,绘制在Y轴上的内容,每个数据点的大小,以及每个数据点应该呈现的颜色。这些都可以通过x=, y=, s=, 和 c= 参数来控制:
`df.plot( kind="scatter", x="var_a", y="var_b", s="var_c", c="var_d", )`
像这样的简单散点图并不特别有趣,但现在我们已经掌握了基础知识,让我们试用一个更具现实感的数据集。美国能源部发布了年度报告(www.fueleconomy.gov/feg/download.shtml),总结了对在美国销售的车辆进行的详细燃油经济性测试的结果。这本书包括了一个涵盖 1985 年至 2025 年模型年份的本地副本。
目前,我们只读取一些对我们有兴趣的列,即 city08(城市油耗,英里/加仑)、highway08(高速公路油耗,英里/加仑)、VClass(紧凑型车、SUV 等)、fuelCost08(年度燃油成本)和每辆车的模型 year(有关此数据集包含的所有术语的完整定义,请参阅 www.fueleconomy.gov):
`df = pd.read_csv( "data/vehicles.csv.zip", dtype_backend="numpy_nullable", usecols=["city08", "highway08", "VClass", "fuelCost08", "year"], ) df.head()`
`city08 fuelCost08 highway08 VClass year 0 19 2,450 25 Two Seaters 1985 1 9 4,700 14 Two Seaters 1985 2 23 1,900 33 Subcompact Cars 1985 3 10 4,700 12 Vans 1985 4 17 3,400 23 Compact Cars 1993`
该数据集包括许多不同的车辆类别,因此为了让我们的分析更集中,暂时我们只关注 2015 年及之后的不同车型。卡车、SUV 和厢式车可以留到另一个分析中:
`car_classes = ( "Subcompact Cars", "Compact Cars", "Midsize Cars", "Large Cars", "Two Seaters", ) mask = (df["year"] >= 2015) & df["VClass"].isin(car_classes) df = df[mask] df.head()`
`city08 fuelCost08 highway08 VClass year 27058 16 3,400 23 Subcompact Cars 2015 27059 20 2,250 28 Compact Cars 2015 27060 26 1,700 37 Midsize Cars 2015 27061 28 1,600 39 Midsize Cars 2015 27062 25 1,800 35 Midsize Cars 2015`
散点图可以帮助我们回答这样一个问题:城市油耗和高速公路油耗之间的关系是什么? 通过将这些列分别绘制在 X 轴和 Y 轴上:
`df.plot( kind="scatter", x="city08", y="highway08", )`
也许不令人惊讶的是,存在一个强烈的线性趋势。车辆在城市道路上获得的油耗越好,它在高速公路上的油耗也越好。
当然,我们仍然看到值的分布相当大;许多车辆集中在 10–35 MPG 范围内,但有些超过 100。为了进一步深入,我们可以为每个车辆类别分配颜色,并将其添加到可视化中。
这有很多方法可以实现,但通常最好的方法之一是确保你想要用作颜色值的变量是一个分类数据类型:
`classes_ser = pd.Series(car_classes, dtype=pd.StringDtype()) cat = pd.CategoricalDtype(classes_ser) df["VClass"] = df["VClass"].astype(cat) df.head()`
`city08 fuelCost08 highway08 VClass year 27058 16 3,400 23 Subcompact Cars 2015 27059 20 2,250 28 Compact Cars 2015 27060 26 1,700 37 Midsize Cars 2015 27061 28 1,600 39 Midsize Cars 2015 27062 25 1,800 35 Midsize Cars 2015`
解决了这些问题后,你可以将分类列传递给 pd.DataFrame.plot 的 c= 参数:
`df.plot( kind="scatter", x="city08", y="highway08", c="VClass", )`
添加一个 colormap= 参数可能有助于在视觉上区分数据点。有关该参数可接受的值列表,请参阅 Matplotlib 文档(matplotlib.org/stable/users/explain/colors/colormaps.html):
`df.plot( kind="scatter", x="city08", y="highway08", c="VClass", colormap="Dark2", )`
从这些图表中,我们可以推测一些事情。虽然“二座车”不多,但当它们出现时,往往在城市和高速公路的油耗表现都较差。“中型车”似乎主导了 40–60 MPG 范围,但当你查看那些在城市或高速公路上都能达到 100 MPG 或更好的车辆时,“大型车”和“中型车”似乎都相对较好。
到目前为止,我们已经使用了X轴、Y轴和散点图的颜色来深入分析数据,但我们可以更进一步,按燃油成本对每个数据点进行缩放,传递fuelCost08作为 s=参数:
`df.plot( kind="scatter", x="city08", y="highway08", c="VClass", colormap="Dark2", s="fuelCost08", )`
这里每个气泡的大小可能太大,不太实用。我们的燃油经济性列中的值范围是几千,这造成了一个过于庞大的图表区域,难以有效使用。只需对这些值进行缩放,就能快速得到一个更合理的可视化;在这里,我选择将其除以 25,并使用alpha=参数引入一些透明度,得到一个更令人满意的图表:
`df.assign( scaled_fuel_cost=lambda x: x["fuelCost08"] / 25, ).plot( kind="scatter", x="city08", y="highway08", c="VClass", colormap="Dark2", s="scaled_fuel_cost", alpha=0.4, )`
更大圆圈出现接近原点的趋势表明,通常情况下,油耗较差的车辆有更高的年燃油成本。你可能会在这个散点图中找到一些点,其中相对较高的油耗仍然比其他具有相似范围的车辆有更高的燃油成本,这可能是因为不同的燃料类型要求。
还有更多内容…
散点图的一个很好的补充是散点矩阵,它生成你pd.DataFrame中所有连续列数据之间的成对关系。让我们看看使用我们的车辆数据会是什么样子:
`from pandas.plotting import scatter_matrix scatter_matrix(df)`
这张图表包含了很多信息,所以让我们先消化第一列的可视化。如果你看图表的底部,标签为city08,这意味着city08是该列每个图表中的Y轴。
第一列第一行的可视化展示了city08在Y轴与city08在X轴上的组合。这不是一个将同一列与自己进行散点图绘制的散点图,而是散点矩阵,展示了在这个可视化中city08值的分布。正如你所看到的,大多数车辆的城市油耗低于 50 MPG。
如果你看一下第二行第一列中下方的可视化,你会看到燃油成本与城市油耗之间的关系。这表明,随着选择城市油耗更高的汽车,你每年在燃油上的支出会呈指数性减少。
第一列第三行的可视化展示了highway08在Y轴上的数据,这与我们在整个教程中展示的视觉效果相同。再次强调,城市与高速公路的里程之间存在线性关系。
第一列最后一行的可视化展示了年份在Y轴上的数据。从中可以看出,2023 年和 2024 年型号的车辆更多,且实现了 75 MPG 及以上的城市油耗。
探索分类数据
形容词类别应用于那些广义上用于分类和帮助导航数据的数据,但这些值在聚合时几乎没有任何实际意义。例如,如果你正在处理一个包含眼睛颜色字段的数据集,值为棕色、绿色、榛色、蓝色等,你可以使用这个字段来导航数据集,回答类似*眼睛颜色为 X 的行,平均瞳孔直径是多少?的问题。然而,你不会问诸如眼睛颜色的总和是多少?*的问题,因为像"榛色" + "蓝色"这样的公式在这种情况下没有意义。
相比之下,形容词连续通常应用于你需要聚合的数据。比如问题是什么是平均瞳孔直径?,那么瞳孔直径这一列就会被认为是连续的。了解它聚合后的结果(例如最小值、最大值、平均值、标准差等)是有意义的,而且它可以表示理论上无穷多的值。
有时,判断数据是类别数据还是连续数据可能会有些模糊。以一个人的年龄为例,如果你测量的是被试者的平均年龄,那么这一列就是连续的,但如果问题是20 到 30 岁之间的用户有多少人?,那么相同的数据就变成了类别数据。最终,是否将年龄这样的数据视为连续数据或类别数据,将取决于你在分析中的使用方式。
在本实例中,我们将生成有助于快速识别类别数据分布的可视化图表。我们的下一个实例,探索连续数据,将为你提供一些处理连续数据的思路。
如何操作
回到散点图的实例,我们介绍了由美国能源部分发的vehicles数据集。这个数据集包含了多种类别和连续数据,因此我们再次从将其加载到pd.DataFrame开始:
`df = pd.read_csv( "data/vehicles.csv.zip", dtype_backend="numpy_nullable", ) df.head()`
`/tmp/ipykernel_834707/1427318601.py:1: DtypeWarning: Columns (72,74,75,77) have mixed types. Specify dtype option on import or set low_memory=False. df = pd.read_csv( barrels08 bar-relsA08 charge120 … phevCity phevHwy phevComb 0 14.167143 0.0 0.0 … 0 0 0 1 27.046364 0.0 0.0 … 0 0 0 2 11.018889 0.0 0.0 … 0 0 0 3 27.046364 0.0 0.0 … 0 0 0 4 15.658421 0.0 0.0 … 0 0 0 5 rows × 84 columns`
你可能注意到我们收到了一个警告,Columns (72,74,75,77) have mixed types。在开始进行可视化之前,我们快速看一下这些列:
`df.iloc[:, [72, 74, 75, 77]]`
`rangeA mfrCode c240Dscr c240bDscr 0 <NA> <NA> <NA> <NA> 1 <NA> <NA> <NA> <NA> 2 <NA> <NA> <NA> <NA> 3 <NA> <NA> <NA> <NA> 4 <NA> <NA> <NA> <NA> … … … … … 47,518 <NA> <NA> <NA> <NA> 47,519 <NA> <NA> <NA> <NA> 47,520 <NA> <NA> <NA> <NA> 47,521 <NA> <NA> <NA> <NA> 47,522 <NA> <NA> <NA> <NA> 47,523 rows × 4 columns`
虽然我们可以看到列名,但我们的pd.DataFrame预览没有显示任何实际值,因此为了进一步检查,我们可以对每一列使用pd.Series.value_counts。
这是我们在rangeA列中看到的内容:
`df["rangeA"].value_counts()`
`rangeA 290 74 270 58 280 56 310 41 277 38 .. 240/290/290 1 395 1 258 1 256 1 230/350 1 Name: count, Length: 264, dtype: int64`
这里的值……很有意思。在我们还不清楚具体数据含义的情况下,rangeA这一列名和大部分值暗示将其视为连续数据是有价值的。通过这样做,我们可以回答类似*车辆的平均 rangeA 是多少?*的问题,但我们看到的240/290/290和230/350等值会阻止我们这样做。目前,我们将把这些数据当作字符串来处理。
回到pd.read_csv发出的警告,pandas 在读取 CSV 文件时会尝试推断数据类型。如果文件开头的数据显示一种类型,但在文件后面看到另一种类型,pandas 会故意发出这个警告,以提醒你数据中可能存在的问题。对于这一列,我们可以结合使用pd.Series.str.isnumeric和pd.Series.idxmax,快速确定 CSV 文件中首次出现非整数值的行:
`df["rangeA"].str.isnumeric().idxmax()`
`7116`
如果你检查pd.read_csv警告的其他列,你不会看到整型数据和字符串数据混合的情况,但你会发现文件开头的大部分数据都缺失,这使得 pandas 很难推断出数据类型:
`df.iloc[:, [74, 75, 77]].pipe(pd.isna).idxmin()`
`mfrCode 23147 c240Dscr 25661 c240bDscr 25661 dtype: int64`
当然,最好的解决方案本应是避免使用 CSV 文件,转而使用一种可以保持类型元数据的数据存储格式,比如 Apache Parquet。然而,我们无法控制这些数据是如何生成的,因此目前我们能做的最好的办法是明确告诉pd.read_csv将所有这些列当作字符串处理,并抑制任何警告:
`df = pd.read_csv( "data/vehicles.csv.zip", dtype_backend="numpy_nullable", dtype={ "rangeA": pd.StringDtype(), "mfrCode": pd.StringDtype(), "c240Dscr": pd.StringDtype(), "c240bDscr": pd.StringDtype() } ) df.head()`
`barrels08 bar-relsA08 charge120 … phevCity phevHwy phevComb 0 14.167143 0.0 0.0 … 0 0 0 1 27.046364 0.0 0.0 … 0 0 0 2 11.018889 0.0 0.0 … 0 0 0 3 27.046364 0.0 0.0 … 0 0 0 4 15.658421 0.0 0.0 … 0 0 0 5 rows × 84 columns`
现在我们已经清理了数据,让我们尝试识别哪些列是类别性质的。由于我们对这个数据集一无所知,我们可以做出一个方向上正确的假设,即所有通过pd.read_csv读取为字符串的列都是类别型的:
`df.select_dtypes(include=["string"]).columns`
`Index(['drive', 'eng_dscr', 'fuelType', 'fuelType1', 'make', 'model', 'mpgData', 'trany', 'VClass', 'baseModel', 'guzzler', 'trans_dscr', 'tCharger', 'sCharger', 'atvType', 'fuelType2', 'rangeA', 'evMotor', 'mfrCode', 'c240Dscr', 'c240bDscr', 'createdOn', 'modifiedOn', 'startStop'], dtype='object')`
我们可以遍历所有这些列,并调用pd.Series.value_counts来理解每列包含什么,但更有效的探索数据方式是先通过pd.Series.nunique来了解每列中有多少个唯一值,并按从低到高排序。较低的数值表示低基数(即与pd.DataFrame的值计数相比,唯一值的数量较少)。而具有较高数值的字段则反向被认为是高基数:
`df.select_dtypes(include=["string"]).nunique().sort_values()`
`sCharger 1 tCharger 1 startStop 2 mpgData 2 guzzler 3 fuelType2 4 c240Dscr 5 c240bDscr 7 drive 7 fuelType1 7 atvType 9 fuelType 15 VClass 34 trany 40 trans_dscr 52 mfrCode 56 make 144 rangeA 245 modifiedOn 298 evMotor 400 createdOn 455 eng_dscr 608 baseModel 1451 model 5064 dtype: int64`
为了便于可视化,我们将选择具有最低基数的九列。这并不是一个绝对的规则来决定哪些内容应该可视化,最终这个决定取决于你自己。对于我们这个特定的数据集,基数最低的九列最多有七个唯一值,这些值可以合理地绘制在条形图的X轴上,以帮助可视化值的分布。
基于我们在本章的Matplotlib 进一步绘图自定义一节中学到的内容,我们可以使用plt.subplots创建一个简单的 3x3 网格,并在该网格中将每个可视化图表绘制到相应的位置:
`low_card = df.select_dtypes(include=["string"]).nunique().sort_values().iloc[:9].index fig, axes = plt.subplots(nrows=3, ncols=3) for index, column in enumerate(low_card): row = index % 3 col = index // 3 ax = axes[row][col] df[column].value_counts().plot(kind="bar", ax=ax) plt.tight_layout()`
`/tmp/ipykernel_834707/4000549653.py:10: UserWarning: Tight layout not applied. tight_layout cannot make axes height small enough to accommodate all axes decorations. plt.tight_layout()`
那张图表… 很难阅读。许多X轴标签超出了图表区域,由于它们的长度。修复这个问题的一种方法是使用pd.Index.str[]与pd.Index.set_axis将更短的标签分配给我们的行索引值,以使用这些值创建一个新的pd.Index。我们还可以使用 Matplotlib 来旋转和调整X轴标签的大小:
`low_card = df.select_dtypes(include=["string"]).nunique().sort_values().iloc[:9].index fig, axes = plt.subplots(nrows=3, ncols=3) for index, column in enumerate(low_card): row = index % 3 col = index // 3 ax = axes[row][col] counts = df[column].value_counts() counts.set_axis(counts.index.str[:8]).plot(kind="bar", ax=ax) ax.set_xticklabels(ax.get_xticklabels(), rotation=45, fontsize=6) plt.tight_layout()`
通过这个可视化,我们可以更容易地从高层次理解我们的数据集。mpgData列中的N出现频率明显高于Y。对于guzzler列,我们看到G值大约是T值的两倍。对于c240Dscr列,我们可以看到绝大多数条目都是standard,尽管总体上,我们的整个数据集中只有略多于 100 行分配了这个值,因此我们可能会决定没有足够的测量数据可靠地使用它。
探索连续数据
在探索分类数据的示例中,我们提供了分类和连续数据的定义,同时仅探索了前者。我们在那个示例中使用的同一个vehicles数据集既包含这两种类型的数据(大多数数据集都是如此),所以我们将重复使用同一个数据集,但是将焦点转移到本示例中的连续数据。
在阅读本示例之前,建议您先熟悉非聚合数据分布绘图示例中展示的技术。实际的绘图调用将是相同的,但本示例将它们应用于更“真实”的数据集,而不是人为创建的数据。
如何做
让我们首先加载vehicles数据集:
`df = pd.read_csv( "data/vehicles.csv.zip", dtype_backend="numpy_nullable", dtype={ "rangeA": pd.StringDtype(), "mfrCode": pd.StringDtype(), "c240Dscr": pd.StringDtype(), "c240bDscr": pd.StringDtype() } ) df.head()`
`barrels08 bar-relsA08 charge120 … phevCity phevHwy phevComb 0 14.167143 0.0 0.0 … 0 0 0 1 27.046364 0.0 0.0 … 0 0 0 2 11.018889 0.0 0.0 … 0 0 0 3 27.046364 0.0 0.0 … 0 0 0 4 15.658421 0.0 0.0 … 0 0 0 5 rows × 84 columns`
在前面的示例中,我们使用了带有include=参数的pd.DataFrame.select_dtypes来保留只包含字符串列的内容,这些列被用作分类数据的代理。通过将相同的参数传递给exclude=,我们可以得到对连续列的合理概述:
`df.select_dtypes(exclude=["string"]).columns`
`Index(['barrels08', 'barrelsA08', 'charge120', 'charge240', 'city08', 'city08U', 'cityA08', 'cityA08U', 'cityCD', 'cityE', 'cityUF', 'co2', 'co2A', 'co2TailpipeAGpm', 'co2TailpipeGpm', 'comb08', 'comb08U', 'combA08', 'combA08U', 'combE', 'combinedCD', 'combinedUF', 'cylinders', 'displ', 'engId', 'feScore', 'fuelCost08', 'fuelCostA08', 'ghgScore', 'ghgScoreA', 'highway08', 'highway08U', 'highwayA08', 'highwayA08U', 'highwayCD', 'highwayE', 'highwayUF', 'hlv', 'hpv', 'id', 'lv2', 'lv4', 'phevBlended', 'pv2', 'pv4', 'range', 'rangeCity', 'rangeCityA', 'rangeHwy', 'rangeHwyA', 'UCity', 'UCityA', 'UHighway', 'UHighwayA', 'year', 'youSaveSpend', 'charge240b', 'phevCity', 'phevHwy', 'phevComb'], dtype='object')`
对于连续数据,使用pd.Series.nunique并不那么合理,因为值可以取理论上无限多的值。相反,为了确定良好的绘图候选列,我们可能只想了解哪些列具有足够数量的非缺失数据,可以使用pd.isna:
`df.select_dtypes( exclude=["string"] ).pipe(pd.isna).sum().sort_values(ascending=False).head()`
`cylinders 801 displ 799 barrels08 0 pv4 0 highwayA08U 0 dtype: int64`
一般来说,我们的大多数连续数据是完整的,但是让我们看看cylinders,看看缺失值是什么:
`df.loc[df["cylinders"].isna(), ["make", "model"]].value_counts()`
`make model Fiat 500e 8 smart fortwo electric drive coupe 7 Toyota RAV4 EV 7 Nissan Leaf 7 Ford Focus Electric 7 .. Polestar 2 Single Motor (19 Inch Wheels) 1 Ford Mustang Mach-E RWD LFP 1 Polestar 2 Dual Motor Performance Pack 1 2 Dual Motor Perf Pack 1 Acura ZDX AWD 1 Name: count, Length: 450, dtype: int64`
这些似乎是电动车辆,因此我们可以合理地选择用0来填充这些缺失值:
`df["cylinders"] = df["cylinders"].fillna(0)`
我们在displ列中看到了相同的模式:
`df.loc[df["displ"].isna(), ["make", "model"]].value_counts()`
`make model Fiat 500e 8 smart fortwo electric drive coupe 7 Toyota RAV4 EV 7 Nissan Leaf 7 Ford Focus Electric 7 .. Porsche Taycan 4S Performance Battery Plus 1 Taycan GTS ST 1 Fisker Ocean Extreme One 1 Fiat 500e All Season 1 Acura ZDX AWD 1 Name: count, Length: 449, dtype: int64`
是否应该用0来填充这些数据还有待讨论。在cylinder的情况下,用0填充缺失值是有道理的,因为我们的数据实际上是分类的(即cylinder值只能有那么多种,而且不能简单地聚合这些值)。如果你有一辆车有 2 个气缸,另一辆车有 3 个,那么说“平均气缸数为 2.5”是没有意义的。
然而,对于像 displacement 这样的列,测量“平均排量”可能更有意义。在这种情况下,向平均数提供许多 0 值将使其向下偏斜,而缺失值将被忽略。与 cylinders 相比,还有许多更多的唯一值:
`df["displ"].nunique()`
`66`
最终,在这个字段中填补缺失值是一个判断调用;对于我们的分析,我们将把它们保留为空白。
现在我们已经验证了数据集中的缺失值,并对我们的完整性感到满意,是时候开始更详细地探索单个字段了。在探索连续数据时,直方图通常是用户首先选择的可视化方式。让我们看看我们的 city08 列是什么样子:
`df["city08"].plot(kind="hist")`
图形看起来非常倾斜,因此我们将增加直方图中的箱数,以查看倾斜是否隐藏了行为(因为倾斜使箱子变宽):
`df["city08"].plot(kind="hist", bins=30)`
正如我们在绘制非聚合数据的分布配方中讨论的那样,如果安装了 SciPy,您可以放弃寻找最佳箱数。使用 SciPy,KDE 图将为您提供更好的分布视图。
知道了这一点,并且从Matplotlib 进一步的图形定制配方中得到启发,我们可以使用 plt.subplots 来一次可视化多个变量的 KDE 图,比如城市和高速公路里程:
`fig, axes = plt.subplots(nrows=2, ncols=1) axes[0].set_xlim(0, 40) axes[1].set_xlim(0, 40) axes[0].set_ylabel("city") axes[1].set_ylabel("highway") df["city08"].plot(kind="kde", ax=axes[0]) df["highway08"].plot(kind="kde", ax=axes[1])`
如您所见,城市里程倾向于略微向左倾斜,分布的峰值大约在 16 或 17 英里每加仑。高速公路里程的峰值更接近于 23 或 24,与理想的正态分布相比,在 17 或 18 英里每加仑处出现更多值。
使用 seaborn 进行高级绘图
seaborn 库是一个流行的 Python 库,用于创建可视化。它本身不进行任何绘制,而是将繁重的工作推迟到 Matplotlib。然而,对于使用 pd.DataFrame 的用户,seaborn 提供了美观的可视化效果和一个抽象了许多工作的 API,这些工作在直接使用 Matplotlib 时必须手动完成。
而不是使用 pd.Series.plot 和 pd.DataFrame.plot,我们将使用 seaborn 自己的 API。本节中的所有示例假设以下代码导入 seaborn 并使用其默认主题:
`import seaborn as sns sns.set_theme() sns.set_style("white")`
如何做到这一点
让我们创建一个小的 pd.DataFrame,展示两个 GitHub 项目随时间收到的星数:
`df = pd.DataFrame([ ["Q1-2024", "project_a", 1], ["Q1-2024", "project_b", 1], ["Q2-2024", "project_a", 2], ["Q2-2024", "project_b", 2], ["Q3-2024", "project_a", 4], ["Q3-2024", "project_b", 3], ["Q4-2024", "project_a", 8], ["Q4-2024", "project_b", 4], ["Q5-2025", "project_a", 16], ["Q5-2025", "project_b", 5], ], columns=["quarter", "project", "github_stars"]) df = df.convert_dtypes(dtype_backend="numpy_nullable") df`
`quarter project github_stars 0 Q1-2024 project_a 1 1 Q1-2024 project_b 1 2 Q2-2024 project_a 2 3 Q2-2024 project_b 2 4 Q3-2024 project_a 4 5 Q3-2024 project_b 3 6 Q4-2024 project_a 8 7 Q4-2024 project_b 4 8 Q5-2025 project_a 16 9 Q5-2025 project_b 5`
这些简单的数据适合用作柱状图,我们可以使用 sns.barplot 来生成。注意,使用 seaborn 的 API 时,调用签名的不同——在 seaborn 中,你需要将 pd.DataFrame 作为参数传递,并明确选择 x、y 和 hue 参数。你还会注意到 seaborn 主题使用了与 Matplotlib 不同的配色方案,你可能会觉得这种配色更具视觉吸引力:
`sns.barplot(df, x="quarter", y="github_stars", hue="project")`
sns.lineplot 可以用来生成与此相同的折线图:
`sns.lineplot(df, x="quarter", y="github_stars", hue="project")`
使用 seaborn 时需要注意的一点是,你需要以长格式而不是宽格式提供数据。为了说明这一点,请仔细观察我们刚刚绘制的原始 pd.DataFrame:
`df`
`quarter project github_stars 0 Q1-2024 project_a 1 1 Q1-2024 project_b 1 2 Q2-2024 project_a 2 3 Q2-2024 project_b 2 4 Q3-2024 project_a 4 5 Q3-2024 project_b 3 6 Q4-2024 project_a 8 7 Q4-2024 project_b 4 8 Q5-2025 project_a 16 9 Q5-2025 project_b 5`
如果我们想用 pandas 绘制等效的折线图和柱状图,我们必须在调用pd.DataFrame.plot之前以不同的方式构建数据:
`df = pd.DataFrame({ "project_a": [1, 2, 4, 8, 16], "project_b": [1, 2, 3, 4, 5], }, index=["Q1-2024", "Q2-2024", "Q3-2024", "Q4-2024", "Q5-2024"]) df = df.convert_dtypes(dtype_backend="numpy_nullable") df`
`project_a project_b Q1-2024 1 1 Q2-2024 2 2 Q3-2024 4 3 Q4-2024 8 4 Q5-2024 16 5`
虽然 seaborn 提供的默认样式有助于制作漂亮的图表,但该库还可以帮助你构建更强大的可视化,而这些在直接使用 pandas 时是没有等效功能的。
为了看到这些类型的图表如何运作,让我们再次使用我们在第五章《算法及其应用》中探讨过的 movie 数据集:
`df = pd.read_csv( "data/movie.csv", usecols=["movie_title", "title_year", "imdb_score", "content_rating"], dtype_backend="numpy_nullable", ) df.head()`
`movie_title content_rating title_year imdb_score 0 Avatar PG-13 2009.0 7.9 1 Pirates of the Caribbean: At World's End PG-13 2007.0 7.1 2 Spectre PG-13 2015.0 6.8 3 The Dark Knight Rises PG-13 2012.0 8.5 4 Star Wars: Episode VII – The Force Awakens <NA> <NA> 7.1`
在我们深入分析这个数据集之前,我们需要进行一些数据清理。首先,看起来 title_year 被读取为浮动点数值。使用整数值会更为合适,因此我们将重新读取数据,并明确传递 dtype= 参数:
`df = pd.read_csv( "data/movie.csv", usecols=["movie_title", "title_year", "imdb_score", "content_rating"], dtype_backend="numpy_nullable", dtype={"title_year": pd.Int16Dtype()}, ) df.head()`
`movie_title content_rating title_year imdb_score 0 Avatar PG-13 2009 7.9 1 Pirates of the Caribbean: At World's End PG-13 2007 7.1 2 Spectre PG-13 2015 6.8 3 The Dark Knight Rises PG-13 2012 8.5 4 Star Wars: Episode VII - The Force Awakens <NA> <NA> 7.1`
既然这些问题已经解决了,让我们看看我们数据集中最老的电影是什么时候上映的:
`df["title_year"].min()`
`1916`
然后将其与最后一部电影进行比较:
`df["title_year"].max()`
`2016`
当我们开始考虑如何可视化这些数据时,我们可能并不总是关心电影确切的上映年份。相反,我们可以通过使用我们在第五章《算法及其应用》中讲解过的 pd.cut 函数,将每部电影归入一个 decade(十年)类别,并提供一个区间,该区间会涵盖数据集中第一部和最后一部电影上映的年份:
`df = df.assign( title_decade=lambda x: pd.cut(x["title_year"], bins=range(1910, 2021, 10))) df.head()`
`movie_title content_rating title_year imdb_score title_decade 0 Avatar PG-13 2009 7.9 (2000.0, 2010.0] 1 Pirates of the Caribbean: At World's End PG-13 2007 7.1 (2000.0, 2010.0] 2 Spectre PG-13 2015 6.8 (2010.0, 2020.0] 3 The Dark Knight Rises PG-13 2012 8.5 (2010.0, 2020.0] 4 Star Wars: Epi-sode VII - The Force Awakens <NA> <NA> 7.1 NaN`
如果我们想了解电影评分在各个十年间的分布变化,箱线图将是可视化这些趋势的一个很好的起点。Seaborn 提供了一个 sns.boxplot 方法,可以轻松绘制此图:
`sns.boxplot( data=df, x="imdb_score", y="title_decade", )`
如果你观察箱线图中的中位数电影评分(即每个箱体中间的黑色竖线),你会发现电影评分整体上有下降的趋势。与此同时,从每个箱体延伸出来的线条(代表最低和最高四分位数的评分)似乎随着时间的推移有了更宽的分布,这可能表明每个十年的最差电影在变得越来越糟,而最好的电影则可能在变得越来越好,至少自 1980 年代以来如此。
虽然箱线图提供了一个不错的高层次视图,显示了每十年的数据分布,但 seaborn 还提供了其他可能更具洞察力的图表。一个例子是小提琴图,它本质上是一个核密度估计图(在非聚合数据的分布绘图一节中已介绍),并叠加在箱线图之上。Seaborn 通过 sns.violinplot 函数来实现这一点:
`sns.violinplot( data=df, x="imdb_score", y="title_decade", )`
许多十年显示出单峰分布,但如果仔细观察 1950 年代,你会注意到核密度估计图有两个峰值(一个在大约 7 分左右,另一个峰值稍微高于 8 分)。1960 年代也展示了类似的现象,尽管 7 分左右的峰值略不明显。对于这两个十年,小提琴图的覆盖层表明,相对较高的评分量分布在 25 和 75 百分位之间,而其他十年则更多地回归到中位数附近。
然而,小提琴图仍然使得难以辨别每十年有多少条评分数据。虽然每个十年的分布当然很重要,但数量可能会讲述另一个故事。也许旧时代的电影评分较高是因为生存偏差,只有被认为好的电影才会在这些十年里被评价,或者也许是因为现代的十年更注重质量而非数量。
无论根本原因是什么,seaborn 至少可以通过使用群体图帮助我们直观地确认每个十年的数量分布,这种图表将小提琴图的核密度估计部分按数量纵向缩放:
`sns.swarmplot( data=df, x="imdb_score", y="title_decade", size=.25, )`
正如你在图表中看到的,大多数评论的数量集中在 1990 年代及之后,尤其是 2000-2010 年间的评论(请记住,我们的数据集仅包含到 2016 年的电影)。1990 年之前的十年,评论的数量相对较少,甚至在图表上几乎不可见。
使用群体图,你可以更进一步地分析数据,通过向可视化中添加更多维度。目前,我们已经发现电影评分随着时间的推移呈现下降趋势,无论是由于评分的生存偏差,还是注重数量而非质量。那么,如果我们想了解不同类型的电影呢?PG-13 电影的表现是否优于 R 级电影?
通过控制每个点在散点图上的颜色,你可以为可视化添加额外的维度。为了更好地展示这一点,让我们缩小范围,仅查看一些年份的数据,因为我们当前的图表已经很难阅读了。我们也可以只关注有评分的电影,因为没有评分的条目或电视节目并不是我们需要深入分析的内容。作为最终的数据清洗步骤,我们将把title_year列转换为分类数据类型,这样绘图库就能知道像 2013 年、2014 年、2015 年等年份应该作为离散值处理,而不是作为 2013 到 2015 年的连续范围:
`ratings_of_interest = {"G", "PG", "PG-13", "R"} mask = ( (df["title_year"] >= 2013) & (df["title_year"] <= 2015) & (df["content_rating"].isin(ratings_of_interest)) ) data = df[mask].assign( title_year=lambda x: x["title_year"].astype(pd.CategoricalDtype()) ) data.head()`
`movie_title content_rating title_year imdb_score title_decade 2 Spectre PG-13 2015 6.8 (2010, 2020] 8 Avengers: Age of Ultron PG-13 2015 7.5 (2010, 2020] 14 The Lone Ranger PG-13 2013 6.5 (2010, 2020] 15 Man of Steel PG-13 2013 7.2 (2010, 2020] 20 The Hobbit: The Battle of the Five Ar-mies PG-13 2014 7.5 (2010, 2020]`
在数据清洗完成后,我们可以继续将content_rating添加到我们的图表中,并通过hue=参数让 seaborn 为每个数据点分配一个独特的颜色:
`sns.swarmplot( data=data, x="imdb_score", y="title_year", hue="content_rating", )`
添加颜色为我们的图表增添了另一个信息维度,尽管另一种方法是为每个content_rating使用一个单独的图表,这样可能会让图表更加易读。
为了实现这一点,我们将使用sns.catplot函数并传入一些额外的参数。需要注意的第一个参数是kind=,通过这个参数,我们将告诉 seaborn 绘制“散点图”给我们。col=参数决定了用于生成单独图表的字段,而col_wrap=参数则告诉我们在一行中可以放几个图表,假设我们的图表是以网格布局方式排列的:
`sns.catplot( kind="swarm", data=data, x="imdb_score", y="title_year", col="content_rating", col_wrap=2, )`
该可视化图表似乎表明,2013 年是一个适合电影的年份,至少相对于 2014 年和 2015 年。在 PG-13 内容评级下,看起来 2013 年有相对更多的电影评分在 7 到 8 之间,而其他年份的评分则没有这么集中的趋势。对于 R 级电影来说,2013 年大多数电影的评分都在 5 分以上,而随着年份的推移,更多的电影评分低于这一线。
加入我们的 Discord 社区
加入我们的社区 Discord 空间,与作者和其他读者进行讨论:
留下你的评论!
感谢你从 Packt 出版社购买这本书——我们希望你喜欢它!你的反馈对于我们至关重要,它帮助我们改进和成长。阅读完成后,请花一点时间在亚马逊上留下评论;这只需一分钟,但对像你这样的读者来说意义重大。
扫描下面的二维码,获取你选择的免费电子书。
packt.link/NzOWQ