--- jupytext: text_representation: format_name: myst kernelspec: display_name: Python 3 name: python3 --- ```{code-cell} ipython3 :tags: [remove_input] import numpy as np import pandas as pd import matplotlib.pyplot as plt np.random.seed(0) plt.rcParams['font.sans-serif'] = ['SimHei'] plt.rcParams['axes.unicode_minus'] = False np.set_printoptions(suppress = True) ``` # 第十二章 ## 零、练一练 ```{admonition} 练一练 在阅读归一化方法的内容后,思考以下问题: - 上述案例中只在单侧添加了极端值,对于双侧都含有极端值的分布如何将其合理变换至$[0,1]$区间? - 如何修改上文归一至$[−1,1]$区间的方法,使其归一化不易受极端值影响? ``` - 1 ```{code-cell} ipython3 s = pd.Series([-10] + list(np.random.rand(1000)) + [10]) _ = plt.boxplot(s) ``` ```{code-cell} ipython3 s_fix = s.clip((-s).nlargest(2).iloc[1], s.nlargest(2).iloc[1]) _ = plt.boxplot(s_fix) ``` ```{code-cell} ipython3 # 还可以通过异常检测来做,预测为异常的离群点进行剔除 from sklearn.neighbors import LocalOutlierFactor clf = LocalOutlierFactor(n_neighbors=10) label = clf.fit_predict(s.values[:, None]) s_fix2 = s[label==1] _ = plt.boxplot(s_fix2) ``` - 2 ```{code-cell} ipython # 直接用上面的去除异常后再操作 new_data = s_fix - s_fix.mean() new_data = new_data / new_data.abs().max() _ = plt.boxplot(new_data) ``` ```{admonition} 练一练 阅读上述关于上凸变换的内容,完成以下任务: - 请模仿图12.5对土地面积进行类似绘制。 - 当对数变换的底数变化时,新数据的偏度会变化吗?为什么? - 请构造1个上凸变换并将其应用于人口的特征列使其偏度尽可能接近0。 - 在自变量$x$非负时,幂变换$x\rightarrow x^\lambda$在$\lambda$取何值为下凸变换(二阶导数恒大于0)?它一定是关于自变量的单调递增变换吗? - 鉴于上凸变换能够将右偏分布的偏度减小,那么也就不难想到下凸变换能够提高左偏分布的偏度,请构造1个相应的例子来说明。(偏态分布可以选择从scipy.stats.skewnorm.rvs中生成) ``` - 1 ```python df = pd.read_csv("data/ch12/area-pop.csv") my_lambda = np.linspace(1e-4, 1e0, 100) skew = [(df.LandArea ** i).skew() for i in my_lambda] plt.plot(my_lambda, skew) plt.xscale("log") plt.xlabel("$\lambda$") plt.ylabel("Skewness of Land Area") ``` - 2 由于对随机变量进行数乘不会改变偏度(参见偏度公式易得),且$Skew[\log_a(X)] = Skew[\frac{\log_b(X)}{\log_b(a)}]=Skew[\log_b(X)]$,因此不变。 - 3 观察到$\lambda$与偏度是单调关系,因此考虑使用二分法: ```{code-cell} ipython3 df = pd.read_csv("data/ch12/area-pop.csv") left, right = 0.1, 1 while right - left > 1e-15: mid = (left + right) / 2 mid_skew = (df.Population ** mid).skew() if mid_skew > 0: right = mid else: left = mid mid ``` 因此可取$x\rightarrow x^{0.428}$ - 4 当$\lambda\in (-\infty, 0)\cup(1,\infty)$时为下凸变换,且当取大于1的部分时关于自变量单调递增。 - 5 ```{code-cell} ipython3 from scipy.stats import skewnorm arr = skewnorm.rvs(-10, size=10000) _ = plt.hist(arr, bins=20) ``` ```{code-cell} ipython3 from scipy.stats import skewnorm arr_transformed = np.exp(arr) _ = plt.hist(arr_transformed, bins=20) ``` ```{code-cell} ipython3 pd.Series(arr).skew(), pd.Series(arr_transformed).skew() ``` ```{admonition} 练一练 scipy中的stats模块封装了许多与分布有关的生成函数,通过from scipy import stats导入后,对于不同的c值,stats.loggamma.rvs(c, size=500)能够产生不同偏态的对数伽马分布。请利用这个函数构造一些实数区间上的偏态分布,分别使用Box-Cox方法和Yeo-Johnson方法(处理含负值的数据)来进行数据变换,并观察变换前后的数据分布变化情况以及偏度变化。 ``` ```{code-cell} ipython3 from scipy.stats import loggamma arr = np.abs(-loggamma.rvs(0.2, size=500)) _ = plt.hist(arr) pd.Series(arr).skew() ``` ```{code-cell} ipython3 from scipy.stats import boxcox arr_boxcox, Lambda = boxcox(arr) _ = plt.hist(arr_boxcox) pd.Series(arr_boxcox).skew() ``` ```{code-cell} ipython3 from scipy.stats import yeojohnson arr_yeojohnson, Lambda = yeojohnson(arr) _ = plt.hist(arr_yeojohnson) pd.Series(arr_yeojohnson).skew() ``` ```{code-cell} ipython3 from scipy.stats import loggamma arr_neg = loggamma.rvs(0.2, size=500) _ = plt.hist(arr_neg) pd.Series(arr_neg).skew() ``` ```{code-cell} ipython3 from scipy.stats import yeojohnson arr_neg_yeojohnson, Lambda = yeojohnson(arr_neg) _ = plt.hist(arr_neg_yeojohnson) pd.Series(arr_neg_yeojohnson).skew() ``` ```{admonition} 练一练 在data/ch12/passage_ch文件夹下包含了若干篇中文文档,结合jieba分词工具和sklearn中的tf-idf计算模块,提取每篇文章的主题特征。(提示:在TfidfVectorizer构造器中指定tokenizer参数为jieba.cut) ``` ```{code-cell} ipython3 from sklearn.feature_extraction.text import ( TfidfVectorizer, TfidfTransformer) import jieba passages = [] for i in range(1,21): with open("data/ch12/passage_ch/%d.txt"%i, encoding="utf8") as f: passage = " ".join(f.readlines()) passages.append(passage) vectorizer = TfidfVectorizer(tokenizer=jieba.cut) count = vectorizer.fit_transform(passages) transformer = TfidfTransformer() result = transformer.fit_transform(count) np.array(vectorizer.get_feature_names_out())[result.toarray().argmax(1)] ``` ```{admonition} 练一练 请封装一个函数best_ks(),其输入参数为需要分箱的Series、目标变量的Series、分箱的个数,输出结果为best-ks分箱的结果Series,每个元素值为原序列对应元素所属的箱子(Interval)。此处规定,当箱子中只有一个类别或只有一个元素时则不进行分箱,因此最终箱子的个数可能与给定的分箱个数可能不同。 ``` ```{code-cell} ipython3 import bisect def best_ks(s, y, n): bin_left, bin_right = s.min(), s.max()+0.001 cut_points = [bin_left, bin_right] def _helper_find(s, y): data_sorted = pd.DataFrame({"s":s, "y":y}).sort_values("s") y_0 = (data_sorted.y==0).cumsum()/(data_sorted.y==0).sum() y_1 = (data_sorted.y==1).cumsum()/(data_sorted.y==1).sum() cut_point = data_sorted.iloc[(y_1 - y_0).abs().argmax()]["s"] return cut_point def _helper_bin(s, y, left, right, n): if y.all() or (1-y).all(): # 只有一个类别了 return if y.shape[0] <= 1: # 只有一个元素了 return if n <= 0: return cut_point = _helper_find(s, y) bisect.insort(cut_points, cut_point) # 插入有序数组 bool_left = (s>=left) & (s=cut_point) & (s F_max: hit[i] += 1 if status[i] != 0: continue p_accept = binom.cdf(cur_iter - hit[i], cur_iter, 0.5) p_reject = binom.cdf(hit[i], cur_iter, 0.5) # 为什么需要判断?前面几轮实际上还不稳定,得出的概率值方差很大 if cur_iter > 5: ps[i] += 1 - p_accept counts[i] += 1 threshold = alpha / (df_reg.shape[1]-1) if p_accept <= threshold: status[i] = 1 elif p_reject <= threshold: status[i] = -1 cur_iter += 1 proba = ps / counts res = pd.DataFrame({ "Column": df_cls.columns[:-1], "Rank": pd.Series(proba).rank(ascending=False, method="first").values.astype("int"), "Proba of Accept": proba }) res ``` ## 一、卡方分箱 卡方分箱是一种利用卡方值来分箱的方法,其核心思想是:对于2个相邻的箱子(实际表示区间),如果目标$y$和箱子所属编号(即按照属于左边的箱子还是右边的箱子划分类别)之间的卡方值较小,说明这2个箱子的边界划分无法有效区分目标$y$的情况,因此需要对这2个箱子进行合并。其分箱流程为:事先指定最大分箱数量$Bin_{max}$、最小分箱数量$Bin_{min}$和阈值$\chi_0^2$,首先对数据按照最大分箱数量进行qcut得到$Bin_{max}$个箱子,接着每一轮计算所有2个相邻箱子(特征需要排序)的卡方值,合并这一轮中卡方值最小的1组相邻箱子,算法终止条件为某一轮剩余的箱子个数达到了最小分箱数量$Bin_{min}$或者所有相邻箱子的卡方值都高于给定的阈值$\chi_0^2$。sklearn内置的鸢尾花数据集是一个3分类任务的数据集,请根据上述卡方分箱的原理对于“sepal length (cm)”列进行分箱。 ```{code-cell} ipython3 from sklearn.datasets import load_iris data = load_iris() df = pd.DataFrame(data.data, columns=data.feature_names) df['target'] = data.target df.iloc[:,[0,-1]].head() ``` ```text 【解答】 ``` ```{code-cell} ipython3 def get_chi2(s, y, bins): y1 = y[(s>=bins[0])&(s=bins[1])&(s min_nbins and cur_chi2 <= chi2_0: merge_i = 1 min_chi2 = np.inf for i in range(1, len(bins)-1): adj_bin = bins[i-1:i+2] chi2 = get_chi2(s, y, adj_bin) if chi2 < min_chi2: merge_i = i min_chi2 = chi2 cur_chi2 = min_chi2 bins.pop(merge_i) cur_bins -= 1 return bins s, y = df["sepal length (cm)"], df["target"] chi2_cut(s, y, 10, 4, 3.5) # 返回箱子边界 ``` ## 二、基于标签的特征构造 本章中大多介绍的特征构造方法都是无监督方式,即不利用任何$y$中的信息来构造特征,而本章中介绍的分箱方法都是有监督方法,它们利用了$y$的信息来进行分箱的节点切割。事实上,还有很多其他利用标签的特征构造方法,请读者阅读如下的2种构建思路并完成相应任务。 - 伪标签方法是一种半监督学习方法,它将一部分可信的标签作为真实样本参与模型训练。例如对于二分类问题,许多模型都可以输出$y=1$或$y=0$的概率,当某一些测试样本输出的预测概率很高时,说明模型很有把握其被归为某一个类别,此时我们可以直接将这些样本赋予伪标签,即当$y=1$的概率为0.9998时,直接将其视为$y=1$,重新训练模型。请利用上题中提到的鸢尾花数据集,使用逻辑回归模型进行基于伪标签方法的预测。sklearn中逻辑回归的使用方法在下面的代码块中给出。 ```{code-cell} ipython3 from sklearn.linear_model import LogisticRegression X, y = load_iris(return_X_y=True) model = LogisticRegression(max_iter=150, random_state=0).fit(X, y) model.predict(X[:2, :]) # 前2个样本的预测类别 ``` ```{code-cell} ipython3 model.predict_proba(X[:2, :]) # 前2个样本属于每个类别的概率 ``` - 在回归问题中,模型预测值与真实标签值的残差有时也能作为重要特征,例如某些模型在不同的特征集合上会对真实值存在高估、低估或某种模式的有偏估计,此时可以加入残差特征来获得这种模式的信息。事实上,虽然在训练集上能够获得残差,但是在测试集上由于标签未知,我们无法知道真实的残差值,此时可以使用2阶段的方式进行模型训练。在第一阶段中,在训练集上使用模型$A$来拟合特征$X$和真实标签$y$,把该模型$A$的样本预测结果记作$\hat{y}$,将残差记作$e=y-\hat{y}$。在第二阶段中,在训练集上使用模型$B$来拟合特征$X$和残差$e$。在测试集上,我们把特征$x_{test}$输入模型$A$得到$\hat{y}_{test}$,再把特征$x_{test}$输入模型$B$得到$\hat{e}_{test}$,最后把$\hat{y}_{test}$和$\hat{e}_{test}$相加得到正式的预测输出。由于2个模型的输入部分只用了特征本身而不涉及真实标签,因此整个流程是可训练的并且利用到了残差的信息。请使用sklearn中的make\_regression()函数构造1个回归数据集,选择任意一个回归模型按照上述流程进行2阶段预测。 ```text 【解答】 ``` - 1 ```{code-cell} ipython3 from sklearn.model_selection import train_test_split from sklearn.metrics import accuracy_score X, y = load_iris(return_X_y=True) X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.5, random_state=0) model = LogisticRegression(max_iter=150, random_state=0).fit(X_train, y_train) y_predict = model.predict(X_test) acc1 = accuracy_score(y_test, y_predict) # 准确率 proba = model.predict_proba(X_test) high_confidence = (proba > 0.8).any(1) X_train_new = np.concatenate([X_train, X_test[high_confidence]]) y_train_new = np.concatenate([y_train, proba.argmax(1)[high_confidence]]) model_new = LogisticRegression(max_iter=150, random_state=0).fit( X_train_new, y_train_new) y_predict = np.empty_like(y_test, dtype="int") y_predict[high_confidence] = proba.argmax(1)[high_confidence] y_predict[~high_confidence] = model_new.predict(X_test[~high_confidence]) acc2 = accuracy_score(y_test, y_predict) # 再次计算准确率 acc1, acc2 ``` - 2 ```{code-cell} ipython3 from sklearn.datasets import make_regression from sklearn.model_selection import train_test_split X, y = make_regression(n_samples=10000, n_features=10, n_informative=3, n_targets=1, random_state=1) X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.5, random_state=0) ``` ```{code-cell} ipython3 from sklearn.tree import DecisionTreeRegressor from sklearn.metrics import mean_squared_error model = DecisionTreeRegressor(max_depth=8, random_state=0) model.fit(X_train, y_train) y_predict = model.predict(X_test) mean_squared_error(y_test, y_predict) ``` ```{code-cell} ipython3 model_A = DecisionTreeRegressor(max_depth=8, random_state=0) model_A.fit(X_train, y_train) y_hat_train = model_A.predict(X_train) e_train = y_train - y_hat_train model_B = DecisionTreeRegressor(max_depth=8, random_state=0) model_B.fit(X_train, e_train) y_hat_test = model_A.predict(X_test) e_hat_test = model_B.predict(X_test) y_predict = y_hat_test + e_hat_test mean_squared_error(y_test, y_predict) ``` ## 三、信用卡诈骗数据的特征工程 信用卡消费已经成为当今大众生活的重要消费方式之一,但目前利用信用卡诈骗交易的非法事件也越来越多,这增加了用户、银行以及金融机构的风险,因此通过交易订单的特征来预测其是否为诈骗交易是金融风控中的重要任务。现有1个信用卡诈骗交易情况的数据集(data/ch12/creditcard.csv),其中包含了28个匿名特征,1个时间特征(Time列中的数字代表自2013年9月1日0点以来的秒数)、1个交易金额特征(Amount)以及1个目标特征(Class为1代表是诈骗交易),请按照下面的思路,结合本章所学知识逐步进行特征工程。 ```{code-cell} ipython3 df = pd.read_csv("data/ch12/creditcard.csv") df.iloc[:,[0,1,2,-3,-2,-1]].head() ``` - 将Time列还原至时间戳序列。 - 基于分箱技术对特征进行变换。 - 通过Time列构造尽可能丰富的时序特征。 - 利用分组技术构造尽可能丰富的交叉特征。 - 对所有变量进行特征降维。 - 使用本章介绍的各类特征选择方法进行特征选择。 ```text 【解答】 ``` - 1 ```{code-cell} ipython3 df = pd.read_csv("data/ch12/creditcard.csv") df.Time = pd.to_timedelta(df.Time, unit="s") + pd.Timestamp("20130901") ``` - 2-6 略