Python 是一种鸭子类型语言。这意味着变量的数据类型可以改变,只要语法兼容即可。Python 也是一种动态编程语言。这意味着我们可以在程序运行时更改它,包括定义新函数和名称解析的作用域。这些不仅为我们编写 Python 代码提供了一种新的范式,也为调试提供了一套新工具。接下来,我们将看到在 Python 中可以做到而许多其他语言无法做到的事情。
完成本教程后,您将了解
- Python 如何管理你定义的变量
- Python 代码如何使用变量,以及为什么我们不需要像 C 或 Java 那样定义其类型
使用我的新书《Python for Machine Learning》启动你的项目,其中包含分步教程和所有示例的Python源代码文件。
让我们开始吧。
Python 中的鸭子类型、作用域和检查函数。照片由 Julissa Helmuth 提供。部分权利保留
概述
本教程分为三个部分;它们是
- 编程语言中的鸭子类型
- Python 中的作用域和命名空间
- 检查类型和作用域
编程语言中的鸭子类型
鸭子类型是某些现代编程语言的一项特性,允许数据类型动态化。
一种编程风格,它不查看对象的类型来确定它是否具有正确的接口;相反,只是调用或使用方法或属性(“如果它看起来像鸭子,并且像鸭子一样叫,那它一定是一只鸭子。”)通过强调接口而不是特定类型,精心设计的代码通过允许多态替换来提高其灵活性。
简单来说,程序应该允许你交换数据结构,只要相同的语法仍然有意义。例如,在 C 语言中,你必须定义如下函数:
1 2 3 4 5 6 7 8 9 |
float fsquare(float x) { return x * x; }; int isquare(int x) { return x * x; }; |
虽然操作 `x * x` 对于整数和浮点数是相同的,但接受整数参数的函数和接受浮点数参数的函数并不相同。因为 C 语言中的类型是静态的,所以我们必须定义两个函数,即使它们执行相同的逻辑。在 Python 中,类型是动态的;因此,我们可以将相应的函数定义为:
1 2 |
def square(x): return x * x |
这一特性确实给了我们巨大的能力和便利。例如,在 scikit-learn 中,我们有一个函数用于交叉验证:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
# 在数据集上评估感知器模型 from numpy import mean from numpy import std from sklearn.datasets import make_classification from sklearn.model_selection import cross_val_score from sklearn.model_selection import RepeatedStratifiedKFold from sklearn.linear_model import Perceptron # 定义数据集 X, y = make_classification(n_samples=1000, n_features=10, n_informative=10, n_redundant=0, random_state=1) # 定义模型 model = Perceptron() # 定义模型评估方法 cv = RepeatedStratifiedKFold(n_splits=10, n_repeats=3, random_state=1) # 评估模型 scores = cross_val_score(model, X, y, scoring='accuracy', cv=cv, n_jobs=-1) # 总结结果 print('Mean Accuracy: %.3f (%.3f)' % (mean(scores), std(scores))) |
但在上面,`model` 是一个 scikit-learn 模型对象的变量。它是什么模型并不重要,无论是上面提到的感知器模型、决策树,还是支持向量机模型。重要的是,在 `cross_val_score()` 函数内部,数据将通过其 `fit()` 函数传递给模型。因此,模型必须实现 `fit()` 成员函数,并且 `fit()` 函数的行为必须相同。结果是 `cross_val_score()` 函数不期望任何特定的模型类型,只要它看起来像一个模型即可。如果我们使用 Keras 来构建神经网络模型,我们可以通过包装器使 Keras 模型看起来像一个 scikit-learn 模型。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
# 使用 sklearn 的 10 折交叉验证对 Pima Indians 数据集进行 MLP from keras.models import Sequential from keras.layers import Dense from keras.wrappers.scikit_learn import KerasClassifier from sklearn.model_selection import StratifiedKFold from sklearn.model_selection import cross_val_score from sklearn.datasets import load_diabetes import numpy # 创建模型的功能,KerasClassifier 所必需 def create_model(): # 创建模型 model = Sequential() model.add(Dense(12, input_dim=8, activation='relu')) model.add(Dense(8, activation='relu')) model.add(Dense(1, activation='sigmoid')) # 编译模型 model.compile(loss='binary_crossentropy', optimizer='adam', metrics=['accuracy']) return model # 设置随机种子以保证结果可复现 seed = 7 numpy.random.seed(seed) # 加载皮马印第安人糖尿病数据集 dataset = numpy.loadtxt("https://raw.githubusercontent.com/jbrownlee/Datasets/master/pima-indians-diabetes.csv", delimiter=",") # 分割为输入 (X) 和输出 (Y) 变量 X = dataset[:,0:8] Y = dataset[:,8] # 创建模型 model = KerasClassifier(build_fn=create_model, epochs=150, batch_size=10, verbose=0) # 使用 10 折交叉验证进行评估 kfold = StratifiedKFold(n_splits=10, shuffle=True, random_state=seed) results = cross_val_score(model, X, Y, cv=kfold) print(results.mean()) |
在上面,我们使用了 Keras 的包装器。还存在其他包装器,例如 scikeras。它所做的就是确保 Keras 模型的接口看起来像一个 scikit-learn 分类器,这样您就可以使用 `cross_val_score()` 函数。如果我们用以下方式替换上面的 `model`:
1 |
model = create_model() |
那么 scikit-learn 函数将报错,因为它找不到 `model.score()` 函数。
同样,由于鸭子类型,我们可以重用一个期望列表的函数,但提供 NumPy 数组或 pandas Series,因为它们都支持相同的索引和切片操作。例如,我们使用 ARIMA 对时间序列进行拟合,如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
from statsmodels.tsa.statespace.sarimax import SARIMAX import numpy as np import pandas as pd data = [266.0,145.9,183.1,119.3,180.3,168.5,231.8,224.5,192.8,122.9,336.5,185.9, 194.3,149.5,210.1,273.3,191.4,287.0,226.0,303.6,289.9,421.6,264.5,342.3, 339.7,440.4,315.9,439.3,401.3,437.4,575.5,407.6,682.0,475.3,581.3,646.9] model = SARIMAX(y, order=(5,1,0)) res = model.fit(disp=False) print("AIC = ", res.aic) data = np.array(data) model = SARIMAX(y, order=(5,1,0)) res = model.fit(disp=False) print("AIC = ", res.aic) data = pd.Series(data) model = SARIMAX(y, order=(5,1,0)) res = model.fit(disp=False) print("AIC = ", res.aic) |
上面的代码应该为每次拟合产生相同的 AIC 分数。
Python 中的作用域和命名空间
在大多数语言中,变量定义在有限的作用域内。例如,在函数内定义的变量只能在该函数内访问。
1 2 3 4 5 6 7 |
from math import sqrt def quadratic(a,b,c): discrim = b*b - 4*a*c x = -b/(2*a) y = sqrt(discrim)/(2*a) return x-y, x+y |
局部变量 `discrim` 在 `quadratic()` 函数之外的任何地方都无法访问。此外,这可能会让某些人感到惊讶:
1 2 3 4 5 6 7 8 |
a = 1 def f(x): a = 2 * x return a b = f(3) print(a, b) |
1 |
1 6 |
我们在函数 `f` 外部定义了变量 `a`,但在 `f` 内部,变量 `a` 被赋值为 `2 * x`。但是,函数内部的 `a` 和外部的 `a` 除了名称之外是无关的。因此,当我们退出函数时,`a` 的值不会改变。为了使其在函数 `f` 内部可修改,我们需要将名称 `a` 声明为 `global`,这样就可以清楚地表明该名称应该来自全局作用域,而不是局部作用域。
1 2 3 4 5 6 7 8 9 |
a = 1 def f(x): global a a = 2 * x return a b = f(3) print(a, b) |
1 |
6 6 |
然而,当我们引入函数中的嵌套作用域时,我们可能会进一步使问题复杂化。考虑以下示例:
1 2 3 4 5 6 7 8 9 10 |
a = 1 def f(x): a = x def g(x): return a * x return g(3) b = f(2) print(b) |
1 |
6 |
函数 `f` 中的变量 `a` 与全局变量不同。但是,在 `g` 中,由于没有向 `a` 写入任何内容,只是从它读取,Python 会看到来自最近作用域的同一个 `a`,即函数 `f` 中的 `a`。然而,变量 `x` 是作为函数 `g` 的参数定义的,当我们调用 `g(3)` 时,它取值为 `3`,而不是假定函数 `f` 中的 `x` 的值。
注意:如果一个变量在函数中任何地方被赋值,它就被定义在局部作用域中。如果该变量在赋值之前就尝试从它读取值,则会引发错误,而不是使用来自外部或全局作用域的同名变量的值。
这个属性有很多用途。Python 中许多记忆化装饰器的实现巧妙地利用了函数作用域。另一个例子是:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
import numpy as np def datagen(X, y, batch_size, sampling_rate=0.7): """从输入的 numpy 数组 X 和 y 生成样本的生成器 """ # 从数组 X 和 y 随机选择行 indexing = np.random.random(len(X)) < sampling_rate Xsam, ysam = X[indexing], y[indexing] # 生成批次的实际逻辑 def _gen(batch_size): while True: Xbatch, ybatch = [], [] for _ in range(batch_size): i = np.random.randint(len(Xsam)) Xbatch.append(Xsam[i]) ybatch.append(ysam[i]) yield np.array(Xbatch), np.array(ybatch) # 创建并返回一个生成器 return _gen(batch_size) |
这是一个生成器函数,它从输入的 NumPy 数组 `X` 和 `y` 创建样本批次。Keras 模型可以在其训练中使用这样的生成器。但是,出于交叉验证等原因,我们不想从整个输入数组 `X` 和 `y` 中采样,而是从它们中采样固定的行子集。我们的做法是在 `datagen()` 函数的开头随机选择一部分行,并将它们保留在 `Xsam`、`ysam` 中。然后在内部函数 `_gen()` 中,从 `Xsam` 和 `ysam` 中采样行,直到创建批次。虽然列表 `Xbatch` 和 `ybatch` 在 `_gen()` 函数内部定义和创建,但数组 `Xsam` 和 `ysam` 并不是 `_gen()` 的局部变量。更有趣的是,当生成器创建时:
1 2 3 4 5 6 7 |
X = np.random.random((100,3)) y = np.random.random(100) gen1 = datagen(X, y, 3) gen2 = datagen(X, y, 4) print(next(gen1)) print(next(gen2)) |
1 2 3 4 5 6 7 |
(array([[0.89702235, 0.97516228, 0.08893787], [0.26395301, 0.37674529, 0.1439478 ], [0.24859104, 0.17448628, 0.41182877]]), array([0.2821138 , 0.87590954, 0.96646776])) (array([[0.62199772, 0.01442743, 0.4897467 ], [0.41129379, 0.24600387, 0.53640666], [0.02417213, 0.27637708, 0.65571031], [0.15107433, 0.11331674, 0.67000849]]), array([0.91559533, 0.84886957, 0.30451455, 0.5144225 ])) |
函数 `datagen()` 被调用了两次,因此创建了两组不同的 `Xsam`、`ysam`。但是,由于内部函数 `_gen()` 依赖于它们,这两组 `Xsam`、`ysam` 会同时驻留在内存中。技术上讲,当我们调用 `datagen()` 时,会创建一个闭包,其中包含定义的特定 `Xsam`、`ysam`,而对 `_gen()` 的调用会访问该闭包。换句话说,`datagen()` 调用的两个实例的作用域是共存的。
总之,当一行代码引用一个名称(无论是变量、函数还是模块)时,该名称将按照 LEGB 规则的顺序进行解析。
- 首先是局部作用域,即在同一个函数中定义的那些名称。
- 闭包或“ nonlocal ”作用域。如果我们位于嵌套函数内部,那就是上一级函数。
- 全局作用域,即在同一脚本的顶层定义的那些名称(但不跨不同的程序文件)。
- 内置作用域,即 Python 自动创建的那些名称,例如变量
__name__
或函数list()
。
想开始学习机器学习 Python 吗?
立即参加我为期7天的免费电子邮件速成课程(附示例代码)。
点击注册,同时获得该课程的免费PDF电子书版本。
检查类型和作用域
由于 Python 中的类型不是静态的,有时我们想知道我们正在处理什么,但从代码中很难判断。一种方法是使用 type()
或 isinstance()
函数。例如:
1 2 3 4 5 |
import numpy as np X = np.random.random((100,3)) print(type(X)) print(isinstance(X, np.ndarray)) |
1 2 |
<class 'numpy.ndarray'> True |
type()
函数返回一个类型对象。isinstance()
函数返回一个布尔值,允许我们检查某物是否匹配特定类型。如果我们想知道变量的类型,这些函数很有用。如果我们正在调试代码,这会很有帮助。例如,如果我们把一个 pandas DataFrame 传递给上面定义的 datagen()
函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
import pandas as pd import numpy as np def datagen(X, y, batch_size, sampling_rate=0.7): """从输入的 numpy 数组 X 和 y 生成样本的生成器 """ # 从数组 X 和 y 随机选择行 indexing = np.random.random(len(X)) < sampling_rate Xsam, ysam = X[indexing], y[indexing] # 生成批次的实际逻辑 def _gen(batch_size): while True: Xbatch, ybatch = [], [] for _ in range(batch_size): i = np.random.randint(len(Xsam)) Xbatch.append(Xsam[i]) ybatch.append(ysam[i]) yield np.array(Xbatch), np.array(ybatch) # 创建并返回一个生成器 return _gen(batch_size) X = pd.DataFrame(np.random.random((100,3))) y = pd.DataFrame(np.random.random(100)) gen3 = datagen(X, y, 3) print(next(gen3)) |
在 Python 的调试器 pdb
下运行上述代码将得到以下结果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
> /Users/MLM/ducktype.py(1)<module>() -> import pandas as pd (Pdb) c 回溯(最近一次调用) File "/usr/local/lib/python3.9/site-packages/pandas/core/indexes/range.py", line 385, in get_loc return self._range.index(new_key) ValueError: 1 is not in range 上述异常是以下异常的直接原因 回溯(最近一次调用) File "/usr/local/Cellar/python@3.9/3.9.9/Frameworks/Python.framework/Versions/3.9/lib/python3.9/pdb.py", line 1723, in main pdb._runscript(mainpyfile) File "/usr/local/Cellar/python@3.9/3.9.9/Frameworks/Python.framework/Versions/3.9/lib/python3.9/pdb.py", line 1583, in _runscript self.run(statement) File "/usr/local/Cellar/python@3.9/3.9.9/Frameworks/Python.framework/Versions/3.9/lib/python3.9/bdb.py", line 580, in run exec(cmd, globals, locals) File "<string>", line 1, in <module> File "/Users/MLM/ducktype.py", line 1, in <module> import pandas as pd File "/Users/MLM/ducktype.py", line 18, in _gen ybatch.append(ysam[i]) File "/usr/local/lib/python3.9/site-packages/pandas/core/frame.py", line 3458, in __getitem__ indexer = self.columns.get_loc(key) File "/usr/local/lib/python3.9/site-packages/pandas/core/indexes/range.py", line 387, in get_loc raise KeyError(key) from err KeyError: 1 Uncaught exception. Entering post mortem debugging Running 'cont' or 'step' will restart the program > /usr/local/lib/python3.9/site-packages/pandas/core/indexes/range.py(387)get_loc() -> raise KeyError(key) from err (Pdb) |
从堆栈跟踪中可以看出,出现了问题,因为我们无法获取 ysam[i]
。我们可以使用以下方法来验证 ysam
确实是一个 Pandas DataFrame 而不是 NumPy 数组。
1 2 3 4 5 6 7 8 |
(Pdb) up > /usr/local/lib/python3.9/site-packages/pandas/core/frame.py(3458)__getitem__() -> indexer = self.columns.get_loc(key) (Pdb) up > /Users/MLM/ducktype.py(18)_gen() -> ybatch.append(ysam[i]) (Pdb) type(ysam) <class 'pandas.core.frame.DataFrame'> |
因此,我们不能使用 ysam[i]
从 ysam
中选择第 i
行。在调试器中,我们可以做什么来验证如何修改我们的代码?有几个有用的函数可以用来检查变量和作用域。
dir()
用于查看作用域中定义的名称或对象中定义的属性。locals()
和globals()
分别用于查看局部和全局定义的名称和值。
例如,我们可以使用 dir(ysam)
来查看 ysam
中定义了哪些属性或函数。
1 2 3 4 5 6 7 8 9 10 |
(Pdb) dir(ysam) ['T', '_AXIS_LEN', '_AXIS_ORDERS', '_AXIS_REVERSED', '_AXIS_TO_AXIS_NUMBER', ... 'iat', 'idxmax', 'idxmin', 'iloc', 'index', 'infer_objects', 'info', 'insert', 'interpolate', 'isin', 'isna', 'isnull', 'items', 'iteritems', 'iterrows', 'itertuples', 'join', 'keys', 'kurt', 'kurtosis', 'last', 'last_valid_index', ... 'transform', 'transpose', 'truediv', 'truncate', 'tz_convert', 'tz_localize', 'unstack', 'update', 'value_counts', 'values', 'var', 'where', 'xs'] (Pdb) |
其中一些是属性,例如 shape
,一些是函数,例如 describe()
。您可以在 pdb
中读取属性或调用函数。通过仔细阅读此输出,我们回想起从 DataFrame 中读取第 i
行的方法是通过 iloc
,因此我们可以通过以下方式验证语法:
1 2 3 4 |
(Pdb) ysam.iloc[i] 0 0.83794 Name: 2, dtype: float64 (Pdb) |
如果调用不带参数的 dir()
,它会显示当前作用域中定义的所有名称,例如:
1 2 3 4 5 6 7 8 |
(Pdb) dir() ['Xbatch', 'Xsam', '_', 'batch_size', 'i', 'ybatch', 'ysam'] (Pdb) up > /Users/MLM/ducktype.py(1)<module>() -> import pandas as pd (Pdb) dir() ['X', '__builtins__', '__file__', '__name__', 'datagen', 'gen3', 'np', 'pd', 'y'] (Pdb) |
作用域会随着您在调用堆栈中的移动而改变。与不带参数的 dir()
类似,我们可以调用 locals()
来显示所有局部定义的变量,例如:
1 2 3 4 5 |
(Pdb) locals() {'batch_size': 3, 'Xbatch': ..., 'ybatch': ..., '_': 0, 'i': 1, 'Xsam': ..., 'ysam': ...} (Pdb) |
确实,locals()
返回一个 dict
,允许您查看所有名称和值。因此,如果我们想读取变量 Xbatch
,我们可以通过 locals()["Xbatch"]
来获取。类似地,我们可以使用 globals()
来获取全局作用域中定义的名称的字典。
这项技术有时很有益。例如,我们可以通过使用 dir(model)
来检查 Keras 模型是否“已编译”。在 Keras 中,编译模型是为训练设置损失函数并构建前向和后向传播的流程。因此,已编译的模型将具有一个额外的属性 loss
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
from tensorflow.keras import Sequential from tensorflow.keras.layers import Dense model = Sequential([ Dense(5, input_shape=(3,)), Dense(1) ]) has_loss = "loss" in dir(model) print("Before compile, loss function defined:", has_loss) model.compile() has_loss = "loss" in dir(model) print("After compile, loss function defined:", has_loss) |
1 2 |
Before compile, loss function defined: False After compile, loss function defined: True |
这使我们能够在代码中添加一个额外的检查,以避免出现错误。
延伸阅读
如果您想深入了解,本节提供了更多关于该主题的资源。
文章
- 鸭子类型,https://en.wikipedia.org/wiki/Duck_typing
- Python 词汇表(鸭子类型),https://docs.pythonlang.cn/3/glossary.html#term-duck-typing
- Python 内置函数,https://docs.pythonlang.cn/3/library/functions.html
书籍
- Fluent Python,第二版,作者 Luciano Ramalho,https://www.amazon.com/dp/1492056359/
总结
在本教程中,您了解了 Python 如何组织命名作用域以及变量如何与代码交互。具体来说,您学到了:
- Python 代码通过变量的接口来使用它们;因此,变量的数据类型通常不重要。
- Python 变量在其命名作用域或闭包中定义,同名变量可以在不同作用域中共存,因此它们不会相互干扰。
- 我们有一些 Python 内置函数,允许我们检查当前作用域中定义的名称或变量的数据类型。
很棒的观点!Jason 博士。感谢分享。我只是快速浏览了一下,希望能再读一遍以深入理解。祝好!
不客气,Chandra!