☰
用 Python 和 Pandas 探索数据集:从读取、查询到可视化的完整实战指南
2026/10/15 8:40:30 网站建设 项目流程
  • 示例工程
  • 教程

【免费下载链接】materials

Bonus materials, exercises, and example projects for our Python tutorials

项目地址:https://gitcode.com/gh_mirrors/ma/materials
点击查看免费下载

本文基于 materials 仓库 pandas-intro 目录下的配套教学 notebook pandas_intro.ipynb 展开,以 NBA 历史比赛数据(来自 FiveThirtyEight 的nbaallelo.csv)为贯穿案例,系统讲解用 Pandas 探索数据集的核心流程。读完本文,你将掌握read_csv读取数据、.info()/.describe()快速体检、Series/DataFrame两大数据结构、.loc/.iloc索引访问、布尔查询、聚合分组、列增删改、缺失值清洗、concat/merge多表合并,以及基于 matplotlib 的快速可视化,形成一条可复制的"读数据 → 认识数据 → 清洗数据 → 分析数据 → 呈现数据"完整链路。

环境准备与数据集获取

安装依赖

本教程的完整代码均可通过 Python 3 运行,核心依赖只有三个:pandas(数据分析)、requests(下载数据集)、matplotlib(绘图)。在 notebook 中提供了两种安装方式(见 pandas_intro.ipynb 的 Environment Setup 章节):

$ python3 -m pip install requests pandas matplotlib

使用 conda 环境时:

$ conda install requests pandas matplotlib

下载 NBA 数据集

教程使用五八同城(FiveThirtyEight)公开的 NBA ELO 历史数据集作为探索对象。notebook 通过requests直接下载 CSV 并落盘为nba_all_elo.csv:

import requests download_url = "https://raw.githubusercontent.com/fivethirtyeight/data/master/nba-elo/nbaallelo.csv" target_csv_path = "nba_all_elo.csv" response = requests.get(download_url) response.raise_for_status() with open(target_csv_path, "wb") as f: f.write(response.content) print("Download ready.")

该数据集涵盖了从 1946 年至今的 NBA 比赛记录,包含比分、球队、ELO 评分、比赛地点等 23 个原始字段,规模庞大且贴近真实业务数据,非常适合用来练习数据探索。

初见 DataFrame:读取与基本概览

读取 CSV 并确认对象类型

import pandas as pd nba = pd.read_csv("nba_all_elo.csv") type(nba) # Expected: # <class 'pandas.core.frame.DataFrame'>

pd.read_csv()返回的核心对象是DataFrame——Pandas 中最常用的二维表格数据结构。紧接着可以用三个"灵魂方法"快速把握数据规模:

len(nba) # Expected: # 126314 nba.shape # Expected: # (126314, 23)

len()返回行数(126314 行),.shape返回(行数, 列数)元组(23 列)。仅用两行代码就能确认这是一个超过 12 万行的中型数据集。

用.head()和.tail()预览数据

nba.head() nba.tail()

.head()默认展示前 5 行,.tail()展示后 5 行,是快速浏览数据长相最直观的方式。

调整显示选项

数据集中有 23 列,默认显示会截断。notebook 中通过pd.set_option()调整全局显示参数:

pd.set_option("display.max.columns", None) # 显示所有列 pd.set_option("display.precision", 2) # 数值精度保留两位小数

display.max.columns设为None表示不限制列显示数量;display.precision控制浮点数的显示精度(不影响底层存储值)。这两个选项对后续所有 DataFrame 的输出都生效。

练习:你能打印出这个 DataFrame 的最后 3 行吗?(提示:nba.tail(3))

深入了解数据:.info()与.describe()

用.info()查看数据类型与缺失情况

nba.info()

.info()输出每个列的类型(dtype)与非空值(non-null)数量,是判断数据"有没有缺、缺多少"的第一手资料。从 notebook 后续的renamed_df.info()输出可以看到该类方法呈现的信息形态:

<class 'pandas.core.frame.DataFrame'> RangeIndex: 126314 entries, 0 to 126313 Data columns (total 25 columns): gameorder 126314 non-null int64 ... notes 5424 non-null object date_played 126314 non-null datetime64[ns] difference 126314 non-null int64 dtypes: datetime64ns, float64(6), int64(8), object(10) memory usage: 24.1+ MB

从这份输出可以读出三个关键信息:gameorder是int64整数、notes只有 5424 个非空值(意味着存在大量缺失)、date_played是datetime64[ns]日期类型,同时还能看到内存占用估算。

用.describe()查看统计摘要

nba.describe()

.describe()默认对数值列输出count、mean、std、min、四分位数和max等描述统计。若要查看对象(字符串)列,需要传入参数:

nba.describe(include=object)

此时输出的将变为count、unique、top(出现最多的值)、freq(该值出现次数)——这对于快速了解分类字段的分布非常有帮助,例如查看notes列最常见的值是什么。

迷你数据探索:计数、筛选与日期聚合

notebook 用一个"迷你探索"串起了多个核心 API。首先是.value_counts()统计分类取值频次:

nba["team_id"].value_counts() # Expected: # BOS 5997 # NYK 5769 # LAL 5078 ... # SDS 11 nba["fran_id"].value_counts() # Expected: # Lakers 6024 # Celtics 5997 # Knicks 5769 ... # Falcons 60 # Name: fran_id, dtype: int64

接着组合.loc布尔筛选与.value_counts(),看看 Lakers 名下有哪些具体的team_id:

nba.loc[nba["fran_id"] == "Lakers", "team_id"].value_counts() # Expected: # LAL 5078 # MNL 946 # Name: team_id, dtype: int64

然后是日期处理。原始数据中日期是字符串列date_game,先用pd.to_datetime()转换为真正的日期类型:

nba["date_played"] = pd.to_datetime(nba["date_game"])

转换后即可对 MNL(明尼阿波利斯湖人)这支球队的比赛日期求最小值和最大值:

nba.loc[nba["team_id"] == "MNL", "date_played"].min() # Expected: # Timestamp('1948-11-04 00:00:00') nba.loc[nba["team_id"] == "MNL", "date_played"].max() # Expected: # Timestamp('1960-03-26 00:00:00')

甚至可以用.agg()一次性传入多个聚合函数:

nba.loc[nba["team_id"] == "MNL", "date_played"].agg(("min", "max")) # Expected: # min 1948-11-04 # max 1960-03-26 # Name: date_played, dtype: datetime64[ns]

练习:找出波士顿凯尔特人(Boston Celtics)在本数据集中所有比赛的总得分。

Pandas 核心数据结构:Series 与 DataFrame

Series:Pandas 的基本构件

Series是一维带标签数组。从普通列表创建时自动生成RangeIndex:

revenues = pd.Series([5555, 7000, 1980]) revenues # Expected: # 0 5555 # 1 7000 # 2 1980 # dtype: int64

其内部数据(.values)本质上是 NumPy 数组,而索引(.index)独立存在:

revenues.values # Expected: # array([5555, 7000, 1980]) revenues.index # Expected: # RangeIndex(start=0, stop=3, step=1) type(revenues.values) # Expected: # <class 'numpy.ndarray'>

也可以显式指定标签索引:

city_revenues = pd.Series( [4200, 8000, 6500], index=["Amsterdam", "Toronto", "Tokyo"] ) city_revenues # Expected: # Amsterdam 4200 # Toronto 8000 # Tokyo 6500 # dtype: int64

Series 与 Python 内置结构的对比

用字典直接创建 Series,标签即字典键,此时它表现得像增强版字典:

city_employee_count = pd.Series({"Amsterdam": 5, "Tokyo": 8}) city_employee_count # Expected: # Amsterdam 5 # Tokyo 8 # dtype: int64 city_employee_count.keys() # Expected: # Index(['Amsterdam', 'Tokyo'], dtype='object') "Tokyo" in city_employee_count # Expected: # True "New York" in city_employee_count # Expected: # False

注意:in检查的是索引标签而非值,这与 Python 字典的成员判断语义一致。

DataFrame:Pandas 最流行的数据结构

DataFrame本质上是共享索引的多个 Series 的集合。从字典构造时,字典值可以是 Series,缺失位置自动填充NaN:

city_data = pd.DataFrame( {"revenue": city_revenues, "employee_count": city_employee_count} ) city_data # Expected: # revenue employee_count # Amsterdam 4200 5.0 # Tokyo 6500 8.0 # Toronto 8000 NaN

索引、数值、轴都可以分别取出:

city_data.index # Expected: # Index(['Amsterdam', 'Tokyo', 'Toronto'], dtype='object') city_data.values # Expected: # array([[4.2e+03, 5.0e+00], # [6.5e+03, 8.0e+00], # [8.0e+03, nan]]) city_data.axes # Expected: # [Index(['Amsterdam', 'Tokyo', 'Toronto'], dtype='object'), # Index(['revenue', 'employee_count'], dtype='object')]

.axes[0]是行(索引)轴、.axes[1]是列轴。与 Series 不同,DataFrame的in成员判断针对列名而非行标签:

city_data.keys() # Expected: # Index(['revenue', 'employee_count'], dtype='object') "Amsterdam" in city_data # Expected: # False "revenue" in city_data # Expected: # True

练习:显示nba数据集的 index 和 axes,并检查它有没有名为 "points" 的列——还是叫 "pts"?

元素访问:Python list 索引运算符与 Series 的异同

[]索引运算符的便利与局限

对带标签的 Series,[]既支持标签又支持位置:

city_revenues["Toronto"] # Expected: # 8000 city_revenues[1] # Expected: # 8000 city_revenues[-1] # Expected: # 6500

切片的语义同样"双通道":

city_revenues[1:] # Expected: # Toronto 8000 # Tokyo 6500 # dtype: int64 city_revenues["Toronto":] # Expected: # Toronto 8000 # Tokyo 6500 # dtype: int64

但混用两种语义很容易造成歧义,这正是.loc和.iloc存在的意义。

用.loc与.iloc精确访问

看一个经典陷阱:当 Series 的显式索引与位置编号不一致时,[]的行为会变得含糊。notebook 构造了一个索引为[1, 2, 3, 5, 8]的颜色 Series:

colors = pd.Series( ["red", "purple", "blue", "green", "yellow"], index=[1, 2, 3, 5, 8] ) colors # Expected: # 1 red # 2 purple # 3 blue # 5 green # 8 yellow # dtype: object

此时必须区分"按标签取"与"按位置取":

colors.loc[1] # 按标签 1 → 取第 0 个元素 # Expected: # 'red' colors.iloc[1] # 按位置 1 → 取第 1 个元素 # Expected: # 'purple'

切片方面,.loc的切片是闭区间(包含两端标签),.iloc的切片是半开区间(不含末尾位置):

# Return the elements with the implicit index: 1, 2 colors.iloc[1:3] # Expected: # 2 purple # 3 blue # dtype: object # Return the elements with the explicit index between 3 and 8 colors.loc[3:8] # Expected: # 3 blue # 5 green # 8 yellow # dtype: object colors.iloc[-2] # 负数按位置从尾部数 # Expected: # 'green'

访问 DataFrame 的元素

访问列

[]传入列名返回一个 Series:

city_data["revenue"] # Expected: # Amsterdam 4200 # Tokyo 6500 # Toronto 8000 # Name: revenue, dtype: int64 type(city_data["revenue"]) # Expected: # <class 'pandas.core.series.Series'>

列名若符合 Python 标识符规则,还可以用属性访问(city_data.revenue),二者结果一致,但属性访问无法处理带空格或特殊字符的列名。

需要注意toys.shape与toys["shape"]的区别——前者是 DataFrame 的维度属性,后者是名为shape的列:

toys = pd.DataFrame( [ {"name": "ball", "shape": "sphere"}, {"name": "Rubik's cube", "shape": "cube"}, ] ) toys["shape"] # Expected: # 0 sphere # 1 cube # Name: shape, dtype: object toys.shape # Expected: # (2, 2)

访问行:.loc与.iloc

city_data.loc["Amsterdam"] # Expected: # revenue 4200.0 # employee_count 5.0 # Name: Amsterdam, dtype: float64 city_data.loc["Tokyo":"Toronto"] # Expected: # revenue employee_count # Tokyo 6500 8.0 # Toronto 8000 NaN city_data.iloc[1] # Expected: # revenue 6500.0 # employee_count 8.0 # Name: Tokyo, dtype: float64

练习:显示nba数据集的倒数第二行。

同时访问行与列的子集

.loc的第一个位置是行标签切片,第二个位置是列名切片:

city_data.loc["Amsterdam":"Tokyo", "revenue"] # Expected: # Amsterdam 4200 # Tokyo 6500 # Name: revenue, dtype: int64

练习:查看标签 5555 到 5559 之间的比赛记录,只关心球队名称和双方比分。

用布尔条件查询数据

Pandas 的查询本质是布尔掩码:先构造与 DataFrame 等长的布尔 Series,再放入[]中过滤出True的行。

按数值条件过滤:

current_decade = nba[nba["year_id"] > 2010] current_decade.shape # Expected: # (12658, 24)

按非空条件过滤:

games_with_notes = nba[nba["notes"].notnull()] games_with_notes.shape # Expected: # (5424, 24)

用字符串方法过滤(.str访问器):

ers = nba[nba["fran_id"].str.endswith("ers")] ers.shape # Expected: # (27797, 24)

多个条件组合时,必须用&(与)和|(或)而非 Python 的and/or,并且每个条件都要加括号:

nba[ (nba["_iscopy"] == 0) & (nba["pts"] > 100) & (nba["opp_pts"] > 100) & (nba["team_id"] == "BLB") ]

练习:1992 年春天,洛杉矶的两支球队都曾在另一块场地打主场比赛。找出这两场比赛(两支球队的 ID 都以 "LA" 开头)。

聚合与分组

Series 聚合

city_revenues.sum() # Expected: # 18700 city_revenues.max() # Expected: # 8000

DataFrame 聚合

先从 DataFrame 取出pts列(得到一个 Series),再求总和:

points = nba["pts"] type(points) # Expected: # <class 'pandas.core.series.Series'> points.sum() # Expected: # 12976235

分组聚合:groupby

.groupby()是 Pandas 分析能力的核心。按球队全名分组求和:

nba.groupby("fran_id", sort=False)["pts"].sum() # Expected: # fran_id # Huskies 3995 # Knicks 582497 # Stags 20398 # Falcons 3797 # Capitols 22387

sort=False表示保留数据原始分组顺序而非按分组键排序。也可以按多个列分组——下面统计马刺 2011 年之后每个赛季常规赛/季后赛(game_result)的胜场数:

nba[(nba["fran_id"] == "Spurs") & (nba["year_id"] > 2010)].groupby( ["year_id", "game_result"] )["game_id"].count() # Expected: # year_id game_result # 2011 L 25 # W 63 # 2012 L 20 # W 60 # 2013 L 30 # W 73 # 2014 L 27 # W 78 # 2015 L 31 # W 58 # Name: game_id, dtype: int64

关于groupby的更深入用法(多列分组、agg多函数组合等),可进一步参考仓库中的 pandas-groupby/README.md 及其配套数据文件。

练习:看看金州勇士(Golden State Warriors)2014-15 赛季(year_id为 2015)在常规赛和季后赛各取得了多少胜场与负场。

操作列:新增、重命名、删除与类型转换

对原始数据做任何修改前,先复制一份,避免污染原始数据:

df = nba.copy() df.shape # Expected: # (126314, 24)

新增列

新增列本质上就是"按行对齐"的向量运算,直接赋值即可:

df["difference"] = df.pts - df.opp_pts df.shape # Expected: # (126314, 25) df["difference"].max() # Expected: # 68

重命名列

.rename()通过columns参数传入新旧列名映射,返回新对象(不修改原对象):

renamed_df = df.rename( columns={"game_result": "result", "game_location": "location"} )

renamed_df.info()中即可看到game_result已变成result、game_location已变成location。

删除列

elo_columns = ["elo_i", "elo_n", "opp_elo_i", "opp_elo_n"] df.drop(elo_columns, inplace=True, axis=1) df.shape # Expected: # (126314, 21)

axis=1表示按列方向删除,inplace=True表示原地修改当前对象。从df.shape由(126314, 25)变为(126314, 21)可以看到一次性删掉了 4 列 ELO 评分字段。

更改数据类型

先用.info()检查各列类型,再逐列转换。字符串日期转日期类型:

df["date_game"] = pd.to_datetime(df["date_game"])

字符串枚举列转Categorical可以大幅节省内存并提升分组性能。转换前先看取值分布:

df["game_location"].nunique() # Expected: # 3 df["game_location"].value_counts() # Expected: # A 63138 # H 63138 # N 38 # Name: game_location, dtype: int64

只有 3 个取值(A 客场、H 主场、N 中立场地),非常适合转为分类类型:

df["game_location"] = pd.Categorical(df["game_location"]) df["game_location"].dtype # Expected: # CategoricalDtype(categories=['A', 'H', 'N'], ordered=False)

练习:在nba数据集中找出另一个"数据类型过于宽泛"的列,并思考它适合转成什么类型。

清洗数据:缺失值、无效值与矛盾值

缺失值处理

再次通过.info()定位缺失:notes列只有 5424 个非空值,其余 12 万行都是NaN。

按需选择处理策略:

  • 删除含缺失的行(.dropna()默认按行删):
rows_without_missing_data = nba.dropna() rows_without_missing_data.shape # Expected: # (5424, 24)

12 万行数据因为notes大量缺失只剩下 5424 行——可见"删行"对稀疏字段是代价极高的操作。

  • 删除含缺失的列(axis=1):
data_without_missing_columns = nba.dropna(axis=1) data_without_missing_columns.shape # Expected: # (126314, 23)
  • 填充缺失值(.fillna()):
data_with_default_notes = nba.copy() data_with_default_notes["notes"].fillna(value="no notes at all", inplace=True) data_with_default_notes["notes"].describe() # Expected: # count 126314 # unique 232 # top no notes at all # freq 120890 # Name: notes, dtype: object

填充后notes列count变为 126314(无缺失),且describe(include=object)显示最频繁值变成了默认文案no notes at all。

无效值检查

pts为 0 的比赛记录属于可疑数据,直接筛出来人工核验:

nba[nba["pts"] == 0]

列间矛盾值检查

还可以用布尔查询验证不同列之间的逻辑一致性——得分高于对手的比赛结果必然为 "W",低于对手必然为 "L":

nba[(nba["pts"] > nba["opp_pts"]) & (nba["game_result"] != "W")].empty # Expected: # True nba[(nba["pts"] < nba["opp_pts"]) & (nba["game_result"] != "L")].empty # Expected: # True

.empty返回True表示筛选结果为空,即数据集中不存在此类矛盾记录——这是数据质量验证的经典写法。

处理多个数据集:concat 与 merge

基于索引纵向拼接:concat()

准备第二份城市数据并按行拼接(axis=0为默认):

further_city_data = pd.DataFrame( {"revenue": [7000, 3400], "employee_count": [2, 2]}, index=["New York", "Barcelona"], ) all_city_data = pd.concat([city_data, further_city_data], sort=False) all_city_data # Expected: # Amsterdam 4200 5.0 # Tokyo 6500 8.0 # Toronto 8000 NaN # New York 7000 2.0 # Barcelona 3400 2.0

按列拼接(axis=1)时,行索引自动对齐,缺失位置填充NaN:

city_countries = pd.DataFrame( { "country": ["Holland", "Japan", "Holland", "Canada", "Spain"], "capital": [1, 1, 0, 0, 0], }, index=["Amsterdam", "Tokyo", "Rotterdam", "Toronto", "Barcelona"], ) cities = pd.concat([all_city_data, city_countries], axis=1, sort=False) cities # Expected: # revenue employee_count country capital # Amsterdam 4200.0 5.0 Holland 1.0 # Tokyo 6500.0 8.0 Japan 1.0 # Toronto 8000.0 NaN Canada 0.0 # New York 7000.0 2.0 NaN NaN # Barcelona 3400.0 2.0 Spain 0.0 # Rotterdam NaN NaN Holland 0.0

如果只想保留两侧索引都存在的行,可以指定join="inner":

pd.concat([all_city_data, city_countries], axis=1, join="inner") # Expected: # revenue employee_count country capital # Amsterdam 4200 5.0 Holland 1 # Tokyo 6500 8.0 Japan 1 # Toronto 8000 NaN Canada 0 # Barcelona 3400 2.0 Spain 0

关于concat/merge/join的更多组合方式,可参考仓库中的 introduction-combining-data-pandas-merge-join-and-concat/pandas_merge.ipynb(含配套气候数据 CSV)。

基于任意列合并:merge()

merge()允许不按索引、而是按任意列进行连接。下面将cities的国家名(country列)与countries的索引对齐:

countries = pd.DataFrame( { "population_millions": [17, 127, 37], "continent": ["Europe", "Asia", "North America"], }, index=["Holland", "Japan", "Canada"], ) pd.merge(cities, countries, left_on="country", right_index=True)

left_on="country"指定左表用country列,right_index=True指定右表用索引,默认how="inner"(只保留能匹配上的行)。如果希望保留左表所有行、无法匹配的填NaN,则用how="left":

pd.merge(cities, countries, left_on="country", right_index=True, how="left")

可视化你的 DataFrame

在 notebook 中先启用内嵌绘图模式(Jupyter 环境):

%matplotlib inline

之后任何返回 Series/DataFrame 的聚合结果都可以直接调用.plot()出图。按年份绘制尼克斯(Knicks)每年总得分的趋势线:

nba[nba["fran_id"] == "Knicks"].groupby("year_id")["pts"].sum().plot()

绘制球队出现频次的横向柱状图(kind="bar"):

nba["fran_id"].value_counts().head(10).plot(kind="bar")

Pandas 的.plot()默认基于 matplotlib 实现,kind参数支持line、bar、barh、hist、box、kde、pie、scatter等多种图形类型,是"聚合结果 → 快速可视化"的最短路径。

练习:2013 年迈阿密热火(Miami Heat)夺冠。创建一个饼图(pie plot),展示该赛季他们的胜负场次分布。

总结:一条完整的数据探索链路

从 pandas_intro.ipynb 的实践可以看出,一次规范的数据探索之旅可以归纳为六个阶段,每个阶段对应本文讲解的核心 API:

  1. 读取与规模感知:pd.read_csv()→len()、.shape、.head()、.tail();
  2. 结构与统计体检:.info()(类型与缺失)、.describe()(数值/对象统计摘要);
  3. 数据选择与筛选:[]、.loc、.iloc、布尔掩码组合查询;
  4. 聚合与分组:.sum()、.max()、.agg()、.groupby();
  5. 列操作与清洗:新增/重命名/删除/类型转换、dropna/fillna、矛盾值校验;
  6. 多表合并与可视化:concat()、merge()、.plot()。

这套方法论既适用于本仓库中 NBA 数据集这类单表场景,也能平滑迁移到多源数据的日常分析工作中。如果你希望深入相邻主题,仓库中还提供了相关配套资源:pandas-groupby/README.md 专门深入讲解groupby的高级用法,pandas-iterate-over-rows/README.md 则讨论了"为什么不应逐行遍历 DataFrame"及其替代方案。掌握了本文的数据探索主干后,配合这些主题,就能建立起一个完整的 Pandas 数据分析知识体系。

  • 示例工程
  • 教程

【免费下载链接】materials

Bonus materials, exercises, and example projects for our Python tutorials

项目地址:https://gitcode.com/gh_mirrors/ma/materials
点击查看免费下载

相关推荐

上一篇:AirPodsDesktop终极指南:在Windows/Linux上完美使用苹果AirPods的完整解决方案
下一篇:10分钟精通抖音无水印下载:douyin-downloader深度使用全攻略

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询