数据预处理系列:资源匮乏下数据处理_Pandas内存优化和加速

发布时间:2023年12月23日

1、内存优化和加速工作流程

我们在实际工作中发现有时候数据量小于内存当前容量,也有会经常出现kernel shutdown 、或者jupyter停滞,导致无法计算下去,主要原因是内存占用过大在处理中超过了当前内存,所以本文通过优化内存占用达到可以正常计算的目的。

当我们添加特征或进行训练-测试分割时,可能会消耗比某些人可用的内存更多的内存。

我们将探讨一些可以节省内存并提高整体工作流程速度的方法,从加载数据到创建数值特征。

# 导入必要的库
import numpy as np
import pandas as pd

import os
print(os.listdir("../input"))  # 打印输入目录中的文件列表

from sklearn import preprocessing  # 导入预处理模块
import random  # 导入随机模块

2、加载文件并减小文件大小

为了测试某些操作需要多长时间,我们可以在单元格顶部使用%%time魔术命令。

将csv文件加载到内存中可能需要一些时间,因为pandas需要为每一行推断数据类型。

# 输出代码运行时间
%%time

# 读取训练数据集
train = pd.read_csv("../input/train.csv")

我们的目标是在加载对象时减少加载时间和内存使用量。

使用.info()我们可以检查数据类型和总内存使用量。为此,我们需要设置memory_usage=True,这会花费一些时间,但会返回更准确的大小。

# 查看训练数据的信息,包括数据类型和内存使用情况
train.info(memory_usage="deep")

每个DataFrame和Series还有一个.memory_usage属性,它显示以字节为单位的内存使用情况。

# 计算训练数据集的内存使用情况
train.memory_usage(deep=True)

为了更容易阅读,我们将所有值转换为兆字节。

# 计算训练数据的内存使用情况
# train是一个DataFrame对象,表示训练数据
# memory_usage()函数用于计算DataFrame对象的内存使用情况,参数deep=True表示深度计算,即计算每个元素的内存使用情况
# * 1e-6表示将内存使用情况转换为以兆字节(MB)为单位
train.memory_usage(deep=True) * 1e-6

总内存使用情况可以如下所示:

# 计算训练数据集的内存使用量
# 使用train.memory_usage(deep=True)函数计算每个列的内存使用量,并返回一个包含每个列的Series对象
# deep=True表示计算每个对象的内存使用量,而不仅仅是对象本身的大小
# 使用.sum()函数对每个列的内存使用量进行求和,得到总的内存使用量
# 使用* 1e-6将内存使用量转换为以兆字节为单位,方便阅读和理解
train.memory_usage(deep=True).sum() * 1e-6

我们可以看到,标题和描述栏占据了最多的空间,但是地区、城市、参数1-3和激活日期也使用了相当多的内存。

虽然2500 MB并不算太大,但是通过一些列组合、文本转换和在训练-测试拆分期间创建的新对象等操作,这个大小很容易增长到10+GB。

3、 使用正确的数据类型来减小文件/对象大小

减小数据大小的最简单方法之一是将列转换为正确的数据类型。目前,几乎每一列都使用object类型,这基本上是非常浪费内存的字符串。

Pandas使用Numpy的数据类型以及一些自己的补充。最重要的类型包括整数、浮点数、日期时间、布尔、字符串和分类类型。
关于它们的更多信息可以在这里找到。

3.1 datetime

最简单和最常见的方法是将日期转换为datetime类型。

# 打印"activation_date"列在转换前的内存使用情况
print("转换前的内存使用情况:", train["activation_date"].memory_usage(deep=True) * 1e-6)

# 将"activation_date"列转换为日期时间格式
train["activation_date"] = pd.to_datetime(train["activation_date"])

# 打印"activation_date"列在转换后的内存使用情况
print("转换后的内存使用情况:", train["activation_date"].memory_usage(deep=True) * 1e-6)

3.2 分类变量

具有大量重复值的文本列,例如regioncity,应该被转换为分类列。这种特殊的数据类型基本上将所有唯一值保存在字典中,然后在每一列中放置内存高效的整数,并在使用DataFrame时显示相应的文本值。这类似于用标签编码的值覆盖列,但保持可读性。

一些工具,如XGBoost和LightGBM,也可以直接使用分类列,而无需将其转换为整数标签。然而,其他一些库在使用它们时会遇到问题,需要使用字符串或整数值。

# 打印训练集中"region"列在转换前的内存使用情况
print("转换前的大小:", train["region"].memory_usage(deep=True) * 1e-6)

# 将"region"列的数据类型转换为"category"
train["region"] = train["region"].astype("category")

# 打印训练集中"region"列在转换后的内存使用情况
print("转换后的大小:", train["region"].memory_usage(deep=True) * 1e-6)
# 打印训练集 "city" 列在转换前的内存使用情况
print("转换前的内存使用情况:", train["city"].memory_usage(deep=True) * 1e-6)

# 将训练集 "city" 列的数据类型转换为 "category"
train["city"] = train["city"].astype("category")

# 打印训练集 "city" 列在转换后的内存使用情况
print("转换后的内存使用情况:", train["city"].memory_usage(deep=True) * 1e-6)

这仅适用于基数较低的列,即具有较少唯一值的列。对于像title这样超过90%的唯一值的列,这没有帮助。

为了使这更容易,我们可以定义一个函数来帮助进行分类转换,并打印出转换前后的对象大小:

# 定义一个函数convert_columns_to_catg,用于将指定的列转换为category类型
# 参数:
# - df: 要转换的数据框
# - column_list: 要转换的列名列表

def convert_columns_to_catg(df, column_list):
    # 遍历列名列表
    for col in column_list:
        # 打印正在转换的列名和其内存使用情况
        print("converting", col.ljust(30), "size: ", round(df[col].memory_usage(deep=True)*1e-6,2), end="\t")
        
        # 将列的数据类型转换为category类型
        df[col] = df[col].astype("category")
        
        # 打印转换后的列内存使用情况
        print("->\t", round(df[col].memory_usage(deep=True)*1e-6,2))
# 定义函数convert_columns_to_catg,接收train和column_list两个参数
def convert_columns_to_catg(train, column_list):
    # 遍历column_list中的每个元素
    for col in column_list:
        # 将train中的col列转换为category类型
        train[col] = train[col].astype('category')
    # 返回转换后的train数据集
    return train

总内存大小已经大大减少(从2500 MB到1300 MB),现在标题和描述占据了大部分的空间:

# 打印训练数据的内存使用情况
print(train.memory_usage(deep=True)*1e-6)

# 打印训练数据的总内存使用量
print("total:", train.memory_usage(deep=True).sum()*1e-6)

4、将对象保存为pickle文件以加快加载速度

在进行了这些基本改进之后,我们可以将DataFrame保存为所谓的pickle文件,这是整个Python对象保存到硬盘上,包含所有数据类型。与只存储原始字符串值的csv相比,pickle文件的大小要小得多。生成的文件将不会有相同的大小减小,但仍然会更小。

不幸的是,我们无法在Kaggle内核中保存较大的文件,因此我们将只将1300 MB与原始csv文件进行比较:

# 将train数据集保存为pickle文件,方便后续读取
train.to_pickle("train.pkl")
# 导入os模块,用于获取文件信息
import os

# 输出train.csv文件的大小,需要将字节转换为兆字节
print("train.csv:", os.stat('../input/train.csv').st_size * 1e-6)

# 输出train.pkl文件的大小,需要将字节转换为兆字节
print("train.pkl:", os.stat('train.pkl').st_size * 1e-6)

加载.pkl文件的速度也会快得多(请记住csv文件的20-25秒加载时间)。如果您只需进行一次数据类型改进,将所有内容保存为pickle文件,然后仅从这些文件加载,您每次重新加载数据时都可以节省时间。

# 删除train变量
del train
%%time

# 读取名为"train.pkl"的pickle文件,并将其存储在train变量中
train = pd.read_pickle("train.pkl")

当使用一个新的数据集时,我通常会创建一个第一个笔记本来加载所有相关的文件,转换数据类型,将DataFrame保存为pickle文件,然后只在主要的特征工程笔记本中加载它。这样在实际开始处理数据时可以节省时间和内存。

# 删除Kernels虚拟环境中的文件。
os.remove("train.pkl")

5、垃圾收集器

Python有一个用于控制其垃圾收集器的库,该库用于管理内存中的对象,并特别是删除不需要的对象。

在进行较大的转换、对象创建/删除或通常运行时间超过几秒钟的任何其他操作后,通过直接调用垃圾收集器可以帮助释放内存。

在您自己的计算机上,您可以使用htop、Windows任务管理器和类似的工具来监视RAM。在本笔记本中,我们可以使用psutil来显示已使用的RAM。

import gc  # 导入gc模块,用于垃圾回收
import psutil  # 导入psutil模块,用于获取系统信息
# 打印当前可用的内存信息
print("available RAM:", psutil.virtual_memory())

# 执行垃圾回收操作,释放内存空间
gc.collect()

# 再次打印当前可用的内存信息
print("available RAM:", psutil.virtual_memory())

6、数值数据,标签编码和高基数特征

虽然文本列占用了大部分内存,但如果数值列不使用最佳数据类型,随着时间的推移,它们也会逐渐增加。

我们将查看一些新创建的标签列,并探索一种同时处理高基数特征的方法。

对于基于树的模型,我们通常对分类列应用标签编码。

对于像region这样的列,这种方法效果很好,因为即使对于出现次数最少的区域,整个数据集中仍然有足够的值让树找到模式。

# 统计训练数据集中"region"列的值的频次
train["region"].value_counts()

对于像user_id这样的其他列,只有几十个用户拥有超过数百行数据,而有很多用户只有少数几行数据,这使得为每个唯一用户创建标签对于基于树的模型来说过于复杂。

# 统计训练集中每个用户的出现次数,并取出出现次数最多的前5个用户
train["user_id"].value_counts().head(5)
# 统计训练数据中每个用户的出现次数,并取出出现次数最少的5个用户
train["user_id"].value_counts().tail(5)

6.1 单列编码

当对高基数列进行标签编码时,您应该仅对具有至少20/50/100行数据的类别应用这些编码,具体取决于您的偏好。

为了使这个过程更简单,让我们定义另一个函数来使用计数阈值进行标签编码:

# 定义一个函数create_label_encoding_with_min_count,用于创建具有最小计数的标签编码
# 参数df表示数据框,column表示要进行标签编码的列,min_count表示最小计数,默认为50
def create_label_encoding_with_min_count(df, column, min_count=50):
    # 使用groupby函数按照column列进行分组,并计算每个分组的计数,将结果转换为整数类型
    column_counts = df.groupby([column])[column].transform("count").astype(int)
    # 使用np.where函数根据计数是否大于等于min_count来选择要进行标签编码的值
    column_values = np.where(column_counts >= min_count, df[column], "")
    # 将标签编码后的结果存储在train数据框的column+"_label"列中
    train[column+"_label"] = preprocessing.LabelEncoder().fit_transform(column_values)
    
    # 返回标签编码后的结果,即df数据框的column+"_label"列
    return df[column+"_label"]
# 对train数据集中的"user_id"列进行标签编码,要求每个值出现的次数不少于50
train["user_id_label"] = create_label_encoding_with_min_count(train, "user_id", min_count=50)
# 打印训练集中唯一用户的数量
print("number of unique users      :", len(train["user_id"].unique()))

# 打印训练集中唯一用户标签的数量
print("number of unique user labels:", len(train["user_id_label"].unique()))

这562个值比700k个值要容易使用得多,如果你真的会使用它们的话。

6.2 多列编码

在某些情况下,先将列连接起来,然后对结果列应用标签编码是有意义的,以帮助树模型找到结构,或者避免将属于不同父类别的类别标记为相同的数字。

在Avito数据集中,这在地区-城市层次结构中发生。许多内核分别对两列应用标签编码,这会导致信息丢失。有许多具有相同名称但属于不同地区的城市。

# 首先,我们使用train.loc[]函数来选择满足条件train["city"]=="Светлый"的数据行
train.loc[train["city"]=="Светлый", "region"].value_counts().head()

为了做到这一点,我们应该将地区和城市连接起来,以便为各个城市分配更好的标签。

由于我们无法将分类列的值相加,所以我们需要使用.apply()和一个连接函数,这种方法速度较慢,并且会创建一个内存使用量较高的新列。

%%time
train["region_city"] = train.loc[:, ["region", "city"]].apply(lambda s: " ".join(s), axis=1)
# 统计"region_city"列中的唯一值数量
print("unique:", len(train["region_city"].unique()))

# 计算"region_city"列的内存使用量,并将结果转换为以MB为单位
print("size:  ", train["region_city"].memory_usage(deep=True)*1e-6)

如果您想使用多个列组合,仅创建可能需要几分钟。

为了加快速度,我们可以使用groupby函数,并为每个列组合应用唯一值。使用.transform()方法,通常我们会将聚合值返回到每一行。在这种情况下,我们将为每个分组组合返回一个唯一的随机数。

%%time
train["region_city_2"] = train.groupby(["region", "city"])["region"].transform(lambda x: random.random())
# 打印出train["region_city_2"]列中的唯一值的数量
print("unique:", len(train["region_city_2"].unique()))

# 打印出train["region_city_2"]列的内存使用量
# 使用deep=True参数可以计算出对象本身使用的内存,而不仅仅是对象引用的内存
# 将内存使用量乘以1e-6,以MB为单位进行显示
print("size:  ", train["region_city_2"].memory_usage(deep=True)*1e-6)

这不仅速度快了10倍,而且创建了一个更小的新列,可以在LabelEncoder()中使用。



# 将train数据集中的"region_city_2"列进行标签编码,要求标签出现的最小次数为50
train["region_city_2_label"] = create_label_encoding_with_min_count(train, "region_city_2", min_count=50)
# 执行垃圾回收操作,释放内存空间
gc.collect()

6.3 数值数据大小减小

让我们添加一些更多的数值列,看看我们可以用它们的数据类型和大小做些什么。

# 给train数据集添加一个新的列"description_len",该列记录了每个样本的"description"字段的字符长度
train["description_len"] = train["description"].fillna("").apply(len)

# 给train数据集添加一个新的列"description_count_words",该列记录了每个样本的"description"字段的单词数量
# 使用lambda函数对每个样本的"description"字段进行处理,将其按空格分割为单词,并计算单词的数量
train["description_count_words"] = train["description"].fillna("").apply(lambda s: len(s.split(" ")))
# 输出train数据集中指定列的信息
train.loc[:, ["user_id_label", "region_city_2_label", "description_len", "description_count_words"]].info()

我们可以看到,pandas创建了所有新的列为int64,这意味着它们可以存储-9223372036854775808到9223372036854775807之间的值。

观察我们新列的最小值和最大值,很明显我们可以使用更小的数据类型,因为我们使用了较小的最小-最大范围:

# 遍历列表中的每个元素
for col in ["user_id_label", "region_city_2_label", "description_len", "description_count_words"]:
    # 输出当前元素,并使用ljust()函数使输出长度为30,方便观察
    print(col.ljust(30), "min:", train[col].min(), "  max:", train[col].max()) 
    # 输出当前元素在train数据集中的最小值和最大值

这些列的内存使用量目前相对较小。如果您将训练集和测试集一起使用,并创建几十个整数列,这仍然可能会快速增加数百兆字节的额外RAM使用量。

# 对于给定的数据集train,我们选择了四个特征进行处理和分析,这些特征分别是"user_id_label"、"region_city_2_label"、"description_len"和"description_count_words"。
# 使用train.loc[:, ["user_id_label", "region_city_2_label", "description_len", "description_count_words"]]可以选择这四个特征列。
# 使用memory_usage(deep=True)可以计算这四个特征列的内存使用情况。
# 乘以1e-6可以将内存使用情况转换为以兆字节(MB)为单位。
train.loc[:, ["user_id_label", "region_city_2_label", "description_len", "description_count_words"]
         ].memory_usage(deep=True)*1e-6

Pandas提供了.to_numeric方法,用于将列转换为数值,并同时将它们降低到给定值范围的最高效数据类型。

# 给train数据集添加一个名为"user_id_label"的新列
# 使用pd.to_numeric函数将train数据集中的"user_id_label"列转换为整数类型,并将转换结果存储在新列中
train["user_id_label"] = pd.to_numeric(train["user_id_label"], downcast="integer")
# 输出train中指定列的信息
train.loc[:, ["user_id_label", "region_city_2_label", "description_len", "description_count_words"]].info()

# 注意这里的int16类型
# 对训练数据进行操作
# 选择train数据中的"user_id_label", "region_city_2_label", "description_len", "description_count_words"这四列数据
# 使用.loc方法,通过索引标签选择指定的列
# 使用memory_usage方法计算所选列的内存使用情况,deep=True表示计算所有嵌套对象的内存使用情况
# 将内存使用情况乘以1e-6,将结果转换为兆字节(MB)单位
train.loc[:, ["user_id_label", "region_city_2_label", "description_len", "description_count_words"]
         ].info()

对于几十个或几百个整数列,最终减小50-75%的大小可以帮助很多。

为了将DataFrame中所有可用的整数列降低精度,我们还可以使用以下函数:

# 定义函数downcast_df_int_columns,用于将DataFrame中的整数列进行降维处理
def downcast_df_int_columns(df):
    # 获取DataFrame中的整数列,并存储在list_of_columns中
    list_of_columns = list(df.select_dtypes(include=["int32", "int64"]).columns)
    
    # 如果存在整数列
    if len(list_of_columns) >= 1:
        # 找到整数列中最长的列名长度,用于更好的状态打印
        max_string_length = max([len(col) for col in list_of_columns])
        print("downcasting integers for:", list_of_columns, "\n")
        
        # 遍历整数列
        for col in list_of_columns:
            # 打印降维前的内存使用情况
            print("reduced memory usage for:  ", col.ljust(max_string_length+2)[:max_string_length+2],
                  "from", str(round(df[col].memory_usage(deep=True)*1e-6,2)).rjust(8), "to", end=" ")
            # 将整数列的数据类型降维为整型
            df[col] = pd.to_numeric(df[col], downcast="integer")
            # 打印降维后的内存使用情况
            print(str(round(df[col].memory_usage(deep=True)*1e-6,2)).rjust(8))
    else:
        print("no columns to downcast")
    
    # 手动进行垃圾回收,释放内存
    gc.collect()
    
    # 打印处理完成的提示信息
    print("done")
downcast_df_int_columns(train)

你也可以将float64的值向下转换为float32,但是当处理大量小数位时,可能会导致数据丢失。

即使你的数据集起初较小且没有内存问题,如果将文本转换为类别、向下转换整数值,并且通常尽量避免处理大量字符串值,这也会很有帮助。

文章来源:https://blog.csdn.net/wjjc1017/article/details/135164271
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。