第十五章 数据分析

第十五章 数据分析

上一章写了爬虫,把别人的数据搬到自己硬盘上。搬回来之后呢。

从一堆原始数据到你敢下结论,中间隔着四步。

text 复制代码
   读进来          洗干净           算出去           看得见
  ┌────────┐    ┌────────┐    ┌────────┐    ┌────────┐
  │ read_  │──► │ 去重   │──► │groupby │──► │ matplotlib
  │  csv   │    │ 补缺   │    │  agg   │    │  画图   │
  │ read_  │    │ 过滤   │    │ pivot  │    │         │
  │ excel  │    │ 改类型 │    │  merge │    │         │
  └────────┘    └────────┘    └────────┘    └────────┘
    pandas         pandas        pandas        matplotlib

前三步归 pandas 管,最后一步归 matplotlib 管。这两位的地基都是 numpy,它负责把几百万个数的运算一口气算完。

这一章就顺着这四步走。中间会反复提到三个东西,先把它们的分工记下来,DataFrame 是整张表,Series 是其中一列,ndarray 是藏在底下的数组。

本章的例子全程用一个虚构的"旧书铺"订单表,十二行数据,你复制下来就能跑,不需要联网,也不需要找数据集。


1 Pandas 入门

1.1 只有两个数据结构

pandas 学起来没那么难,因为它把数据抽象成了两个东西。

text 复制代码
    一维 Series                          二维 DataFrame

   index    value                     index │  book      │ price │ qty
  ┌───────┬─────────┐                ┌──────┼────────────┼───────┼─────┐
  │   0   │  45.0   │                │   0  │ Python基础 │  45.0 │  2  │
  │   1   │  68.0   │                │   1  │ 网络编程   │  68.0 │  1  │
  │   2   │  52.5   │                │   2  │ 数据分析   │  52.5 │  3  │
  └───────┴─────────┘                └──────┴────────────┴───────┴─────┘
  一列数据加一串标签                   多列共享同一个 index
                                      每一列抽出来都是一个 Series

Series 是带标签的一列。DataFrame 是把几个 Series 按同一个标签并排放起来,也就是一张表。

拿 Python 自带的容器对比一下,心里就有数了。

需求 原生 Python pandas
一列数 list Series
一列带名字的数 dict Series
一张二维表 列表套列表 DataFrame
一行记录 dict Series(行方向)
取第 3 行的 price rows[2]["price"] df.loc[2, "price"]
所有行的 price 求和 循环累加 df["price"].sum()
按城市分组求和 自己写字典兜 df.groupby("city")["amount"].sum()

左边的写法数据量小的时候挺好。数据一上百万行,循环就跑不动了。pandas 底层的 numpy 是用 C 写的,整列一次算完。

1.2 装上,先看版本

bash 复制代码
pip install pandas openpyxl matplotlib

openpyxl 是读写 Excel 用的,不装的话 to_excel 会报错。matplotlib 画图用,第三节开始需要。

装完先看版本,这一步省不得。

python 复制代码
>>> import pandas as pd
>>> import numpy as np
>>> pd.__version__
'3.0.6'
>>> np.__version__
'2.5.3'

我写这篇的时候是 pandas 3.0.6 配 numpy 2.5.3。pandas 在 2.0 和 3.0 各做了一次大调整,网上很多教程是按 1.x 写的,有些写法现在跑不通,遇到的时候我会标出来。你的版本号跟我不一样也没关系,本章的例子在 2.x 上基本通用。

1.3 Series,带标签的一列

python 复制代码
>>> s = pd.Series([45.0, 68.0, 52.5])
>>> s
0    45.0
1    68.0
2    52.5
dtype: float64
>>> s.values
array([45. , 68. , 52.5])
>>> s.dtype
dtype('float64')
>>> s.index
RangeIndex(start=0, stop=3, step=1)

左边那列 0、1、2 是索引,默认从 0 开始排。也可以自己给。

python 复制代码
>>> price = pd.Series([45.0, 68.0, 52.5], index=["a", "b", "c"])
>>> price
a    45.0
b    68.0
c    52.5
dtype: float64
>>> price["b"]
np.float64(68.0)
>>> price[["a", "c"]].tolist()
[45.0, 52.5]
>>> price.index.tolist()
['a', 'b', 'c']

这里有个容易糊涂的点。price[0] 和 price["a"] 都能取到第一个,整数下标和标签混着用不会报错,但一旦索引不是数字串、不是从 0 连续排的整数,含义就可能打架。自己的代码里尽量只在 loc 和 iloc 里做选择,别依赖裸下标。

python 复制代码
>>> s2 = pd.Series([1, 2, 3], index=[10, 11, 12])
>>> s2[10]
np.int64(1)
>>> s2.loc[10]
np.int64(1)
>>> s2.iloc[0]
np.int64(1)
>>> s2[0]
KeyError: 0

最后一行会抛 KeyError,因为索引是整数的时候,裸的 s2[0] 被当成标签去找,而标签里没有 0。这种时候一定用 loc 或 iloc,别偷懒。

Series 上的运算是逐元素进行的,不用写循环。

python 复制代码
>>> price * 2
a     90.0
b    136.0
c    105.0
dtype: float64
>>> price + 10
a    55.0
b    78.0
c    62.5
dtype: float64
>>> price.mean()
np.float64(55.166666666666664)
>>> price.max()
np.float64(68.0)
>>> price > 50
a    False
b     True
c     True
dtype: bool
>>> price[price > 50]
b    68.0
c    52.5
dtype: float64

最后一行是布尔索引,把一个布尔 Series 塞进方括号,只留下为 True 的那些。这个写法会一直用到本章结束。

还能当字典使,两个 Series 可以直接对齐运算,标签相同的项做运算,对不上的给 NaN。

python 复制代码
>>> a = pd.Series([1, 2, 3], index=["x", "y", "z"])
>>> b = pd.Series([10, 20], index=["x", "y"])
>>> a + b
x    11.0
y    22.0
z     NaN
dtype: float64
>>> (a + b).isna().tolist()
[False, False, True]

z 在 b 里没有,结果就是 NaN。SQL 里做表连接才会出现的行为,pandas 做加法就有了。

1.4 DataFrame,一张表

最常用的构造方式是给字典。键变成列名,值是那一列的数据。

python 复制代码
>>> books = pd.DataFrame({
...     "book": ["Python基础教程", "网络编程实战", "数据分析入门"],
...     "price": [45.0, 68.0, 52.5],
...     "qty": [2, 1, 3]})
>>> books
         book  price  qty
0  Python基础教程   45.0    2
1      网络编程实战   68.0    1
2      数据分析入门   52.5    3
>>> books.shape
(3, 3)
>>> books.columns.tolist()
['book', 'price', 'qty']
>>> books.index.tolist()
[0, 1, 2]
>>> books.dtypes
book         str
price    float64
qty        int64
dtype: object

注意最后一行的输出。pandas 3.0 里字符串列的类型是 str,不是以前那个 object。这是 3.0 最显眼的一个变化,你在老教程里看到的 dtype: object 今天会显示成 dtype: str。

也可以从列表套字典构造,一行一个 dict,键对不齐的地方自动补 NaN。

python 复制代码
>>> pd.DataFrame([
...     {"book": "并发编程", "price": 77.0},
...     {"book": "爬虫与反爬", "qty": 4}])
    book  price  qty
0   并发编程   77.0  NaN
1  爬虫与反爬    NaN  4.0

取一列出来就是一个 Series,两种写法等价,列名是合法标识符时用属性的写法短一些。

python 复制代码
>>> books["price"]
0    45.0
1    68.0
2    52.5
Name: price, dtype: float64
>>> books.price
0    45.0
1    68.0
2    52.5
Name: price, dtype: float64
>>> type(books["price"])
<class 'pandas.Series'>
>>> books[["book", "price"]]
         book  price
0  Python基础教程   45.0
1      网络编程实战   68.0
2      数据分析入门   52.5

方括号里放一个字符串得到 Series,放一个字符串列表得到 DataFrame。少一对方括号,类型完全不同。

1.5 读数据,read_csv 是干活用得最多的那只手

真实场景里没人手打字典,数据都在文件里。read_csv 是所有读函数的模板,先把它弄明白,别的都是它的变体。

python 复制代码
>>> import io
>>> CSV = ("date,book,category,city,qty,price\n"
...        "2026-01-05,Python基础教程,编程,北京,2,45.0\n"
...        "2026-01-08,网络编程实战,编程,上海,1,68.0\n"
...        "2026-01-12,数据分析入门,数据,北京,3,52.5\n"
...        "2026-01-15,Python基础教程,编程,广州,1,45.0\n"
...        "2026-02-02,数据分析入门,数据,上海,2,52.5\n"
...        "2026-02-09,爬虫与反爬,编程,北京,4,39.9\n"
...        "2026-02-14,并发编程,编程,上海,1,77.0\n"
...        "2026-02-20,数据分析入门,数据,广州,2,52.5\n"
...        "2026-03-03,网络编程实战,编程,北京,2,68.0\n"
...        "2026-03-11,并发编程,编程,广州,1,77.0\n"
...        "2026-03-19,数据分析入门,数据,北京,1,52.5\n"
...        "2026-03-25,爬虫与反爬,编程,上海,3,39.9\n")
>>> orders = pd.read_csv(io.StringIO(CSV))
>>> orders
          date        book category city  qty  price
0   2026-01-05  Python基础教程       编程   北京    2   45.0
1   2026-01-08      网络编程实战       编程   上海    1   68.0
2   2026-01-12      数据分析入门       数据   北京    3   52.5
3   2026-01-15  Python基础教程       编程   广州    1   45.0
4   2026-02-02      数据分析入门       数据   上海    2   52.5
5   2026-02-09       爬虫与反爬       编程   北京    4   39.9
6   2026-02-14        并发编程       编程   上海    1   77.0
7   2026-02-20      数据分析入门       数据   广州    2   52.5
8   2026-03-03      网络编程实战       编程   北京    2   68.0
9   2026-03-11        并发编程       编程   广州    1   77.0
10  2026-03-19      数据分析入门       数据   北京    1   52.5
11  2026-03-25       爬虫与反爬       编程   上海    3   39.9

io.StringIO 是把字符串伪装成文件的工具,写教程和测试的时候很好用,因为它不需要真的在磁盘上放一个文件。真实项目里这里写文件路径。

python 复制代码
>>> orders["amount"] = orders["qty"] * orders["price"]
>>> orders.head(3)
         date        book category city  qty  price  amount
0  2026-01-05  Python基础教程       编程   北京    2   45.0    90.0
1  2026-01-08      网络编程实战       编程   上海    1   68.0    68.0
2  2026-01-12      数据分析入门       数据   北京    3   52.5   157.5

整列相乘,amount 一次算完十二行,没有一行循环。

read_csv 常用的几个参数。

参数 干什么 例子
sep 分隔符,默认逗号 sep="\t" 读制表符分隔
header 第几行当表头 header=None 表示没表头
names 自己指定列名 names=["a", "b"]
usecols 只读这几列 usecols=["book", "price"]
dtype 指定列类型 dtype={"qty": "int32"}
parse_dates 把这几列当日期读 parse_dates=["date"]
nrows 先读几行看看 nrows=100
encoding 文件编码 encoding="gbk" 读老 Excel 导出的中文
na_values 哪些字符串算缺失 na_values=["-", "无"]

中文 CSV 最常见的坑是编码。Excel 另存的 CSV 在中文 Windows 上是 GBK,read_csv 默认按 UTF-8 读,一读就报 UnicodeDecodeError。加上 encoding="gbk" 通常就好了,实在不行用第十三章讲过的二进制方式判断。

别的读取函数长一个样。

python 复制代码
>>> pd.read_csv(io.StringIO(CSV), usecols=["book", "price"]).head(2)
         book  price
0  Python基础教程   45.0
1      网络编程实战   68.0
>>> pd.read_csv(io.StringIO(CSV), nrows=3).shape
(3, 6)

Excel、JSON、剪贴板也是同一套路(read_excel、read_json、read_clipboard),参数大同小异。

1.6 看清一张表

拿到一张陌生表,别急着算,先花三十秒把它看清楚。

python 复制代码
>>> orders.shape
(12, 7)
>>> len(orders)
12
>>> orders.columns.tolist()
['date', 'book', 'category', 'city', 'qty', 'price', 'amount']
>>> orders.dtypes
date            str
book            str
category        str
city            str
qty           int64
price       float64
amount      float64
dtype: object
>>> orders.index
RangeIndex(start=0, stop=12, step=1)

shape 给出行数和列数,dtypes 告诉你每列是什么类型。体检一张陌生表,先看这两行。

python 复制代码
>>> orders.head(3)
         date        book category city  qty  price  amount
0  2026-01-05  Python基础教程       编程   北京    2   45.0    90.0
1  2026-01-08      网络编程实战       编程   上海    1   68.0    68.0
2  2026-01-12      数据分析入门       数据   北京    3   52.5   157.5
>>> orders.tail(2)
          date    book category city  qty  price  amount
10  2026-03-19  数据分析入门       数据   北京    1   52.5    52.5
11  2026-03-25   爬虫与反爬       编程   上海    3   39.9   119.7
>>> orders.sample(2, random_state=7)
          date    book category city  qty  price  amount
7   2026-02-20  数据分析入门       数据   广州    2   52.5   105.0
10  2026-03-19  数据分析入门       数据   北京    1   52.5    52.5

head 看开头,tail 看结尾,sample 随机抽几行。看真实数据的时候务必抽中间看看,脏数据常常藏在中间。

python 复制代码
>>> orders.describe().round(2)
         qty  price  amount
count  12.00  12.00   12.00
mean    1.92  55.82   99.36
std     1.00  13.41   38.20
min     1.00  39.90   45.00
25%     1.00  45.00   74.75
50%     2.00  52.50   97.50
75%     2.25  68.00  123.78
max     4.00  77.00  159.60

describe 只对数值列有效,一次性给出 count、mean、std、min、四分位数、max。这一张表基本回答了"数据长什么样"。

python 复制代码
>>> orders.info()
<class 'pandas.DataFrame'>
RangeIndex: 12 entries, 0 to 11
Data columns (total 7 columns):
 #   Column    Non-Null Count  Dtype  
---  ------    --------------  -----  
 0   date      12 non-null     str    
 1   book      12 non-null     str    
 2   category  12 non-null     str    
 3   city      12 non-null     str    
 4   qty       12 non-null     int64  
 5   price     12 non-null     float64
 6   amount    12 non-null     float64
dtypes: float64(2), int64(1), str(4)
memory usage: 804.0 bytes

info 给的是列数、行数、每列的非空个数和类型,还有内存占用。找哪一列有缺失看这一眼就够。

再补几个高频的。

python 复制代码
>>> orders["category"].value_counts()
category
编程    8
数据    4
Name: count, dtype: int64
>>> orders["city"].unique().tolist()
['北京', '上海', '广州']
>>> orders["book"].nunique()
5
>>> orders.isna().sum().to_dict()
{'date': 0, 'book': 0, 'category': 0, 'city': 0, 'qty': 0, 'price': 0, 'amount': 0}
>>> orders.duplicated().sum()
np.int64(0)

这一组我自己几乎每次都跑。value_counts 数每个值出现的次数,nunique 数有多少种取值,后两个是查脏数据的。

1.7 取行列,loc 和 iloc

pandas 的索引方式多到让人晕,常用的其实就两组。

text 复制代码
                    按标签            按位置
                 ┌──────────────┬──────────────┐
   单个值        │ df.loc[2,"c"] │ df.iloc[2,3] │
                 ├──────────────┼──────────────┤
   一片          │ df.loc[0:2,  │ df.iloc[0:2, │
                 │    "a":"c"]  │     0:3]     │
                 ├──────────────┼──────────────┤
   布尔筛选      │ df.loc[mask] │ df.iloc[...] │
                 └──────────────┴──────────────┘
    注意 loc 的切片 0:2 是闭区间,会带上 2
    iloc 的 0:2 是半开区间,不带 2
python 复制代码
>>> orders.loc[0]
date        2026-01-05
book        Python基础教程
category            编程
city                北京
qty                  2
price             45.0
amount            90.0
Name: 0, dtype: object
>>> orders.loc[0, "book"]
'Python基础教程'
>>> orders.loc[[0, 2], ["book", "amount"]]
         book  amount
0  Python基础教程    90.0
2      数据分析入门   157.5
>>> orders.iloc[0, 1]
'Python基础教程'
>>> orders.iloc[0:2, 0:3]
         date        book category
0  2026-01-05  Python基础教程       编程
1  2026-01-08      网络编程实战       编程

loc[0] 取出来的是一行,横着的一行也是个 Series,它的 index 就是原来的列名。这是很多人第一次迷惑的地方。

只想取一个标量的时候,at 和 iat 比 loc 快,写起来也更短。

python 复制代码
>>> orders.at[0, "book"]
'Python基础教程'
>>> orders.iat[0, 1]
'Python基础教程'

再提醒一次之前埋的伏笔,loc 的切片两头都算,iloc 的切片跟 Python 列表习惯一致,右边不算。写错一格不会报错,只是多一行或少一行,这种错误最难发现。

1.8 条件筛选

把条件写进方括号,得到一个布尔 Series,塞回原表就是筛选。

python 复制代码
>>> orders[orders["price"] > 50]
          date    book category city  qty  price  amount
1   2026-01-08  网络编程实战       编程   上海    1   68.0    68.0
2   2026-01-12  数据分析入门       数据   北京    3   52.5   157.5
4   2026-02-02  数据分析入门       数据   上海    2   52.5   105.0
6   2026-02-14    并发编程       编程   上海    1   77.0    77.0
7   2026-02-20  数据分析入门       数据   广州    2   52.5   105.0
8   2026-03-03  网络编程实战       编程   北京    2   68.0   136.0
9   2026-03-11    并发编程       编程   广州    1   77.0    77.0
10  2026-03-19  数据分析入门       数据   北京    1   52.5    52.5
>>> orders[orders["city"] == "北京"]
          date        book category city  qty  price  amount
0   2026-01-05  Python基础教程       编程   北京    2   45.0    90.0
2   2026-01-12      数据分析入门       数据   北京    3   52.5   157.5
5   2026-02-09       爬虫与反爬       编程   北京    4   39.9   159.6
8   2026-03-03      网络编程实战       编程   北京    2   68.0   136.0
10  2026-03-19      数据分析入门       数据   北京    1   52.5    52.5
>>> orders[orders["book"].isin(["并发编程", "爬虫与反爬"])]
          date   book category city  qty  price  amount
5   2026-02-09  爬虫与反爬       编程   北京    4   39.9   159.6
6   2026-02-14   并发编程       编程   上海    1   77.0    77.0
9   2026-03-11   并发编程       编程   广州    1   77.0    77.0
11  2026-03-25  爬虫与反爬       编程   上海    3   39.9   119.7

多个条件用 & 和 | 连接,每个条件外面都要加括号。and 和 or 在这里不能用,它们只能处理单个布尔值。

python 复制代码
>>> orders[(orders["price"] > 50) & (orders["qty"] >= 2)]
         date    book category city  qty  price  amount
2  2026-01-12  数据分析入门       数据   北京    3   52.5   157.5
4  2026-02-02  数据分析入门       数据   上海    2   52.5   105.0
7  2026-02-20  数据分析入门       数据   广州    2   52.5   105.0
8  2026-03-03  网络编程实战       编程   北京    2   68.0   136.0
>>> orders[(orders["city"] == "北京") | (orders["city"] == "上海")]
          date        book category city  qty  price  amount
0   2026-01-05  Python基础教程       编程   北京    2   45.0    90.0
1   2026-01-08      网络编程实战       编程   上海    1   68.0    68.0
2   2026-01-12      数据分析入门       数据   北京    3   52.5   157.5
4   2026-02-02      数据分析入门       数据   上海    2   52.5   105.0
5   2026-02-09       爬虫与反爬       编程   北京    4   39.9   159.6
6   2026-02-14        并发编程       编程   上海    1   77.0    77.0
8   2026-03-03      网络编程实战       编程   北京    2   68.0   136.0
10  2026-03-19      数据分析入门       数据   北京    1   52.5    52.5
11  2026-03-25       爬虫与反爬       编程   上海    3   39.9   119.7

忘了加括号会得到 TypeError,忘了换成 & 会得到 ValueError: The truth value of a Series is ambiguous。这两个报错我见过太多次。

字符串匹配也是常用的一类筛选。

python 复制代码
>>> orders[orders["book"].str.contains("Python")]
         date        book category city  qty  price  amount
0  2026-01-05  Python基础教程       编程   北京    2   45.0    90.0
3  2026-01-15  Python基础教程       编程   广州    1   45.0    45.0
>>> orders[orders["amount"].between(100, 160)]
          date    book category city  qty  price  amount
2   2026-01-12  数据分析入门       数据   北京    3   52.5   157.5
4   2026-02-02  数据分析入门       数据   上海    2   52.5   105.0
5   2026-02-09   爬虫与反爬       编程   北京    4   39.9   159.6
7   2026-02-20  数据分析入门       数据   广州    2   52.5   105.0
8   2026-03-03  网络编程实战       编程   北京    2   68.0   136.0
11  2026-03-25   爬虫与反爬       编程   上海    3   39.9   119.7

query 是另一种写法,条件多了以后读起来清爽些。

python 复制代码
>>> orders.query("city == '北京' and amount > 100")
         date    book category city  qty  price  amount
2  2026-01-12  数据分析入门       数据   北京    3   52.5   157.5
5  2026-02-09   爬虫与反爬       编程   北京    4   39.9   159.6
8  2026-03-03  网络编程实战       编程   北京    2   68.0   136.0

注意里面单引号和外面的双引号,因为整个条件是当字符串传进去的。

1.9 排序和 TopN

python 复制代码
>>> orders.sort_values("amount", ascending=False).head(3)
         date    book category city  qty  price  amount
5  2026-02-09   爬虫与反爬       编程   北京    4   39.9   159.6
2  2026-01-12  数据分析入门       数据   北京    3   52.5   157.5
8  2026-03-03  网络编程实战       编程   北京    2   68.0   136.0
>>> orders.nlargest(3, "amount")
         date    book category city  qty  price  amount
5  2026-02-09   爬虫与反爬       编程   北京    4   39.9   159.6
2  2026-01-12  数据分析入门       数据   北京    3   52.5   157.5
8  2026-03-03  网络编程实战       编程   北京    2   68.0   136.0
>>> orders.nsmallest(2, "price")
          date   book category city  qty  price  amount
5   2026-02-09  爬虫与反爬       编程   北京    4   39.9   159.6
11  2026-03-25  爬虫与反爬       编程   上海    3   39.9   119.7
>>> orders.sort_values(["category", "amount"], ascending=[True, False]).head(4)
          date    book category city  qty  price  amount
2   2026-01-12  数据分析入门       数据   北京    3   52.5   157.5
4   2026-02-02  数据分析入门       数据   上海    2   52.5   105.0
7   2026-02-20  数据分析入门       数据   广州    2   52.5   105.0
10  2026-03-19  数据分析入门       数据   北京    1   52.5    52.5

sort_values 排完之后索引不会自动重置,还跟着原来的行号走。想重排 index 用 reset_index(drop=True)。

python 复制代码
>>> orders.sort_values("amount", ascending=False).head(2).reset_index(drop=True)
         date    book category city  qty  price  amount
0  2026-02-09   爬虫与反爬       编程   北京    4   39.9   159.6
1  2026-01-12  数据分析入门       数据   北京    3   52.5   157.5

排名、去重取第一条、算百分比,这些也常配着用。

python 复制代码
>>> orders["amount"].rank(method="dense").tolist()
[5.0, 3.0, 9.0, 1.0, 6.0, 10.0, 4.0, 6.0, 8.0, 4.0, 2.0, 7.0]
>>> orders.drop_duplicates("book")[["book", "category"]]
         book category
0  Python基础教程       编程
1      网络编程实战       编程
2      数据分析入门       数据
5       爬虫与反爬       编程
6        并发编程       编程
>>> (orders["qty"] / orders["qty"].sum() * 100).round(1).tolist()
[8.7, 4.3, 13.0, 4.3, 8.7, 17.4, 4.3, 8.7, 8.7, 4.3, 4.3, 13.0]

1.10 中文对不齐怎么办

pandas 算列宽的时候按字符个数算,一个汉字算一格。可终端和浏览器里汉字占两格,结果就是列名对得上、里面的中文歪出去。

python 复制代码
>>> pd.get_option("display.unicode.east_asian_width")
False
>>> print(orders.groupby("book")["amount"].sum().round(1).to_string())
book
Python基础教程    135.0
并发编程          154.0
数据分析入门        420.0
爬虫与反爬         279.3
网络编程实战        204.0

打开那个选项之后再看。

python 复制代码
>>> pd.set_option("display.unicode.east_asian_width", True)
>>> print(orders.groupby("book")["amount"].sum().round(1).to_string())
book
Python基础教程    135.0
并发编程          154.0
数据分析入门      420.0
爬虫与反爬        279.3
网络编程实战      204.0
>>> pd.reset_option("display.unicode.east_asian_width")

开关前后那些中文列的差别一眼可见。做国内报表建议直接打开,行数一多特别明显。

再攒几个调输出格式的选项。

python 复制代码
>>> pd.get_option("display.max_rows")
60
>>> pd.get_option("display.width")
80
>>> pd.set_option("display.float_format", "{:.2f}".format)
>>> orders.head(2)
         date        book category city  qty  price  amount
0  2026-01-05  Python基础教程       编程   北京    2  45.00   90.00
1  2026-01-08      网络编程实战       编程   上海    1  68.00   68.00
>>> pd.reset_option("display.float_format")

display.float_format 一次性把所有浮点数的显示格式统一了,比到处写 round(2) 省事,而且不会动数据本身的精度。


2 Pandas 进阶

入门那些操作是为了把数据拿出来,进阶这些是为了把数据算成答案。

2.1 先学会列运算,别写循环

python 复制代码
>>> orders["amount"].sum()
np.float64(1192.3)
>>> orders["amount"].mean()
np.float64(99.35833333333333)
>>> orders["amount"].max()
np.float64(159.6)
>>> (orders["qty"] * orders["price"]).round(2).tolist()
[90.0, 68.0, 157.5, 45.0, 105.0, 159.6, 77.0, 105.0, 136.0, 77.0, 52.5, 119.7]

Series 之间的加减乘除是逐元素对齐算的。判断 pandas 用得熟不熟,看循环多不多就够了。

python 复制代码
>>> discount = pd.Series(0.9, index=orders.index)
>>> (orders["amount"] * discount).round(1).tolist()
[81.0, 61.2, 141.8, 40.5, 94.5, 143.6, 69.3, 94.5, 122.4, 69.3, 47.2, 107.7]

想加一列新数据,直接赋值就行,列名不存在就新建。

python 复制代码
>>> orders["level"] = orders["amount"].map(lambda x: "大单" if x >= 100 else "小单")
>>> orders["level"].tolist()
['小单', '小单', '大单', '小单', '大单', '大单', '小单', '大单', '大单', '小单', '小单', '大单']
>>> orders = orders.drop(columns=["level"])

map 是逐元素映射,apply 也能干同样的事,但 map 语义更清楚。删列用 drop,记得给 columns=。

2.2 groupby,数据分析的分水岭

这句话是 GroupBy 的全部: split 拆开、apply 算、combine 合起来。

text 复制代码
   原始数据                 groupby("category")              sum()
   
   category  amount        ┌─────┐  数据    ┌─────┐        category  amount
   编程       90.0    ──►  │ 编程│ 90.0 ──► │ sum │ ──►    数据       420.0
   数据      157.5         │     │ 68.0    │     │         编程       772.3
   编程       68.0         └─────┘ 136.0   └─────┘
   数据      105.0         ┌─────┐ 52.5
   编程      136.0    ──►  │ 数据│ 105.0 ──► │ sum │
   数据       52.5         │     │ 157.5  │     │
                           └─────┘  52.5  └─────┘
python 复制代码
>>> orders.groupby("category")["amount"].sum()
category
数据    420.0
编程    772.3
Name: amount, dtype: float64
>>> orders.groupby("category")["amount"].mean().round(2)
category
数据    105.00
编程     96.54
Name: amount, dtype: float64
>>> orders.groupby("category")["qty"].count()
category
数据    4
编程    8
Name: qty, dtype: int64

groupby(...) 后面接什么决定算什么。接 ["amount"] 是只盯着这一列,再接 .sum() 就是每组的合计。

一次看多个指标。

python 复制代码
>>> orders.groupby("category")["amount"].agg(["sum", "mean", "count", "max"]).round(2)
            sum    mean  count    max
category                             
数据        420.0  105.00      4  157.5
编程        772.3   96.54      8  159.6

按多个键分组,得到一个多层索引的结果。

python 复制代码
>>> orders.groupby(["category", "city"])["qty"].sum()
category  city
数据        上海      2
          北京      4
          广州      2
编程        上海      5
          北京      8
          广州      2
Name: qty, dtype: int64

想把分组结果放回普通表里继续算,as_index=False 更顺手。

python 复制代码
>>> orders.groupby("city", as_index=False)["amount"].sum().round(2)
  city  amount
0   上海   369.7
1   北京   595.6
2   广州   227.0
>>> orders.groupby("city", as_index=False)["amount"].sum().round(2).sort_values("amount", ascending=False)
  city  amount
1   北京   595.6
0   上海   369.7
2   广州   227.0

最后一行就是入门那一节开头说的"到底哪本书最赚钱"的正解,按金额排,不是按数量。

2.3 agg,一次算一堆指标

agg 可以接收字典,给不同列配不同的算法。

python 复制代码
>>> orders.groupby("category").agg({"amount": "sum", "qty": "sum", "book": "count"})
          amount  qty  book
category                   
数据         420.0    8     4
编程         772.3   15     8

还能一边算一边给结果列起名字,这种写法又叫命名聚合,产出的表列名干净,适合直接导出报表。

python 复制代码
>>> orders.groupby("category").agg(
...     订单数=("book", "count"),
...     销售额=("amount", "sum"),
...     平均单价=("amount", "mean")).round(2)
          订单数    销售额    平均单价
category                    
数据          4  420.0  105.00
编程          8  772.3   96.54

命名聚合是 pandas 后来才加的东西,老教程里那种先 groupby().sum() 再手动 rename 的写法已经没必要了。

2.4 透视表

当你要同时按行和按列切(data),透视表比 groupby 直观。

python 复制代码
>>> orders.pivot_table(index="city", columns="category", values="amount", aggfunc="sum", fill_value=0).round(1)
category     数据     编程
city                  
上海        105.0  264.7
北京        210.0  385.6
广州        105.0  122.0

index 是左边的行,columns 是上面的列,values 是格子里放什么,aggfunc 是重复数据怎么处理,fill_value 补空格。

text 复制代码
   groupby 出来的样子              pivot_table 出来的样子

   category  city  amount         category     数据     编程
   数据      上海   105.0          city
   数据      北京   210.0          上海        105.0   264.7
   数据      广州   105.0          北京        210.0   385.6
   编程      上海   264.7          广州        105.0   122.0
   编程      北京   385.6
   编程      广州   122.0
   一个方向往下长                  两个方向铺开

加 margins=True 还能顺手算出合计行。

python 复制代码
>>> orders.pivot_table(index="city", values="qty", aggfunc="sum", margins=True)
      qty
city     
上海      7
北京     12
广州      4
All    23

透视表出来往往是宽表,很多时候还要变回长表,这就是 melt 的活。

python 复制代码
>>> wide = orders.pivot_table(index="city", columns="category", values="amount", aggfunc="sum", fill_value=0).round(1)
>>> wide.reset_index().melt(id_vars="city", var_name="类别", value_name="销售额").head(4)
  city  类别    销售额
0   上海  数据  105.0
1   北京  数据  210.0
2   广州  数据  105.0
3   上海  编程  264.7

2.5 表合并,merge 和 concat

merge 是数据库里的 join,concat 是简单的拼接。

text 复制代码
    left.merge(right, how=?)
    
    inner  只留两边都有的       outer  两边都留,缺的补 NaN
    ┌───┐  ┌───┐  ┌─────┐     ┌───┐  ┌───┐  ┌─────┐
    │ A │  │ B │  │ A∩B │     │ A │  │ B │  │ A∪B │
    └───┘  └───┘  └─────┘     └───┘  └───┘  └─────┘
    
    left   以左表为准           right  以右表为准
    ┌───┐  ┌───┐  ┌─────┐     ┌───┐  ┌───┐  ┌─────┐
    │ A │  │ B │  │ 全部A│     │ A │  │ B │  │ 全部B│
    └───┘  └───┘  └─────┘     └───┘  └───┘  └─────┘

给订单表补一列作者信息。

python 复制代码
>>> authors = pd.DataFrame({
...     "book": ["Python基础教程", "数据分析入门", "并发编程"],
...     "author": ["李三", "赵六", "周八"]})
>>> orders.merge(authors, on="book", how="left")[["book", "author"]].head(4)
         book author
0  Python基础教程     李三
1      网络编程实战    NaN
2      数据分析入门     赵六
3  Python基础教程     李三

左连接保留左表全部行,右表没有的补 NaN。这也是查数据缺口的常用手法。

python 复制代码
>>> merged = orders.merge(authors, on="book", how="left")
>>> merged.isna().sum().to_dict()
{'date': 0, 'book': 0, 'category': 0, 'city': 0, 'qty': 0, 'price': 0, 'amount': 0, 'author': 4}

两边列名不一样时用 left_on 和 right_on,想把索引当连接键用 left_index=True。

concat 不管键,直接把两张表接起来,axis=0 上下接,axis=1 左右接。

python 复制代码
>>> part1 = orders.head(2)
>>> part2 = orders.tail(2)
>>> len(pd.concat([part1, part2]))
4
>>> pd.concat([part1, part2], ignore_index=True).tail(2)
         date    book category city  qty  price  amount
2  2026-03-19  数据分析入门       数据   北京    1   52.5    52.5
3  2026-03-25   爬虫与反爬       编程   上海    3   39.9   119.7
>>> pd.concat([part1["qty"], part2["qty"]], axis=1).head(2)
   qty  qty
0  2.0  NaN
1  1.0  NaN

接完之后往往需要重新生成 index,ignore_index=True 就是为了这个。忘了加会出现两个都叫 0 的行标签,loc[0] 直接给你返回两行。

2.6 缺失值

真实数据一定缺东西。pandas 里缺值用 NaN 表示,浮点型的 nan。

python 复制代码
>>> s = pd.Series([1.0, None, 3.0, None, 5.0])
>>> s
0    1.0
1    NaN
2    3.0
3    NaN
4    5.0
dtype: float64
>>> s.isna().tolist()
[False, True, False, True, False]
>>> s.isna().sum()
np.int64(2)

判断缺失千万别用 ==。

python 复制代码
>>> (s == np.nan).tolist()
[False, False, False, False, False]
>>> s.dropna().tolist()
[1.0, 3.0, 5.0]
>>> [pd.isna(x) for x in [np.nan, None, pd.NA]]
[True, True, True]

NaN 有个特别的性格,它跟自己都不相等。所以 x == np.nan 永远 False,得用 isna()。

处理方式三选一,删掉、填固定值、填统计值。

python 复制代码
>>> s.fillna(0).tolist()
[1.0, 0.0, 3.0, 0.0, 5.0]
>>> s.fillna(s.mean()).tolist()
[1.0, 3.0, 3.0, 3.0, 5.0]
>>> s.ffill().tolist()
[1.0, 1.0, 3.0, 3.0, 5.0]
>>> s.interpolate().tolist()
[1.0, 2.0, 3.0, 4.0, 5.0]

ffill 用前一个有效值补,interpolate 线性插值。时间序列常用 ffill,比如某天的系统没上报,就用前一天的值。

二维表上通常按列处理。

python 复制代码
>>> gap = pd.DataFrame({"a": [1, None, 3], "b": [None, 2, 3]})
>>> gap
     a    b
0  1.0  NaN
1  NaN  2.0
2  3.0  3.0
>>> gap.dropna()
     a    b
2  3.0  3.0
>>> gap.fillna({"a": 0, "b": -1})
     a    b
0  1.0 -1.0
1  0.0  2.0
2  3.0  3.0
>>> gap.isna().sum().to_dict()
{'a': 1, 'b': 1}

dropna 默认只要含 NaN 就整行删掉。数据量大、缺失集中在某一两列的时候,这么删会损失大半张表,用 subset 指定列更稳。

python 复制代码
>>> gap.dropna(subset=["a"])
     a    b
0  1.0  NaN
2  3.0  3.0

2.7 字符串,就认准 .str

一列字符串的各种处理都在 .str 后面。

python 复制代码
>>> orders["book"].str.len().tolist()
[10, 6, 6, 10, 6, 5, 4, 6, 6, 4, 6, 5]
>>> orders["book"].str.startswith("Python").tolist()
[True, False, False, True, False, False, False, False, False, False, False, False]
>>> orders["book"].str.replace("Python", "Py").tolist()
['Py基础教程', '网络编程实战', '数据分析入门', 'Py基础教程', '数据分析入门', '爬虫与反爬', '并发编程', '数据分析入门', '网络编程实战', '并发编程', '数据分析入门', '爬虫与反爬']
>>> orders["book"].str.upper().str.replace(" ", "").tolist()
['PYTHON基础教程', '网络编程实战', '数据分析入门', 'PYTHON基础教程', '数据分析入门', '爬虫与反爬', '并发编程', '数据分析入门', '网络编程实战', '并发编程', '数据分析入门', '爬虫与反爬']

常用的是 contains、extract、split、strip。extract 带正则,跟第五章学过的 re 用法一致。

python 复制代码
>>> orders["book"].str.contains("编程|爬虫").tolist()
[False, True, False, False, False, True, True, False, True, True, False, True]
>>> orders["book"].str.extract(r"(\D+?)\d*$") if False else orders["book"].str.extract(r"^(.{2})")[0].tolist()
['Py', '网络', '数据', 'Py', '数据', '爬虫', '并发', '数据', '网络', '并发', '数据', '爬虫']

第二行取每本书名前两个字,纯粹演示 extract 的返回方式,它给出的是一个 DataFrame,每个捕获组占一列。

2.8 时间序列

日期列读进来如果是字符串,先转成 datetime,之后 .dt 就解锁了。

python 复制代码
>>> orders["date"] = pd.to_datetime(orders["date"])
>>> orders["date"].dt.month.tolist()
[1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3]
>>> orders["date"].dt.quarter.unique().tolist()
[1]
>>> orders["date"].dt.day_name().unique().tolist()
['Monday', 'Thursday', 'Saturday', 'Friday', 'Tuesday', 'Wednesday']
>>> (orders["date"].max() - orders["date"].min()).days
79

有了日期 index 就能按时间频次重采样,日常做时间序列常常就靠这一个动作。

python 复制代码
>>> monthly = orders.set_index("date").resample("ME")["amount"].sum().round(2)
>>> monthly
date
2026-01-31    360.5
2026-02-28    446.6
2026-03-31    385.2
Freq: ME, Name: amount, dtype: float64
>>> monthly.index.tolist() if False else [d.strftime("%Y-%m") for d in monthly.index]
['2026-01', '2026-02', '2026-03']

ME 表示月末,Month End 的意思。老教程里写的 "M" 现在会报错。

python 复制代码
>>> orders.set_index("date").resample("M")["amount"].sum()
ValueError: Invalid frequency: M. Failed to parse with error message: ValueError("'M' is no longer supported for offsets. Please use 'ME' instead.")

抛的是 ValueError: Invalid frequency: M。pandas 2.2 开始把 M 换成 ME,季度 -Q 换成 QE,$ 年份 -A 换成 YE。照老教程抄到这里卡住的人不少。

生成连续日期也方便。

python 复制代码
>>> pd.date_range("2026-01-01", periods=3).tolist()
[Timestamp('2026-01-01 00:00:00'), Timestamp('2026-01-02 00:00:00'), Timestamp('2026-01-03 00:00:00')]
>>> pd.date_range("2026-01-01", "2026-01-07", freq="D").tolist()
[Timestamp('2026-01-01 00:00:00'), Timestamp('2026-01-02 00:00:00'), Timestamp('2026-01-03 00:00:00'), Timestamp('2026-01-04 00:00:00'), Timestamp('2026-01-05 00:00:00'), Timestamp('2026-01-06 00:00:00'), Timestamp('2026-01-07 00:00:00')]
>>> pd.to_datetime("2026-01-15").strftime("%Y年%m月")
'2026年01月'

2.9 分箱,把连续值切成段

年龄分段、金额分档,都用 cut 和 qcut。

python 复制代码
>>> pd.cut(orders["amount"], bins=[0, 50, 100, 500], labels=["低", "中", "高"]).tolist()
['中', '中', '高', '低', '高', '高', '中', '高', '高', '中', '中', '高']
>>> pd.qcut(orders["amount"], 3, labels=["低", "中", "高"]).tolist()
['中', '低', '高', '低', '中', '高', '低', '中', '高', '低', '低', '高']
>>> pd.cut(orders["amount"], 3).value_counts()
amount
(44.885, 83.2]    5
(83.2, 121.4]     4
(121.4, 159.6]    3
Name: count, dtype: int64

cut 按你给的边界切,qcut 按分位数切,保证每箱数量尽量一样。做用户分层一般用 qcut,因为你要的是"前百分之二十"这种概念。

写完直接分组就能看出各段的贡献。

python 复制代码
>>> orders.assign(档位=pd.cut(orders["amount"], bins=[0, 50, 100, 500], labels=["低", "中", "高"])).groupby("档位", observed=False)["amount"].agg(["count", "sum"]).round(2)
    count    sum
档位              
低       1   45.0
中       5  364.5
高       6  782.8

2.10 apply,能不用就不用

apply 是万能胶带,什么都能糊,也是最慢的选择。

python 复制代码
>>> orders["amount"].apply(lambda x: round(x * 0.9, 1)).tolist()
[81.0, 61.2, 141.8, 40.5, 94.5, 143.6, 69.3, 94.5, 122.4, 69.3, 47.2, 107.7]
>>> orders.apply(lambda row: row["qty"] * row["price"], axis=1).tolist()[:4]
[90.0, 68.0, 157.5, 45.0]

逐行 apply 的时候,pandas 要在 Python 层面把每一行包成 Series 递给你,数据量一大就慢得难受。能写成列运算的,一定写成列运算。

python 复制代码
>>> slow = orders.apply(lambda row: row["qty"] * row["price"], axis=1)
>>> fast = orders["qty"] * orders["price"]
>>> slow.round(2).equals(fast.round(2))
True

两者结果一致,但后者是 C 级别的一次性算完。

这事的力度有多大,我在二十万行上量了一下。

python 复制代码
import time

import pandas as pd

size = 200000
df_big = pd.DataFrame({"x": range(size)})

t0 = time.perf_counter()
slow = df_big.apply(lambda row: row["x"] ** 2, axis=1)
t1 = time.perf_counter()
fast = df_big["x"] ** 2
t2 = time.perf_counter()

print("两种算法结果一致:", bool((slow == fast).all()))
print("列运算比逐行 apply 快:", (t2 - t1) < (t1 - t0))
text 复制代码
两种算法结果一致: True
列运算比逐行 apply 快: True

我这台机器上快了二十多倍,你的数字会不一样,量级肯定在同一档。逐行 apply 的开销主要花在把每一行包成 Series 上,数据量越大越吃亏。这就是"能不写就不写"的底气。

2.11 滚动窗口

股票里的五日均线、同比环比,都是窗口计算。

python 复制代码
>>> orders["amount"].rolling(3).mean().round(2).tolist()
[nan, nan, 105.17, 90.17, 102.5, 103.2, 113.87, 113.87, 106.0, 106.0, 88.5, 83.07]
>>> orders["amount"].expanding().mean().round(2).tolist()
[90.0, 79.0, 105.17, 90.12, 93.1, 104.18, 100.3, 100.89, 104.79, 102.01, 97.51, 99.36]
>>> orders["amount"].cumsum().round(1).tolist()
[90.0, 158.0, 315.5, 360.5, 465.5, 625.1, 702.1, 807.1, 943.1, 1020.1, 1072.6, 1192.3]
>>> orders["amount"].pct_change().round(3).tolist()
[nan, -0.244, 1.316, -0.714, 1.333, 0.52, -0.518, 0.364, 0.295, -0.434, -0.318, 1.28]

rolling(3) 是每次取最近三条,expanding() 是从头累积到当前,cumsum 是累加,pct_change 是跟前一条比变化率。前几个结果里出现 NaN,是窗口没填满。

2.12 存回去

算完的结果要给别人看,最常见的是 CSV 和 Excel。

写法就一行,orders.to_csv("orders_out.csv", index=False)。这里用临时文件演示,免得在你当前目录里留下垃圾。

python 复制代码
>>> import tempfile, os
>>> tmpdir = tempfile.mkdtemp()
>>> path = os.path.join(tmpdir, "orders.csv")
>>> orders.to_csv(path, index=False)
>>> open(path, encoding="utf-8").read().split("\n")[0]
'date,book,category,city,qty,price,amount'
>>> len(pd.read_csv(path))
12

index=False 一定要写。不加的话会把 0、1、2 这列索引也写进文件,别人读回来就多一列无名项。

Excel 需要 openpyxl,写多个 sheet 也不难。

python 复制代码
>>> xlsx = os.path.join(tmpdir, "report.xlsx")
>>> with pd.ExcelWriter(xlsx, engine="openpyxl") as writer:
...     orders.head(3).to_excel(writer, sheet_name="明细", index=False)
...     orders.groupby("city")["amount"].sum().round(2).to_excel(writer, sheet_name="城市")
>>> with pd.ExcelFile(xlsx) as workbook:
...     print(workbook.sheet_names)
['明细', '城市']
>>> pd.read_excel(xlsx, sheet_name="城市").to_dict("records")
[{'city': '上海', 'amount': 369.7}, {'city': '北京', 'amount': 595.6}, {'city': '广州', 'amount': 227.0}]
>>> os.remove(path)
>>> os.remove(xlsx)
>>> os.rmdir(tmpdir)

两处 with 都不能省。ExcelWriter 出了 with 才真正落盘,早期很多文章的写法是把它 assign 给变量,那样会漏掉保存这一步。ExcelFile 用完不关,Windows 上这文件就删不掉了,删的时候会得到 PermissionError: 另一个程序正在使用此文件。我在自己机器上被这个卡过好一会儿。


3 可视化数据分析图表

数字放进报告里没人看,画成图一眼就懂。这一节讲 matplotlib,Python 里最老资格的画图库。

3.1 第一个坑,中文字变方块

Linux 服务器和 macOS 上第一个拦路虎通常是中文字体,Windows 上千万别以为万事大吉,默认的 DejaVu 字体里压根没有汉字。

先看看你机器上有没有能用的中文字体。

python 复制代码
>>> import matplotlib
>>> import matplotlib.pyplot as plt
>>> import matplotlib.font_manager as fm
>>> fonts = {f.name for f in fm.fontManager.ttflist}
>>> len(fonts)
264
>>> "Microsoft YaHei" in fonts
True

我这里是 264 个字体,里面确实有微软雅黑。有字体不等于会用,matplotlib 默认那套排序里排不到中文字体,得手动指定。

这一段就来验证"不指定会怎样"。

python 复制代码
import os
import tempfile
import warnings

import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt


def draw(path):
    fig, ax = plt.subplots(figsize=(4, 2))
    ax.bar(["北京", "上海", "广州"], [595.6, 369.7, 227.0])
    ax.set_title("城市销售额")
    fig.savefig(path, dpi=100)
    plt.close(fig)


tmp = tempfile.mkdtemp()

with warnings.catch_warnings(record=True) as caught:
    warnings.simplefilter("always")
    draw(os.path.join(tmp, "before.png"))
print("用默认字体画图,警告条数:", len(caught))
print("第一条警告:", str(caught[0].message).split("missing")[0].strip())

matplotlib.rcParams["font.sans-serif"] = ["Microsoft YaHei", "SimHei", "DejaVu Sans"]
matplotlib.rcParams["axes.unicode_minus"] = False

with warnings.catch_warnings(record=True) as caught2:
    warnings.simplefilter("always")
    draw(os.path.join(tmp, "after.png"))
print("指定字体之后,警告条数:", len(caught2))
text 复制代码
用默认字体画图,警告条数: 11
第一条警告: Glyph 22478 (\N{CJK UNIFIED IDEOGRAPH-57CE})
指定字体之后,警告条数: 0

十一条警告,图里每个汉字一条,全都是 Glyph ... missing from font(s) DejaVu Sans 这个句式。图照样画得出来,只是里面的中文全部变成空心方块。关键是它不报错也不中断,等你把图打开才发现,非常坑。

两行配置解决问题。

python 复制代码
>>> matplotlib.rcParams["font.sans-serif"] = ["Microsoft YaHei", "SimHei", "Arial Unicode MS", "DejaVu Sans"]
>>> matplotlib.rcParams["axes.unicode_minus"] = False

第一行的列表是有序的,matplotlib 从头往后找,找到第一个能用的就停。中文环境里写这一串基本通吃 Windows 和 macOS。第二行是让负号正常显示,不加的话负数会变成方块,这个坑跟中文本身没关系。

写在每个脚本的开头有点烦,想永久生效就把它们丢进 matplotlibrc 配置文件里。

另外注意第一行那个 matplotlib.use("Agg")。Agg 是不弹窗口的后端,只在文件里画图。服务器上必须这么写,本地想弹出窗口就别加,用默认的。

3.2 一张图的四个零件

matplotlib 的术语值得花两分钟看清,不然后面的代码全是黑话。

text 复制代码
   Figure 画布,整张图的容器
   ┌──────────────────────────────────────────────────┐
   │  Axes 子图,真正画东西的那块矩形                   │
   │  ┌────────────────────────────────────────────┐  │
   │  │                  title 标题                 │  │
   │  │   ylabel                                   │  │
   │  │    ↑                                       │  │
   │  │    │  ▄▄                                   │  │
   │  │    │  ██  ▄▄                         ▫ 图例 │  │
   │  │    │  ██  ██  ▄▄                            │  │
   │  │    └────────────────────────────►  xlabel   │  │
   │  │        xticks 刻度                          │  │
   │  └────────────────────────────────────────────┘  │
   │  一张 Figure 里可以放多个 Axes                    │
   └──────────────────────────────────────────────────┘

Figure 是纸,Axes 是纸上的一块画。这两个词最容易混,plt.subplots() 返回的就是这两个东西。

对应的 API。

想改什么 怎么写
建图和子图 fig, ax = plt.subplots(figsize=(6, 4))
标题 ax.set_title("月度销售额")
轴标签 ax.set_xlabel("月份") ax.set_ylabel("元")
刻度文字 ax.set_xticks([0, 1, 2], ["1月", "2月", "3月"])
图例 画图时加 label="销售额",再调 ax.legend()
网格线 ax.grid(alpha=0.3)
坐标范围 ax.set_ylim(0, 100)
存文件 fig.savefig("a.png", dpi=150)
拥挤了自动调整 fig.tight_layout()

最后那个 tight_layout() 建议养成习惯,坐标轴文字被裁掉基本都是因为没调它。

3.3 这一章的图怎么看

写书的会把图印在旁边。我这章要发到几个技术平台上,它们的编辑器都不支持插图,贴了也会被图床失效坑掉。

所以我在这一节给你一套终端画图的小工具。它俩用处是这样:matplotlib 出来的 PNG 给老板看,终端版本给自己看,顺便还能塞进服务器日志里。

python 复制代码
# 三个纯文本画图的小工具,存成 mini_chart.py
import unicodedata


def show_width(text):
    """字符串占几个终端格子,一个汉字算两格。"""
    return sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in text)


def bar_chart(data, plot_width=30, char="█"):
    """横向柱状图,data 是 名称 -> 数值 的字典。"""
    label_width = max(show_width(k) for k in data)
    top = max(data.values())
    lines = []
    for key, value in data.items():
        pad = " " * (label_width - show_width(key))
        bar = char * int(round(value / top * plot_width))
        lines.append("%s%s │%s %.1f" % (key, pad, bar, value))
    return "\n".join(lines)


def line_chart(xs, ys, plot_width=40, plot_height=8, mark="●"):
    """折线图,横轴 xs,纵轴 ys。"""
    lo, hi = min(ys), max(ys)
    span = (hi - lo) or 1
    canvas = [[" "] * plot_width for _ in range(plot_height)]
    pts = [(i / (len(xs) - 1) * (plot_width - 1),
            plot_height - 1 - (y - lo) / span * (plot_height - 1))
           for i, y in enumerate(ys)]
    for i in range(len(pts) - 1):
        x0, y0 = pts[i]
        x1, y1 = pts[i + 1]
        steps = int(max(abs(x1 - x0), abs(y1 - y0)) * 2) + 1
        for k in range(steps + 1):
            t = k / steps
            cx = int(round(x0 + (x1 - x0) * t))
            cy = int(round(y0 + (y1 - y0) * t))
            if canvas[cy][cx] == " ":
                canvas[cy][cx] = "·"
    for x, y in pts:
        canvas[int(round(y))][int(round(x))] = mark
    rows = ["%7.1f │%s" % (hi - (hi - lo) * r / (plot_height - 1), "".join(canvas[r]))
            for r in range(plot_height)]
    rows.append("        └" + "─" * plot_width)
    rows.append("         %s%s%s" % (xs[0], " " * (plot_width - len(str(xs[0])) - len(str(xs[-1]))), xs[-1]))
    return "\n".join(rows)


FILLS = ["█", "▓", "▒", "░"]


def ratio_bar(data, plot_width=36):
    """占比图,用一条堆叠起来的条表示。"""
    amounts = sum(data.values()) or 1
    bar = "".join(FILLS[i % 4] * int(round(v / amounts * plot_width))
                  for i, v in enumerate(data.values()))
    legends = ["%s %s  %5.1f%%" % (FILLS[i % 4] * 2, key, value / amounts * 100)
               for i, (key, value) in enumerate(data.items())]
    return bar + "\n" + "\n".join(legends)


city = {"北京": 10058.7, "上海": 7033.6, "广州": 4997.8, "成都": 3997.0, "未知": 496.0}
month = [7990.9, 9810.4, 8781.8]
print(bar_chart(city))
print()
print(line_chart(["1月", "2月", "3月"], month))
print()
print(ratio_bar({"编程": 23433.1, "数据": 3150.0}))
text 复制代码
北京 │██████████████████████████████ 10058.7
上海 │█████████████████████ 7033.6
广州 │███████████████ 4997.8
成都 │████████████ 3997.0
未知 │█ 496.0

 9810.4 │                   ·●··                 
 9550.5 │                ···   ······            
 9290.5 │             ···           ······       
 9030.6 │          ···                   ······  
 8770.7 │       ····                          ··●
 8510.8 │    ····                                
 8250.8 │ ····                                   
 7990.9 │●·                                      
        └────────────────────────────────────────
         1月                                    3月

████████████████████████████████▓▓▓▓
██ 编程   88.2%
▓▓ 数据   11.8%

三个都不难,核心思想都一样,把数值按比例折算成字符个数。

bar_chart 里那个 show_width 是关键。中文字符在等宽字体里占两格,len("北京") 却是 2,按字符数补空格就会歪。前面 1.10 节给 pandas 开 east_asian_width 是同一个道理。

真实项目里我会直接把这三个函数复制进分析脚本,配合 logging 输出。半夜跑批任务失败了,看一眼日志里的柱状图就知道哪天数据断了。

3.4 折线图,看趋势

时间趋势用折线,这是最没有争议的选择。

python 复制代码
import os
import tempfile

import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt

tmp = tempfile.mkdtemp()
monthly = {"1月": 7990.9, "2月": 9810.4, "3月": 8781.8}

fig, ax = plt.subplots(figsize=(6, 3.5), dpi=100)
ax.plot(list(monthly.keys()), list(monthly.values()),
        marker="o", linewidth=2, color="#E8863B", label="销售额")
ax.set_title("旧书铺一季度月度销售额")
ax.set_xlabel("月份")
ax.set_ylabel("元")
ax.grid(axis="y", alpha=0.3)
ax.legend()
fig.tight_layout()
fig.savefig(os.path.join(tmp, "monthly.png"))
plt.close(fig)

print("画了一条线:", len(ax.lines))
print("线上的点:", ax.lines[0].get_ydata().tolist())
print("图例文字:", ax.get_legend().get_texts()[0].get_text())
print("文件存下来了:", os.path.exists(os.path.join(tmp, "monthly.png")))
text 复制代码
画了一条线: 1
线上的点: [7990.9, 9810.4, 8781.8]
图例文字: 销售额
文件存下来了: True

同一个数据用终端工具看,就是 3.3 里那张折线图。趋势都一样,二月是高点。

几条常用美化参数放在这里,marker 换标记形状,linestyle 换虚线实线,color 支持十六进制色值,alpha 调透明度。多画几条线就多调几次 ax.plot,每条给一个 label。

3.5 柱状图,比大小

比谁多谁少,柱状图比折线合适,尤其是分类之间没有顺序的时候。

python 复制代码
import os
import tempfile

import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt

tmp = tempfile.mkdtemp()
city = {"北京": 10058.7, "上海": 7033.6, "广州": 4997.8, "成都": 3997.0}

fig, ax = plt.subplots(figsize=(6, 3.5), dpi=100)
bars = ax.bar(list(city.keys()), list(city.values()), color="#4C72B0")
ax.set_title("各城市销售额")
ax.set_ylabel("元")
for rect in bars:
    height = rect.get_height()
    ax.text(rect.get_x() + rect.get_width() / 2, height + 150,
            "%.0f" % height, ha="center", fontsize=9)
ax.set_ylim(0, max(city.values()) * 1.15)
fig.tight_layout()
fig.savefig(os.path.join(tmp, "city.png"))
plt.close(fig)

print("柱子数量:", len(bars))
print("柱子的高度:", [float(round(b.get_height(), 1)) for b in bars])
print("柱子的位置:", [float(b.get_x() + b.get_width() / 2) for b in bars])
text 复制代码
柱子数量: 4
柱子的高度: [10058.7, 7033.6, 4997.8, 3997.0]
柱子的位置: [0.0, 1.0, 2.0, 3.0]

那段 ax.text 是给每根柱子顶上加数字。不放 set_ylim 的话数字会被顶边裁掉,这是加了标签之后的通病。

分类名字长的时候用横向的 ax.barh,读起来更舒服。

python 复制代码
>>> city = {"北京": 10058.7, "上海": 7033.6, "广州": 4997.8, "成都": 3997.0}
>>> fig2, ax2 = plt.subplots(figsize=(6, 3))
>>> hbars = ax2.barh(list(city.keys()), list(city.values()))
>>> ax2.set_title("各城市销售额(横向)")
Text(0.5, 1.0, '各城市销售额(横向)')
>>> len(hbars)
4
>>> plt.close(fig2)

要对比两个维度,把两组柱子并排放。x 位置手动错开一点就行。

python 复制代码
>>> import numpy as np
>>> city = {"北京": 10058.7, "上海": 7033.6, "广州": 4997.8, "成都": 3997.0}
>>> xs = np.arange(len(city))
>>> fig3, ax3 = plt.subplots(figsize=(6.5, 3.5))
>>> b1 = ax3.bar(xs - 0.2, [10058.7, 7033.6, 4997.8, 3997.0], 0.4, label="一季度")
>>> b2 = ax3.bar(xs + 0.2, [9200.0, 6600.0, 5100.0, 4400.0], 0.4, label="二季度")
>>> ticks = ax3.set_xticks(xs, list(city.keys()))
>>> legend = ax3.legend()
>>> len(ax3.patches)
8
>>> plt.close(fig3)

这里 np.arange 出场了,用 numpy 算柱子的 x 坐标比手写列表方便。这就是 pandas、matplotlib 底下都站着 numpy 的原因。

3.6 饼图,看占比

饼图这些年口碑不太好,人的眼睛对扇形面积不敏感。但只有两三个分类、要强调"某块占了大头"的时候,它依然是最好懂的。

python 复制代码
import os
import tempfile

import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt

tmp = tempfile.mkdtemp()
category = {"编程": 23433.1, "数据": 3150.0}

matplotlib.rcParams["font.sans-serif"] = ["Microsoft YaHei", "SimHei", "DejaVu Sans"]
matplotlib.rcParams["axes.unicode_minus"] = False

fig, ax = plt.subplots(figsize=(4.5, 4), dpi=100)
wedges, texts, autos = ax.pie(
    list(category.values()),
    labels=list(category.keys()),
    autopct="%1.1f%%",
    startangle=90,
    colors=["#4C72B0", "#E8863B"])
ax.set_title("分类占比")
fig.tight_layout()
fig.savefig(os.path.join(tmp, "category.png"))
plt.close(fig)

print("扇区数:", len(wedges))
print("百分比文字:", [t.get_text() for t in autos])
print("标签:", [t.get_text() for t in texts])
text 复制代码
扇区数: 2
百分比文字: ['88.2%', '11.8%']
标签: ['编程', '数据']

autopct 负责显示百分比,startangle=90 让第一块从正上方开始。pie 返回三个东西,扇形对象、标签文字、百分比文字,只取要用的那部分就行。

终端版本就是 3.3 那条形状奇怪的堆叠条,ratio_bar 干的活。

3.7 散点图,找关系

两个数值之间有没有关系,散点图最能说明问题。价格和销量是不是负相关,一眼看得出。

python 复制代码
import os
import tempfile

import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np

tmp = tempfile.mkdtemp()
price = [45.0, 68.0, 52.5, 39.9, 77.0, 61.0, 45.0, 68.0, 52.5, 39.9, 77.0, 61.0]
qty = [2, 1, 3, 4, 1, 2, 3, 2, 2, 3, 1, 2]

matplotlib.rcParams["font.sans-serif"] = ["Microsoft YaHei", "SimHei", "DejaVu Sans"]
matplotlib.rcParams["axes.unicode_minus"] = False

fig, ax = plt.subplots(figsize=(5.5, 3.8), dpi=100)
ax.scatter(price, qty, s=60, alpha=0.7, color="#55A868")
ax.set_title("单价与销量的关系")
ax.set_xlabel("单价(元)")
ax.set_ylabel("销量(本)")
ax.grid(alpha=0.3)
fig.tight_layout()
fig.savefig(os.path.join(tmp, "scatter.png"))
plt.close(fig)

print("画了 %d 个点" % len(ax.collections[0].get_offsets()))
print("相关系数 %.4f" % np.corrcoef(price, qty)[0][1])
print("负相关:", np.corrcoef(price, qty)[0][1] < 0)
text 复制代码
画了 12 个点
相关系数 -0.8606
负相关: True

点从左往右往下走,说明单价越高卖得越少,符合直觉。

要量化这个关系,用相关系数。

python 复制代码
>>> price = [45.0, 68.0, 52.5, 39.9, 77.0, 61.0, 45.0, 68.0, 52.5, 39.9, 77.0, 61.0]
>>> qty = [2, 1, 3, 4, 1, 2, 3, 2, 2, 3, 1, 2]
>>> pd.Series(price).corr(pd.Series(qty)).round(4)
np.float64(-0.8606)
>>> pd.DataFrame({"price": price, "qty": qty}).corr().round(4)
        price     qty
price  1.0000 -0.8606
qty   -0.8606  1.0000

相关系数在 -1 到 1 之间。接近 -1 是强负相关,接近 0 是没关系。这个数只反映线性关系,弯的关系它看不出来,所以画图这一步不能省。

3.8 直方图,看分布

想知道大部分订单金额落在哪个区间,用直方图。它跟柱状图长得像,含义完全不同,柱状图的横轴是分类,直方图的横轴是连续数值被切成的一段段。

python 复制代码
import os
import tempfile

import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np

tmp = tempfile.mkdtemp()
rng = np.random.default_rng(7)
amounts = np.round(rng.normal(120, 40, 300).clip(20, None), 2)

matplotlib.rcParams["font.sans-serif"] = ["Microsoft YaHei", "SimHei", "DejaVu Sans"]
matplotlib.rcParams["axes.unicode_minus"] = False

fig, ax = plt.subplots(figsize=(5.5, 3.5), dpi=100)
counts, bins, patches = ax.hist(amounts, bins=10, color="#C44E52", edgecolor="white")
ax.set_title("订单金额分布")
ax.set_xlabel("金额(元)")
ax.set_ylabel("订单数")
fig.tight_layout()
fig.savefig(os.path.join(tmp, "hist.png"))
plt.close(fig)

print("分了 %d 个箱子" % len(patches))
print("每箱的数量:", counts.astype(int).tolist())
print("合计:", int(counts.sum()))
print("最集中在 %.0f 到 %.0f 元" % (bins[counts.argmax()], bins[counts.argmax() + 1]))
text 复制代码
分了 10 个箱子
每箱的数量: [7, 12, 28, 38, 58, 71, 38, 29, 15, 4]
合计: 300
最集中在 115 到 134 元

bins 控制分几段,太少看不出形状,太多次要不稳。两三百条数据用 10 到 20 段比较合适。

3.9 一页多图

做报告往往是好几张图拼成一页。

python 复制代码
import os
import tempfile

import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt

tmp = tempfile.mkdtemp()
matplotlib.rcParams["font.sans-serif"] = ["Microsoft YaHei", "SimHei", "DejaVu Sans"]
matplotlib.rcParams["axes.unicode_minus"] = False

monthly = {"1月": 7990.9, "2月": 9810.4, "3月": 8781.8}
city = {"北京": 10058.7, "上海": 7033.6, "广州": 4997.8, "成都": 3997.0}

fig, axes = plt.subplots(2, 2, figsize=(9, 6), dpi=100)
axes[0][0].plot(list(monthly.keys()), list(monthly.values()), marker="o", color="#E8863B")
axes[0][0].set_title("月度趋势")
axes[0][1].bar(list(city.keys()), list(city.values()), color="#4C72B0")
axes[0][1].set_title("城市对比")
axes[1][0].pie(list(city.values()), labels=list(city.keys()), autopct="%1.0f%%")
axes[1][0].set_title("城市占比")
axes[1][1].hist([45, 68, 52.5, 39.9, 77, 61, 45, 68, 52.5, 39.9, 77, 61],
                bins=5, color="#C44E52", edgecolor="white")
axes[1][1].set_title("价格分布")
for ax in axes.flat:
    ax.set_ylabel("")
fig.suptitle("旧书铺一季度看板", fontsize=14)
fig.tight_layout()
fig.savefig(os.path.join(tmp, "dashboard.png"))
plt.close(fig)

print("子图排布:", len(axes), "x", len(axes[0]))
print("子图总个数:", len(axes.flat))
print("总标题:", fig._suptitle.get_text())
text 复制代码
子图排布: 2 x 2
子图总个数: 4
总标题: 旧书铺一季度看板

plt.subplots(2, 2) 返回一个二维数组,axes[0][0] 是左上角。遍历全部子图用 axes.flat。fig.suptitle 是整页的总标题,跟单个子图的 set_title 不一样。

3.10 pandas 自带的 plot

赶时间的话可以不出动 matplotlib,DataFrame 自带 .plot(),它其实是 matplotlib 的快捷入口。

python 复制代码
import io

import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import pandas as pd

CSV = ("date,book,qty,price\n"
       "2026-01-05,A,2,45.0\n"
       "2026-01-08,B,1,68.0\n"
       "2026-01-12,C,3,52.5\n"
       "2026-01-15,A,1,45.0\n")
df = pd.read_csv(io.StringIO(CSV))
df["amount"] = df["qty"] * df["price"]

ax = df.plot(x="date", y="amount", kind="line", marker="o", legend=False)
print("返回的还是 Axes 对象:", type(ax).__name__)
print("画了几条线:", len(ax.lines))
print("横轴是:", ax.get_xlabel(), "| 数据点:", ax.lines[0].get_ydata().tolist())
plt.close(ax.get_figure())

ax2 = df.groupby("book")["amount"].sum().plot(kind="bar", legend=False)
print("柱子数:", len(ax2.patches))
plt.close(ax2.get_figure())

ax3 = df.plot(kind="scatter", x="price", y="qty")
print("散点图坐标:", ax3.collections[0].get_offsets().tolist())
plt.close(ax3.get_figure())
text 复制代码
返回的还是 Axes 对象: Axes
画了几条线: 1
横轴是: date | 数据点: [90.0, 68.0, 157.5, 45.0]
柱子数: 3
散点图坐标: [[45.0, 2.0], [68.0, 1.0], [52.5, 3.0], [45.0, 1.0]]

kind 可以是 line、bar、barh、hist、box、kde、area、pie、scatter、hexbin。做探索分析的时候够用了,精细排版还是得回到 matplotlib 的对象写法。

3.11 怎么选图

写报告之前对着这张表挑一下,比随手画强。

你想说明什么 用什么图 例子
随时间的变化 折线图 月度销售额
分类之间比大小 柱状图 各城市对比
部分占整体的比例 饼图、堆叠柱 分类占比
两个数值的关系 散点图 单价与销量
数据落在哪些区间 直方图 订单金额分布
分布的离散程度 箱线图 各城市客单价对比
多张图一起看 subplots 季度看板

反过来也有几条别踩的线。分类超过七八个别用饼图,扇形多得看不清。时间序列别用柱状图,趋势会被打断。数值差距很小的时候别指望画图能看出区别,直接报数字。


4 图解数组计算模块 NumPy

前面三节一直在用 pandas,这节往下钻一层,看它的地基。

4.1 ndarray 是什么

ndarray 是一个多维同类型数组。听起来跟列表差不多,差别在这三条。

text 复制代码
   Python list                        numpy ndarray

   ┌────┬────┬────┬────┐              ┌────┬────┬────┬────┐
   │ ●  │ ●  │ ●  │ ●  │ ── 指针       │ 45 │ 68 │ 52 │ 39 │ ── 数据本身
   └─┬──┴─┬──┴─┬──┴─┬──┘              └────┴────┴────┴────┘
     │    │    │    │                  一块连续内存,类型统一
     ▼    ▼    ▼    ▼
   PyObj PyObj PyObj PyObj
   每个 PyObj 带着类型信息、引用计数,占 28 字节起
   实际数字散落在内存各处

   缺点 慢、占地方                     优点 快、省地方
   优点 什么都能往里放                 缺点 只能放一种类型
python 复制代码
>>> import numpy as np
>>> lst = list(range(100000))
>>> arr = np.arange(100000)
>>> arr.nbytes
800000
>>> arr.itemsize
8
>>> arr.dtype
dtype('int64')

同样十万个整数,numpy 这边只占 800000 字节,也就是每个 8 字节。list 装的是 Python 对象,每个 int 对象二十八字节起步,再加上列表本身的指针。差距大概二十倍往上。

第二条,类型统一。第三条才是关键,批量运算不用写循环,这叫向量化。

4.2 造数组

python 复制代码
>>> np.array([1, 2, 3])
array([1, 2, 3])
>>> np.array([[1, 2, 3], [4, 5, 6]])
array([[1, 2, 3],
       [4, 5, 6]])
>>> np.arange(10)
array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
>>> np.arange(0, 10, 2)
array([0, 2, 4, 6, 8])
>>> np.linspace(0, 1, 5)
array([0.  , 0.25, 0.5 , 0.75, 1.  ])
>>> np.zeros((2, 3))
array([[0., 0., 0.],
       [0., 0., 0.]])
>>> np.ones(3, dtype=int)
array([1, 1, 1])
>>> np.full((2, 2), 7)
array([[7, 7],
       [7, 7]])
>>> np.eye(3)
array([[1., 0., 0.],
       [0., 1., 0.],
       [0., 0., 1.]])

arange 像 range 但产出的是数组,linspace 是按份数均分,zeros、ones、full、eye分别是全零、全一、全某值、单位阵。

一维到二维这样来的。

python 复制代码
>>> np.arange(12).reshape(3, 4)
array([[ 0,  1,  2,  3],
       [ 4,  5,  6,  7],
       [ 8,  9, 10, 11]])
>>> np.arange(12).reshape(3, -1).shape
(3, 4)
>>> np.arange(12).reshape(3, 4).ravel()
array([ 0,  1,  2,  3,  4,  5,  6,  7,  8,  9, 10, 11])
>>> np.arange(12).reshape(3, 4).T
array([[ 0,  4,  8],
       [ 1,  5,  9],
       [ 2,  6, 10],
       [ 3,  7, 11]])

reshape 里写 -1 表示这一维你自己算,很常用。T 是转置,行列互换。

4.3 dtype,数组的性格

python 复制代码
>>> np.array([1, 2, 3]).dtype
dtype('int64')
>>> np.array([1.0, 2.0]).dtype
dtype('float64')
>>> np.array([1, 2.0]).dtype
dtype('float64')
>>> np.array(["a", "b"]).dtype
dtype('<U1')
>>> np.array([1, 2, 3], dtype="int8").dtype
dtype('int8')
>>> np.array([1.9, 2.5]).astype(int)
array([1, 2])

整数和浮点数混着放,结果会被提升成浮点数。想省内存就明确指定 dtype,int8 只占一个字节,放 0 到 127 的够用的小整数正好。

类型装不下会怎么样,得看你是造的时候给大了,还是后来改小了。

python 复制代码
>>> np.array([200, 300], dtype="int8")
OverflowError: Python integer 200 out of bounds for int8
>>> np.array([200, 300], dtype="int64").astype("int8")
array([-56,  44], dtype=int8)

两种情况结果完全相反。构造的时候数值超出范围,numpy 直接抛 OverflowError,算是把你拦住了。用 astype 往小了转的时候它一声不吭,200 绕回 -56。后面这种才阴险,数据已经不对了,程序还跑得好好的。

网上说的"溢出静默变负数"对一半,astype 那条路径确实是这样,但直接构造会先报错。

4.4 向量化,就是不用写循环

python 复制代码
>>> x = np.array([1, 2, 3, 4])
>>> y = np.array([10, 20, 30, 40])
>>> x + y
array([11, 22, 33, 44])
>>> x * y
array([ 10,  40,  90, 160])
>>> y / x
array([10., 10., 10., 10.])
>>> x ** 2
array([ 1,  4,  9, 16])
>>> x + 100
array([101, 102, 103, 104])

这些符号会被逐个应用到每个元素上,x + 100 里那个 100 会被加到每个元素上,这叫广播,后面细说。

快多少,实测一下。

python 复制代码
import time

import numpy as np

size = 200000
data = list(range(1, size + 1))
arr = np.array(data)

t0 = time.perf_counter()
loop_result = sum(v * v for v in data)
t1 = time.perf_counter()
vector_result = (arr * arr).sum()
t2 = time.perf_counter()

print("循环算出来:", loop_result)
print("向量算出来:", vector_result)
print("两个结果一致:", loop_result == vector_result)
print("比 Python 循环快:", t2 - t1 < t1 - t0)
text 复制代码
循环算出来: 2666686666700000
向量算出来: 2666686666700000
两个结果一致: True
比 Python 循环快: True

我这台机器上 numpy 快二十多倍,你的数字会不一样,量级肯定在同一档。数据量越大优势越明显,因为循环的解释开销是固定的。

这也是 pandas 快的根本原因。前面写的 orders["qty"] * orders["price"] 内部就是把两列转成 numpy 数组相乘,全程没有 Python 层的循环。

4.5 索引和切片

一维数组跟列表一个用法。

python 复制代码
>>> a = np.array([10, 20, 30, 40, 50])
>>> a[0]
np.int64(10)
>>> a[-1]
np.int64(50)
>>> a[1:4]
array([20, 30, 40])
>>> a[::2]
array([10, 30, 50])

二维就要用逗号分隔。

python 复制代码
>>> b = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]])
>>> b[0]
array([1, 2, 3])
>>> b[0, 1]
np.int64(2)
>>> b[0][1]
np.int64(2)
>>> b[1:, :2]
array([[4, 5],
       [7, 8]])
>>> b[:, 1]
array([2, 5, 8])

b[0, 1] 和 b[0][1] 结果一样,前者一次定位,后者先取出第一行再取一号位,前者更快也更清楚。

布尔索引跟 pandas 里那个思路一模一样,pandas 的做法是从这里搬过去的。

python 复制代码
>>> b > 5
array([[False, False, False],
       [False, False,  True],
       [ True,  True,  True]])
>>> b[b > 5]
array([6, 7, 8, 9])
>>> b[b % 2 == 0]
array([2, 4, 6, 8])
>>> b[[0, 2]]
array([[1, 2, 3],
       [7, 8, 9]])
>>> b[[0, 2], [1, 2]]
array([2, 9])

最后一行是两个数组配对取值,取的是 (0,1) 和 (2,2) 两个位置。

赋值也一样方便。

python 复制代码
>>> c = np.arange(6)
>>> c[c > 3] = 0
>>> c
array([0, 1, 2, 3, 0, 0])

4.6 视图还是副本,这里最容易出事

切片返回的是视图,改它等于改原数组。

python 复制代码
>>> base = np.arange(6)
>>> view = base[::2]
>>> view.base is base
True
>>> view.flags.owndata
False
>>> view[0] = 100
>>> base
array([100,   1,   2,   3,   4,   5])

原数组被改了。这是 numpy 故意设计的,为了省内存不复制数据。想要独立的一份就用 copy()。

python 复制代码
>>> safe = base.copy()
>>> safe.base is None
True
>>> safe[0] = 999
>>> base
array([100,   1,   2,   3,   4,   5])

base is None 为真说明这一份自己管着数据。

这个坑在 pandas 里同样存在,pandas 3.0 干脆把链式赋值给废掉了。你要改数据就老老实实写 df.loc[条件, 列名] = ...,或者先 copy() 拿一份出来改。指望"我先取个子集再赋值"作用回原表,在新版 pandas 上连个警告都不会给你。

4.7 广播

形状不一样的数组也能算,规则叫广播。

text 复制代码
   (2, 3)              (3,)
   ┌─────────┐         ┌─────────┐
   │ 0  1  2 │    +    │ 10 20 30│    ──►  第二个数组被"拉长"成两行
   │ 3  4  5 │         └─────────┘         ┌─────────┐
   └─────────┘                             │10 20 30 │
                                           │10 20 30 │
                                           └─────────┘
   从最后一维往前比,这一维是 3 对 3,对得上
   剩下那维是 2 对 1,1 会被复制成 2
python 复制代码
>>> m = np.arange(6).reshape(2, 3)
>>> n = np.array([10, 20, 30])
>>> m + n
array([[10, 21, 32],
       [13, 24, 35]])
>>> m * np.array([[10], [20]])
array([[  0,  10,  20],
       [ 60,  80, 100]])

对不上就报错。

python 复制代码
>>> m + np.array([1, 2])
ValueError: operands could not be broadcast together with shapes (2,3) (2,) 

抛 ValueError: operands could not be broadcast together with shapes (2,3) (2,)。遇到这个先别慌,把两个 shape 打出来对一遍,通常是忘了把一维数组转置成列。

4.8 通用函数 ufunc

numpy 把各种数学运算都做成了 ufunc,它们支持广播、支持全类型。

python 复制代码
>>> x = np.array([1, 4, 9])
>>> np.sqrt(x).round(4)
array([1., 2., 3.])
>>> np.exp([0, 1]).round(4)
array([1.    , 2.7183])
>>> np.log([1, np.e]).round(4)
array([0., 1.])
>>> np.abs([-1, -2, 3])
array([1, 2, 3])
>>> np.round([1.2345, 2.5678], 2)
array([1.23, 2.57])
>>> np.maximum([1, 5, 3], [2, 2, 4])
array([2, 5, 4])
>>> np.clip([1, 5, 9], 2, 7)
array([2, 5, 7])

np.where 出场率极高,三元表达式的数组版。

python 复制代码
>>> scores = np.array([45, 80, 59, 92])
>>> np.where(scores >= 60, "及格", "不及格")
array(['不及格', '及格', '不及格', '及格'], dtype='<U3')
>>> np.where(scores >= 60, scores, 0)
array([ 0, 80,  0, 92])

4.9 统计函数

python 复制代码
>>> z = np.array([[1, 2, 3], [4, 5, 6]])
>>> z.sum()
np.int64(21)
>>> z.mean()
np.float64(3.5)
>>> z.std().round(4)
np.float64(1.7078)
>>> z.min(), z.max()
(np.int64(1), np.int64(6))
>>> z.sum(axis=0)
array([5, 7, 9])
>>> z.sum(axis=1)
array([ 6, 15])
>>> z.mean(axis=1)
array([2., 5.])

axis=0 是竖着压,把每列合成一个数,axis=1 是横着压,把每行合成一个数。这个方向感第一次肯定会晕,记一句,axis 指的是被消掉的那一维。

text 复制代码
   axis=0 竖着压,行没了        axis=1 横着压,列没了

    1  2  3                     1  2  3   →  2
    4  5  6                     4  5  6   →  5
    ↓  ↓  ↓                        ↓
    5  7  9                     行数不变,每行一个数

pandas 里的 axis 跟 numpy 完全一致,所以 DataFrame 上也是这个规则。

还有几个常用的。

python 复制代码
>>> np.median([1, 2, 3, 4])
np.float64(2.5)
>>> np.percentile([1, 2, 3, 4], 50)
np.float64(2.5)
>>> np.argmax([1, 5, 3])
np.int64(1)
>>> np.cumsum([1, 2, 3])
array([1, 3, 6])
>>> np.diff([1, 4, 9])
array([3, 5])
>>> np.unique([3, 1, 2, 3, 1])
array([1, 2, 3])
>>> np.corrcoef([1, 2, 3], [1, 2, 4]).round(4)
array([[1.   , 0.982],
       [0.982, 1.   ]])

4.10 随机数

做 Monte Carlo 测试和造样例数据都离不开随机。老教程里的 np.random.seed(42) 现在还能用,推荐的新写法是显式生成器。

python 复制代码
>>> rng = np.random.default_rng(42)
>>> rng.integers(0, 100, 5)
array([ 8, 77, 65, 43, 43])
>>> rng.random(3).round(4)
array([0.6974, 0.0942, 0.9756])
>>> rng.normal(0, 1, 5).round(3)
array([ 0.128, -0.316, -0.017, -0.853,  0.879])
>>> rng.choice(["a", "b", "c"], 3)
array(['c', 'a', 'c'], dtype='<U1')
>>> rng.permutation([1, 2, 3])
array([1, 2, 3])

同一个种子出来的序列固定不变,这是让实验可复现的前提。

python 复制代码
>>> rng1 = np.random.default_rng(42)
>>> rng2 = np.random.default_rng(42)
>>> rng1.integers(0, 10, 4).tolist() == rng2.integers(0, 10, 4).tolist()
True

本章实战里那份假的订单数据就是这么造的,种子写死,你跑出来的数字跟文章里的完全一致。

4.11 numpy 和 pandas,谁干哪件事

一句话说完:要标签、要对齐、要处理缺失,用 pandas;要算得快、要多维数组、要给 matplotlib 喂坐标,用 numpy。

两者之间来回倒腾特别简单。

python 复制代码
>>> series = pd.Series([1, 2, 3])
>>> series.to_numpy()
array([1, 2, 3])
>>> series.values
array([1, 2, 3])
>>> np.array(series)
array([1, 2, 3])
>>> pd.Series(np.array([7, 8, 9]))
0    7
1    8
2    9
dtype: int64
>>> pd.DataFrame(np.arange(6).reshape(2, 3), columns=["a", "b", "c"])
   a  b  c
0  0  1  2
1  3  4  5

to_numpy() 和 .values 都能拿到底下的数组,前者语义更清楚。DataFrame 转 numpy 的时候注意,列类型不统一会得到 object 数组,数值 columns 的那些加速就没了。


5 实战: 老板要的三个数

5.1 要做什么

现在把前面串起来。场景是这样,旧书铺今年一季度的订单数据在一个 CSV 里,老板想看三件事。

一,这个季度到底卖了多少钱,跟前面几个季度比是涨了还是跌了。

二,钱主要从哪个城市、哪个分类、哪本书来。

三,给我一张能直接发出去的 Excel 和几张图。

难点在于原始数据是脏的。有重复的订单行,有没填城市的记录,还有数量为 0 的废单。这三种必须在算钱之前处理掉,不然数字全是错的。

5.2 完整代码

python 复制代码
# 旧书铺一季度销售分析,存成 shop_report.py
import os
import shutil
import tempfile
import unicodedata

import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd

matplotlib.rcParams["font.sans-serif"] = ["Microsoft YaHei", "SimHei", "DejaVu Sans"]
matplotlib.rcParams["axes.unicode_minus"] = False

BOOKS = {"Python基础教程": (45.0, "编程"), "网络编程实战": (68.0, "编程"),
         "爬虫与反爬": (39.9, "编程"), "并发编程": (77.0, "编程"),
         "Web框架速成": (61.0, "编程"), "数据分析入门": (52.5, "数据")}
CITIES = ["北京", "上海", "广州", "成都"]


def make_orders(rng):
    """造一份订单,种子固定,所以每次跑出来的结果都一样。"""
    pool = pd.date_range("2026-01-01", "2026-03-31", freq="D")
    rows = []
    for _ in range(200):
        book = str(rng.choice(list(BOOKS)))
        price, cat = BOOKS[book]
        day = rng.choice(pool)
        rows.append({"date": pd.Timestamp(day).date().isoformat(),
                     "book": book, "category": cat,
                     "city": str(rng.choice(CITIES, p=[0.35, 0.28, 0.22, 0.15])),
                     "qty": int(rng.integers(1, 5)), "price": price})
    df = pd.DataFrame(rows)
    # 掺沙子:3 条完全重复,4 条城市缺失,2 条数量为 0
    df = pd.concat([df, df.head(3)], ignore_index=True)
    df.loc[10:13, "city"] = None
    df.loc[20:21, "qty"] = 0
    df["amount"] = (df["qty"] * df["price"]).round(2)
    return df.sort_values(["date", "book"]).reset_index(drop=True)


def show_width(text):
    """汉字占两格,按字符数补空格会歪。"""
    return sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in text)


def bar_chart(data, plot_width=30):
    label_width = max(show_width(k) for k in data)
    top = max(data.values())
    lines = []
    for key, value in data.items():
        lines.append("%s%s │%s %.1f" % (key, " " * (label_width - show_width(key)),
                                        "█" * int(round(value / top * plot_width)), value))
    return "\n".join(lines)


rng = np.random.default_rng(20260101)
raw = make_orders(rng)
print("== 原始数据 ==")
print("共 %d 行,日期 %s 到 %s" % (len(raw), raw["date"].min(), raw["date"].max()))
print(raw.head(3).to_string(index=False))
print()

print("== 清洗 ==")
print("完全重复的行 %d 条" % raw.duplicated().sum())
print("有缺失的列 %s" % raw.isna().sum()[lambda s: s > 0].to_dict())
print("数量为 0 的废单 %d 条" % int((raw["qty"] == 0).sum()))

orders = raw.drop_duplicates()
orders["city"] = orders["city"].fillna("未知")
orders = orders[orders["qty"] > 0].copy()
orders["date"] = pd.to_datetime(orders["date"])
print("清洗后剩 %d 行,剔除 %d 行" % (len(orders), len(raw) - len(orders)))
print("还剩 %d 个缺失值" % int(orders.isna().sum().sum()))
print()

print("== 指标 ==")
total = round(orders["amount"].sum(), 2)
print("季度总销售额 %.2f 元" % total)
print("订单 %d 笔,售出 %d 本,客单价 %.2f 元" % (len(orders), int(orders["qty"].sum()), total / len(orders)))
print()

monthly = orders.set_index("date").resample("ME")["amount"].sum().round(2)
print("月度:")
for day, value in monthly.items():
    print("  %s  %8.2f 元" % (day.strftime("%Y-%m"), value))
print("走势是%s" % ("连续上涨" if monthly.is_monotonic_increasing
                  else "连续下滑" if monthly.is_monotonic_decreasing else "有涨有跌"))
print()

by_city = orders.groupby("city")["amount"].sum().round(2).sort_values(ascending=False)
print("城市:")
for city, value in by_city.items():
    print("  %-4s %9.2f 元  %5.1f%%" % (city, value, value / total * 100))
print()

by_cat = orders.groupby("category")["amount"].sum().round(2).sort_values(ascending=False)
print("分类:")
for cat, value in by_cat.items():
    print("  %-4s %9.2f 元  %5.1f%%" % (cat, value, value / total * 100))
print()

by_book = orders.groupby("book")["amount"].sum().round(2).nlargest(3)
print("最卖钱的三本:")
for book, value in by_book.items():
    print("  %-14s %8.2f 元" % (book, value))
print()

print("== 终端图 ==")
print(bar_chart(by_city.to_dict()))
print()
print(bar_chart(by_book.to_dict()))
print()

print("== 出图 ==")
out_dir = tempfile.mkdtemp(prefix="shop-")
fig, ax = plt.subplots(figsize=(6, 3.4), dpi=100)
ax.plot([d.strftime("%m月") for d in monthly.index], monthly.values,
        marker="o", linewidth=2, color="#E8863B")
ax.set_title("旧书铺一季度月度销售额")
ax.set_ylabel("元")
ax.grid(axis="y", alpha=0.3)
fig.tight_layout()
fig.savefig(os.path.join(out_dir, "monthly.png"))
plt.close(fig)

fig2, ax2 = plt.subplots(figsize=(5, 4), dpi=100)
ax2.pie(by_cat.values, labels=by_cat.index, autopct="%1.1f%%", startangle=90,
        colors=["#4C72B0", "#E8863B"])
ax2.set_title("分类占比")
fig2.tight_layout()
fig2.savefig(os.path.join(out_dir, "category.png"))
plt.close(fig2)
print("生成两张图:", sorted(os.listdir(out_dir)))
print()

print("== 报表 ==")
report = os.path.join(out_dir, "report.xlsx")
with pd.ExcelWriter(report, engine="openpyxl") as writer:
    orders.head(20).to_excel(writer, sheet_name="订单明细", index=False)
    monthly.rename("销售额").to_frame().to_excel(writer, sheet_name="月度")
    by_city.rename("销售额").to_frame().to_excel(writer, sheet_name="城市")
    orders.pivot_table(index="city", columns="category", values="amount",
                       aggfunc="sum", fill_value=0).round(2).to_excel(writer, sheet_name="城市x分类")
with pd.ExcelFile(report) as workbook:
    print("工作表:", workbook.sheet_names)
print("回读月度页:", pd.read_excel(report, sheet_name="月度").to_dict("records"))
shutil.rmtree(out_dir, ignore_errors=True)

5.3 跑出来的结果

text 复制代码
== 原始数据 ==
共 203 行,日期 2026-01-02 到 2026-03-31
      date       book category city  qty  price  amount
2026-01-02 Python基础教程       编程   上海    3   45.0   135.0
2026-01-02    Web框架速成       编程   成都    4   61.0   244.0
2026-01-04       并发编程       编程   北京    4   77.0   308.0

== 清洗 ==
完全重复的行 5 条
有缺失的列 {'city': 4}
数量为 0 的废单 2 条
清洗后剩 196 行,剔除 7 行
还剩 0 个缺失值

== 指标 ==
季度总销售额 26583.10 元
订单 196 笔,售出 465 本,客单价 135.63 元

月度:
  2026-01   7990.90 元
  2026-02   9810.40 元
  2026-03   8781.80 元
走势是有涨有跌

城市:
  北京    10058.70 元   37.8%
  上海     7033.60 元   26.5%
  广州     4997.80 元   18.8%
  成都     3997.00 元   15.0%
  未知      496.00 元    1.9%

分类:
  编程    23433.10 元   88.2%
  数据     3150.00 元   11.8%

最卖钱的三本:
  并发编程            6776.00 元
  Web框架速成         5063.00 元
  网络编程实战          4488.00 元

== 终端图 ==
北京 │██████████████████████████████ 10058.7
上海 │█████████████████████ 7033.6
广州 │███████████████ 4997.8
成都 │████████████ 3997.0
未知 │█ 496.0

并发编程     │██████████████████████████████ 6776.0
Web框架速成  │██████████████████████ 5063.0
网络编程实战 │████████████████████ 4488.0

== 出图 ==
生成两张图: ['category.png', 'monthly.png']

== 报表 ==
工作表: ['订单明细', '月度', '城市', '城市x分类']
回读月度页: [{'date': Timestamp('2026-01-31 00:00:00'), '销售额': 7990.9}, {'date': Timestamp('2026-02-28 00:00:00'), '销售额': 9810.4}, {'date': Timestamp('2026-03-31 00:00:00'), '销售额': 8781.8}]

5.4 从这三个数里能读出什么

数据是我编的,但读数据的思路是真的。

第一维度看趋势。三个月分别是 7990、9810、8781,二月最高。单看这三个数容易得出二月卖得好的结论,往下挖一层先问订单数是不是也多了。要是人数没变、只是单笔金额抬上去,那就得接着看是高价书卖得多,还是出了大额客户。这里我们只有一个季度,没法说清,真做分析起码要拉同期数据比,或者跟去年同期比。

第二维度看结构。北京一个城市占了 37.8%,加上上海超过六成。这个信息挣钱意义不大,但开店意义很大。第四名的成都占比 15%,要不要加推广,取决于它是均值低还是人数少。

第三维度看品类。编程类占 88.2%,数据类只有 11.8%,因为定价表里只有一本书是数据类。品类占比这种指标本身不代表好坏,它反映的是你的供给结构。想提升总销售额,要么多进几个数据类的品,要么让现有的编程类书卖得更贵。

最后那批"未知"城市占 1.9%,是四笔订单。假设这是真的,正确的动作是回去查那四条记录的城市,而不是把 1.9% 直接抹掉。我在这里留着它,就是为了提醒你把缺失值补出来之后别忘了回头查源头。

5.5 换成你自己的数据要改哪些

make_orders 整个删掉,换成一行读文件,raw = pd.read_csv("你的文件.csv", encoding="utf-8")。

然后改这三件事。

列名对齐。把 date、book、city、qty、price 换成你自己的列名,最简单的做法是读进来之后统一 rename。

python 复制代码
>>> raw = pd.DataFrame({"订单日期": ["2026-01-05"], "书名": ["Python基础教程"],
...                     "城市": ["北京"], "数量": [2], "单价": [45.0]})
>>> raw = raw.rename(columns={"订单日期": "date", "书名": "book", "城市": "city",
...                           "数量": "qty", "单价": "price"})
>>> raw.columns.tolist()
['date', 'book', 'city', 'qty', 'price']

脏数据的规则重写。duplicated、isna、qty > 0 这三行只是最常见的三种,你的数据可能有 price 为负的退款单、有 test 开头的测试账号、有两年前的旧数据。清洗这一步没有通用答案,只能先看 describe 和 info,再把异常逐一写进过滤条件。

日期字段。古老的 CSV 里日期常常长得不像日期,比如 20260131、31/01/2026。pandas 认不出来的时候手动给格式。

python 复制代码
>>> pd.to_datetime(["20260131"], format="%Y%m%d")
DatetimeIndex(['2026-01-31'], dtype='datetime64[us]', freq=None)
>>> pd.to_datetime(["31/01/2026"], format="%d/%m/%Y")
DatetimeIndex(['2026-01-31'], dtype='datetime64[us]', freq=None)

5.6 还能往上加什么

几个顺手的扩展,都不难。

多 sheet 报表里加一个"同比"页,把上个季度的数据也读进来算变化率。这需要 merge 两个月度表,前面 2.5 节写过。

给报表加个日期戳,文件名改成 report_2026Q1.xlsx,避免每次覆盖。

图切成中英两套,matplotlib 换字体就是多加一个 rcParams。

把整个流程包成一个函数,输出 -o 参数,用第十三章讲过的 argparse 接一下,做成能定时跑的脚本。


6 小结

6.1 这章说了什么

数据分析这条路就四步,读进来、洗干净、算出去、看得见。pandas 管前三步,matplotlib 管最后一步,numpy 在底下托着。

pandas 只有两个结构,Series 是一列,DataFrame 是一张表。入门阶段搞定 read_csv、head、dtypes、describe、loc、iloc、布尔筛选、sort_values,日常分析的一半需求就够用了。进阶阶段真正拉开差距的是 groupby、pivot_table、merge、resample 这四个,外加.str、.dt、cut、rolling 这套小工具。

画图这块就三件事,Windows 上照样要配中文字体,分清 Figure 和 Axes,剩下的对着 3.11 那张表挑。

numpy 的要点只有三个,ndarray 是一块连续同类内存,向量化让 Python 循环变成 C 循环,广播让不同形状的数组能一起算。

6.2 常用操作速查

text 复制代码
   读           pd.read_csv / read_excel / read_json
   写           to_csv(index=False) / to_excel(engine="openpyxl")

   看一眼       head  shape  columns  dtypes  describe  info
   脏数据       isna.sum  duplicated  value_counts  unique

   取子集       df[col]  df[[a,b]]  loc[标签]  iloc[位置]
   筛           df[df[col] > x]  query  isin  between
   排           sort_values  nlargest  rank

   算一列       series.sum mean min max std describe
   分组         groupby(...).agg(新名=("列","算法"))
   透视         pivot_table(index, columns, values, aggfunc)
   合表         merge(on=..., how="left")  concat([a,b])
   时间         to_datetime  .dt.month  resample("ME")
   补缺         isna  fillna  dropna  ffill  interpolate

   画图         fig, ax = plt.subplots(figsize=(6,4))
                ax.plot/bar/barh/pie/scatter/hist
                ax.set_title/set_xlabel/set_ylabel/legend/grid
                fig.tight_layout()  fig.savefig("a.png", dpi=150)

   numpy        np.array/arange/linspace/zeros/eye
                reshape(-1, n)  .T  astype  dtype
                arr[1:3, 0:2]  arr[arr > 5]  np.where
                np.sum/mean/std/argmax/cumsum/unique
                np.random.default_rng(种子)

6.3 十个容易踩的坑

第一,read_csv 读完忘了 index=False,写回去多一列无名 index。

第二,多个条件筛选的时候用了 and 或忘了加括号。& 和 | 之外,不要忘记每个条件两侧的小括号。

第三,逐行用 apply,数据量一上来就卡住。能写成列运算的别用 apply。

第四,链式赋值不生效。pandas 3.0 之后 df[df.x > 1]["y"] = 1 这种写法连警告都不给,直接没反应,用 df.loc[df.x > 1, "y"] = 1。

第五,sort_values 之后 index 还留着原来的顺序,后面 loc[0] 取到的不是第一行。reset_index(drop=True) 解决。

第六,NaN 不等于自己,== np.nan 永远是 False。用 isna()。

第七,中文列对不齐。终端报表用 3.3 节的 show_width 算宽度,pandas 输出就开 display.unicode.east_asian_width。

第八,matplotlib 中文变方块。记得配那两个 rcParams,font.sans-serif 和 axes.unicode_minus。

第九,resample("M") 报错。新版要写 "ME",同理 "Q" 变 "QE","A" 变 "YE"。

第十,numpy 切片是视图不是副本。改了切片等于改了原数组,要独立的一份就 copy()。还要留神 dtype 溢出,int8 装 200 会变成负数。

6.4 四道小题

一、<|hy_place▁holder▁no▁813|>这几行会输出什么,先自己想想再跑。

python 复制代码
>>> s = pd.Series([10, 20, 30, 40], index=["a", "b", "c", "d"])
>>> (s[s > 15] + 5).tolist()
[25, 35, 45]
>>> s.loc["b":"c"].tolist()
[20, 30]

二、这张表里每个城市卖了多少本,列出前三名。思路在 2.2 节里。

python 复制代码
>>> tiny = pd.DataFrame({
...     "city": ["北京", "上海", "北京", "广州", "上海", "北京"],
...     "book": ["A", "B", "A", "C", "B", "B"],
...     "qty": [2, 1, 3, 4, 2, 1]})

三、下面这段本意是给金额打五折再四舍五入,可它压根不生效,你能看出为什么吗。

python 复制代码
# deliberate
import pandas as pd
d = pd.DataFrame({"amount": [100.0, 200.0]})
d[d["amount"] > 150]["amount"] = d[d["amount"] > 150]["amount"] * 0.5
print(d)

四、用 numpy 做这件事,不用循环,生成一个长度为 12 的数组 [0, 1, 4, 9, ...](即下标的平方),再把它 reshape 成 3 行 4 列。

相关推荐
灰山君1 小时前
从三维数字孪生到交互式图表:山海鲸可视化与pyecharts应用解析
大数据·信息可视化·数据分析·数字孪生·3d模型
2601_961963381 小时前
电子合同检索与全文搜索怎么测、怎么选:六个指标 + 五家平台对比 + 八个自测问题
数据库·后端·python·django·智能合约
SEO_juper2 小时前
hreflang 审计工具:外贸多语言站谷歌SEO的隐性杀手
开发语言·人工智能·爬虫·python·seo
Databend2 小时前
Databend 8-9 月双月报:物化视图、数据共享与数据血缘能力升级
大数据·数据分析·agent
打工仔折腾 AI2 小时前
从零开始大模型微调:基于 PyTorch 与 ChatGLM 的 LoRA 实践指南
人工智能·pytorch·后端·python·深度学习·性能优化·ai agent 实战
用户0332126663672 小时前
Python 自动化办公:如何从零快速生成结构清晰的 Excel 报表
python·excel
刘 大 望2 小时前
Agent 的规划与执行范式:Plan-Execute-Replan 与 ReAct
java·python·ai·chatgpt·langchain·ai编程
在世修行3 小时前
智学文枢:数据备份与恢复
python·数据安全·备份恢复·zipfile
ONEYAC唯样3 小时前
TE:不只看产品,更要看价值:下一代高压连接方案如何选择?
python·django