使用瓶颈适配器进行高效模型微调
原文:
towardsdatascience.com/efficient-model-fine-tuning-with-bottleneck-adapter-5162fcec3909
如何使用瓶颈适配器微调基于 Transformer 的模型
·发表于 Towards Data Science ·阅读时间 14 分钟·2023 年 11 月 22 日
–
照片来源:Karolina Grabowska: www.pexels.com/photo/set-of-modern-port-adapters-on-black-surface-4219861/
微调是我们为了在特定任务中从深度学习模型中获得更好性能时可以做的最常见的事情之一。我们需要微调模型的时间通常与模型的大小成正比:模型越大,微调所需的时间就越长。
我们可以达成一致的是,如今,基于 Transformer 的深度学习模型正变得越来越复杂。总体来说,这是一个值得关注的好现象,但它有一个警告:它们往往拥有庞大的参数量。因此,微调大型模型变得越来越难以管理,我们需要一种更高效的方法来进行微调。
在本文中,我们将讨论一种称为瓶颈适配器的高效微调方法。虽然您可以将这种方法应用于任何深度学习模型,但我们将重点关注其在基于 Transformer 的模型中的应用。
本文的结构如下:首先,我们将对一个特定数据集进行正常的 BERT 模型微调。然后,我们将借助adapter-transformers
库将一些瓶颈适配器插入到 BERT 模型中,以查看它们如何帮助我们使微调过程更高效。
在我们微调模型之前,让我们先介绍一下我们将使用的数据集。
关于数据集
我们即将使用的数据集包含从 Reddit 收集的与心理健康相关的不同类型文本(许可协议为 CC-BY-4.0)。该数据集适用于文本分类任务,我们可以预测给定文本是否包含抑郁情绪。让我们看一下它的样本。
!pip install datasets
from datasets import load_dataset
dataset = load_dataset("mrjunos/depression-reddit-cleaned")
print(dataset['train'][2])
'''
{'text': 'anyone else instead of sleeping more when depressed stay up all night to avoid the next day from coming sooner may be the social anxiety in me but life is so much more peaceful when everyone else is asleep and not expecting thing of you',
'label': 1}
'''
如你所见,数据集非常简单,因为我们只有两个字段:一个是文本,另一个是标签。标签本身只有两个可能的值:如果文本包含抑郁情绪则为 1,否则为 0。我们的任务是微调一个预训练的 BERT 模型,以预测每个文本的情感。
总共有大约 7731 个文本,我们将使用其中 6500 个进行训练,其余 1231 个用于微调过程中的验证。
让我们创建一个数据加载器,在微调过程中批量加载数据集,我们将在下一节中看到:
!pip install pip install adapter-transformers
import torch
import numpy as np
from transformers import BertTokenizer
tokenizer = BertTokenizer.from_pretrained('bert-base-cased')
class Dataset(torch.utils.data.Dataset):
def __init__(self, input_data):
self.labels = [data for data in input_data['label']]
self.texts = [tokenizer(data,
padding='max_length', max_length = 512, truncation=True,
return_tensors="pt") for data in input_data['text']]
def __len__(self):
return len(self.labels)
def get_batch_labels(self, idx):
return np.array(self.labels[idx])
def get_batch_texts(self, idx):
return self.texts[idx]
def __getitem__(self, idx):
batch_texts = self.get_batch_texts(idx)
batch_y = self.get_batch_labels(idx)
return batch_texts, batch_y
现在我们有了数据,可以开始讨论本文的主要话题。然而,如果我们已经熟悉普通微调的标准过程,那么理解瓶颈适配器的概念会更容易。
因此,在下一节中,我们将从普通微调过程的概念开始,然后扩展到瓶颈适配器的应用。
我们将使用adapter-transformers
库来进行普通微调和基于适配器的微调。这个库是著名的 HuggingFace 的transformers
库的直接分支,这意味着它包含了transformers
的所有功能,并增加了几个模型类和方法,以便我们可以轻松地将适配器应用到模型中。
你可以使用以下命令安装adapter-transformers
:
pip install adapter-transformers
现在让我们开始讨论普通微调的常规过程。
普通 BERT 微调
微调是深度学习中的一种常见技术,旨在从预训练模型中获得在特定数据和/或任务上的更好性能。其主要思想很简单:我们获取预训练模型的权重,然后基于新的领域特定数据更新这些权重。
普通微调过程。图片由作者提供。
普通微调的常规过程如下。
首先,我们选择一个预训练模型,在我们的情况下是 BERT-base 模型。顺便提一下,我们在这篇文章中不会专注于 BERT,但如果你对 BERT 不熟悉并想了解更多,可以查看我关于 BERT 的文章:
如何利用 Hugging Face 的预训练 BERT 模型来分类新闻文章的文本
[towardsdatascience.com
简而言之,BERT-base 包含 12 层 Transformer 编码器。在微调过程中,我们需要在最后一层上添加一个线性层,作为分类器。由于我们数据集中的标签仅包含两个可能的值,因此我们的线性层的输出也将是两个。
from torch import nn
from transformers import BertForSequenceClassification
class BertClassifier(nn.Module):
def __init__(self, model_id='bert-base-cased', num_class=2):
super(BertClassifier, self).__init__()
self.bert = BertForSequenceClassification.from_pretrained(model_id, num_labels=num_class)
def forward(self, input_id, mask):
output = self.bert(input_ids= input_id, attention_mask=mask,return_dict=False)
return output
BERT 架构。图片由作者提供。
现在我们已经定义了我们的模型,我们需要创建微调脚本。以下是对模型进行微调的代码片段。
from torch.optim import Adam
from tqdm import tqdm
def train(model, train_data, val_data, learning_rate, epochs):
# Fetch training and validation data in batch
train, val = Dataset(train_data), Dataset(val_data)
train_dataloader = torch.utils.data.DataLoader(train, batch_size=2, shuffle=True)
val_dataloader = torch.utils.data.DataLoader(val, batch_size=2)
use_cuda = torch.cuda.is_available()
device = torch.device("cuda" if use_cuda else "cpu")
criterion = nn.CrossEntropyLoss()
optimizer = Adam(model.parameters(), lr= learning_rate)
if use_cuda:
model = model.to(device)
criterion = criterion.to(device)
for epoch_num in range(epochs):
total_acc_train = 0
total_loss_train = 0
# Fine-tune the model
for train_input, train_label in tqdm(train_dataloader):
train_label = train_label.to(device)
mask = train_input['attention_mask'].to(device)
input_id = train_input['input_ids'].squeeze(1).to(device)
output = model(input_id, mask)[0]
batch_loss = criterion(output, train_label.long())
total_loss_train += batch_loss.item()
acc = (output.argmax(dim=1) == train_label).sum().item()
total_acc_train += acc
model.zero_grad()
batch_loss.backward()
optimizer.step()
total_acc_val = 0
total_loss_val = 0
# Validate the model
with torch.no_grad():
for val_input, val_label in val_dataloader:
val_label = val_label.to(device)
mask = val_input['attention_mask'].to(device)
input_id = val_input['input_ids'].squeeze(1).to(device)
output = model(input_id, mask)[0]
batch_loss = criterion(output, val_label.long())
total_loss_val += batch_loss.item()
acc = (output.argmax(dim=1) == val_label).sum().item()
total_acc_val += acc
print(
f'Epochs: {epoch_num + 1} | Train Loss: {total_loss_train / len(train): .3f} \
| Train Accuracy: {total_acc_train / len(train): .3f} \
| Val Loss: {total_loss_val / len(val): .3f} \
| Val Accuracy: {total_acc_val / len(val): .3f}')
我们将对我们的 BERT 模型进行大约 10 个周期的微调,学习率设置为 10e-7。 我在 T4 GPU 上使用批量大小为 2 进行了模型微调。以下是训练和验证准确度的快照。
EPOCHS = 10
LR = 1e-7
model = BertClassifier()
data = dataset['train'].shuffle(seed=42)
train(model, data[:6500], data[6500:], LR, EPOCHS)
100%|███████████████████████████████████ 3250/3250 [11:56<00:00, 4.54it/s]
Epochs: 1 | Train Loss: 0.546 | Train Accuracy: 0.533 | Val Loss: 0.394 | Val Accuracy: 0.847
100%|███████████████████████████████████ 3250/3250 [11:57<00:00, 4.53it/s]
Epochs: 2 | Train Loss: 0.302 | Train Accuracy: 0.888 | Val Loss: 0.226 | Val Accuracy: 0.906
100%|███████████████████████████████████ 3250/3250 [11:57<00:00, 4.53it/s]
Epochs: 3 | Train Loss: 0.184 | Train Accuracy: 0.919 | Val Loss: 0.149 | Val Accuracy: 0.930
100%|███████████████████████████████████ 3250/3250 [11:57<00:00, 4.53it/s]
Epochs: 4 | Train Loss: 0.122 | Train Accuracy: 0.946 | Val Loss: 0.101 | Val Accuracy: 0.955
100%|███████████████████████████████████ 3250/3250 [11:57<00:00, 4.53it/s]
Epochs: 5 | Train Loss: 0.084 | Train Accuracy: 0.963 | Val Loss: 0.075 | Val Accuracy: 0.968
100%|███████████████████████████████████ 3250/3250 [11:56<00:00, 4.53it/s]
Epochs: 6 | Train Loss: 0.063 | Train Accuracy: 0.969 | Val Loss: 0.061 | Val Accuracy: 0.970
100%|███████████████████████████████████ 3250/3250 [11:57<00:00, 4.53it/s]
Epochs: 7 | Train Loss: 0.050 | Train Accuracy: 0.974 | Val Loss: 0.054 | Val Accuracy: 0.973
100%|███████████████████████████████████ 3250/3250 [11:57<00:00, 4.53it/s]
Epochs: 8 | Train Loss: 0.042 | Train Accuracy: 0.978 | Val Loss: 0.049 | Val Accuracy: 0.972
100%|███████████████████████████████████ 3250/3250 [11:57<00:00, 4.53it/s]
Epochs: 9 | Train Loss: 0.035 | Train Accuracy: 0.982 | Val Loss: 0.047 | Val Accuracy: 0.973
100%|███████████████████████████████████ 3250/3250 [11:57<00:00, 4.53it/s]
Epochs: 10 | Train Loss: 0.030 | Train Accuracy: 0.984 | Val Loss: 0.046 | Val Accuracy: 0.966
就这样!我们在数据集上使用 BERT 达到了 97.3% 的验证准确率。然后我们可以继续使用微调后的模型对未见数据进行预测。
总体来说,如果我们的模型具有“小”数量的参数,正常的微调不会成为问题,如上所示的 BERT 模型。让我们检查一下我们的 BERT-base 模型的总参数数量。
def print_trainable_parameters(model):
trainable_params = 0
all_param = 0
for _, param in model.named_parameters():
all_param += param.numel()
if param.requires_grad:
trainable_params += param.numel()
print(
f"trainable params: {trainable_params} || all params: {all_param} || trainable%: {100 * trainable_params / all_param}"
)
print_trainable_parameters(model)
'''
trainable params: 108311810 || all params: 108311810 || trainable%: 100.0
'''
这个模型总共有接近 1.1 亿个参数。虽然看起来很多,但与现在的大多数大型语言模型相比,这仍然不算什么,因为它们可能有数十亿个参数。如果你也注意到,可训练参数的数量与我们 BERT 模型的总参数数量相同。这意味着在正常的微调过程中,我们会更新 BERT 模型的所有参数的权重。
借助 T4 GPU 和我们的训练数据集仅包含 6500 条数据,我们幸运地只需大约 12 分钟每个周期来更新所有权重。现在想象一下,如果我们使用更大的模型和数据集,进行正常微调的计算时间将会非常昂贵。
此外,正常的微调通常与所谓的灾难性遗忘风险相关,如果我们在选择学习率时不小心,或者当我们尝试在多个任务/数据集上微调预训练模型时。灾难性遗忘指的是当我们在新任务上微调预训练模型时,它会“遗忘”其训练过的任务。
因此,我们确实需要一种更高效的程序来进行微调过程。这就是我们可以使用不同类型的高效微调方法的地方,其中瓶颈适配器就是其中之一。
瓶颈适配器的工作原理
适配器的主要思想是,我们引入一小部分层,并将其放置在预训练模型的原始架构中。在微调过程中,我们冻结预训练模型的所有参数,因此,只有这些附加子集层的权重会被更新。
瓶颈适配器特指一种由两个普通前馈层组成的适配器,前后可选地添加归一化层。一个前馈层的功能是缩小输出,而另一个是放大输出。这就是为什么它被称为瓶颈适配器的原因。
常见的瓶颈适配器。图片由作者提供。
你可以将这个适配器应用于任何深度学习模型,但如前所述,我们将重点关注其在基于 Transformer 的模型上的应用。
基于 Transformer 的模型通常由多个 Transformer 层堆叠组成。例如,本文使用的基于 BERT 的模型有 12 个 Transformer 编码器层堆叠。每个堆叠包括以下组件:
Transformer 编码器堆叠。图片由作者提供。
我们可以将瓶颈适配器放入这个堆叠的几种不同方式。然而,有两种常见的配置:一种是 Pfeiffer 提出的,另一种是 Houlsby 提出的。
Pfeiffer 提出的瓶颈适配器插入在最后的规范层之后,而 Houlsby 提出的瓶颈适配器插入在两个不同的位置:一个在多头注意力层之后,另一个在前馈层之后,如下图所示:
Pfeiffer 和 Houlsby 适配器配置的区别。图片由作者提供。
由于我们的 BERT-base 模型有 12 个 Transformer 编码器层堆叠,因此如果使用 Pfeiffer 配置,我们将有 12 个瓶颈适配器:每个堆叠一个适配器。同时,如果使用 Houlsby 配置,我们将有 24 个瓶颈适配器:每个堆叠两个适配器。
尽管 Pfeiffer 配置相比于 Houlsby 配置参数更少,但在 8 个不同任务中的表现相当。
现在的问题是:这个瓶颈适配器是如何让微调过程更高效的?
如前所述,我们在微调过程中冻结预训练模型的权重,只更新适配器的权重。这意味着我们可以显著加快微调过程,接下来的部分会展示这一点。实验也表明,使用适配器进行微调的性能通常与普通微调相当。
同时,假设我们想用相同的预训练模型处理两个不同的数据集。我们可以使用一个模型,通过两个不同的适配器在不同的数据集上进行微调,从而避免灾难性遗忘的风险,而不是拥有两个在不同数据集上微调的模型。
图片由作者提供。
使用这种方法,我们节省了大量存储空间。例如,一个单独的 BERT-base 模型的大小是 440 MB,如果我们有两个模型,则为 880 MB。与此同时,如果我们有一个带有两个适配器的 BERT-base 模型,大小大约为 450 MB,因为瓶颈适配器只占用少量内存。
瓶颈适配器实现
在本节中,我们将实现 Pfeiffer 版本的瓶颈适配器。为此,我们只需要更改模型架构的脚本,而与微调过程和数据加载相关的脚本保持不变。
让我们使用 Pfeiffer 的适配器定义模型架构。
from transformers import AdapterConfig
from transformers.adapters import BertAdapterModel
class BertClassifierWithAdapter(nn.Module):
def __init__(self, model_id='bert-base-cased', adapter_id='pfeiffer',
task_id = 'depression_reddit_dataset', num_class=2):
super(BertClassifierWithAdapter, self).__init__()
self.adapter_config = AdapterConfig.load(adapter_id)
self.bert = BertAdapterModel.from_pretrained(model_id)
# Insert adapter according to configuration
self.bert.add_adapter(task_id, config=self.adapter_config)
# Freeze all BERT-base weights
self.bert.train_adapter(task_id)
# Add prediction layer on top of BERT-base
self.bert.add_classification_head(task_id, num_labels=num_class)
# Make sure that adapters and prediction layer are used during forward pass
self.bert.set_active_adapters(task_id)
def forward(self, input_id, mask):
output = self.bert(input_ids= input_id, attention_mask=mask,return_dict=False)
return output
如你所见,实现适配器版本的模型非常简单:
-
使用
AdapterConfig.load('pfeiffer')
定义我们想要应用的适配器配置。如果你想使用 Houlsby 配置,只需将其更改为'houlsby'
。 -
使用
add_adapter()
方法将适配器插入到我们的 BERT 模型中。常见做法是根据任务或数据集为适配器命名,以便我们希望模型进行微调。 -
使用
train_adapter()
方法冻结预训练模型的所有权重。 -
使用
add_classification_head()
方法在 BERT 模型上添加一个线性层,作为预测头。常见做法是为预测头取与适配器相同的名称。 -
激活我们的适配器和预测头,以确保它们在每次前向传递中都被使用,使用
set_active_adapters()
方法。
现在,让我们检查在添加适配器后参数的总数和可训练参数的比例:
# Initialize model
# task_id is the name of our adapter. You can name it whatever you want but
# common practice is to name it according to task/dataset we will train it on.
task_name = 'depression_reddit_dataset'
model_adapter = BertClassifierWithAdapter(task_id=task_name)
# Check parameters
print_trainable_parameters(model_adapter)
'''
trainable params: 1486658 || all params: 109796930 || trainable%: 1.3540068925424418
'''
带有适配器的模型参数比我们原始的 BERT-base 模型多,但只有 1.35%是可训练的,因为我们只会更新适配器的权重。
现在是训练模型的时候了。由于适配器的权重是随机初始化的,因此这次我们将使用略高的学习率。我们还将训练该模型 10 个时期。如果一切顺利,你将获得类似如下的输出:
LR = 5e-6
EPOCHS = 10
train(model_adapter, dataset['train'][:6500], dataset['train'][6500:], LR, EPOCHS)
100%|███████████████████████████████████████████ 3250/3250 [07:19<00:00, 7.40it/s]
Epochs: 1 | Train Loss: 0.183 | Train Accuracy: 0.846 | Val Loss: 0.125 | Val Accuracy: 0.897
100%|███████████████████████████████████████████ 3250/3250 [07:24<00:00, 7.32it/s]
Epochs: 2 | Train Loss: 0.096 | Train Accuracy: 0.925 | Val Loss: 0.072 | Val Accuracy: 0.946
100%|███████████████████████████████████████████ 3250/3250 [07:23<00:00, 7.32it/s]
Epochs: 3 | Train Loss: 0.060 | Train Accuracy: 0.958 | Val Loss: 0.052 | Val Accuracy: 0.962
100%|███████████████████████████████████████████ 3250/3250 [07:21<00:00, 7.37it/s]
Epochs: 4 | Train Loss: 0.044 | Train Accuracy: 0.968 | Val Loss: 0.047 | Val Accuracy: 0.971
100%|███████████████████████████████████████████ 3250/3250 [07:25<00:00, 7.30it/s]
Epochs: 5 | Train Loss: 0.038 | Train Accuracy: 0.971 | Val Loss: 0.043 | Val Accuracy: 0.973
100%|███████████████████████████████████████████ 3250/3250 [07:25<00:00, 7.29it/s]
Epochs: 6 | Train Loss: 0.034 | Train Accuracy: 0.975 | Val Loss: 0.039 | Val Accuracy: 0.971
100%|███████████████████████████████████████████ 3250/3250 [07:25<00:00, 7.29it/s]
Epochs: 7 | Train Loss: 0.032 | Train Accuracy: 0.978 | Val Loss: 0.038 | Val Accuracy: 0.972
100%|███████████████████████████████████████████ 3250/3250 [07:25<00:00, 7.29it/s]
Epochs: 8 | Train Loss: 0.029 | Train Accuracy: 0.980 | Val Loss: 0.039 | Val Accuracy: 0.974
100%|███████████████████████████████████████████ 3250/3250 [07:25<00:00, 7.29it/s]
Epochs: 9 | Train Loss: 0.027 | Train Accuracy: 0.980 | Val Loss: 0.035 | Val Accuracy: 0.971
100%|███████████████████████████████████████████ 3250/3250 [07:19<00:00, 7.40it/s]
如你所见,带有适配器的模型性能与完全微调版本的模型相当。而且,完成一个时期所需的时间比完全微调快 4.5 分钟。
既然我们已经训练好了它,我们可以保存适配器。
# Save trained model with adapter
model_adapter_path = 'model/bert_adapter/'
model_adapter.bert.save_all_adapters(model_adapter_path)
然后我们可以加载适配器并进行推理,如下所示:
def predict(data, model):
inputs = tokenizer(data['text'],
padding='max_length', max_length=512, truncation=True,
return_tensors="pt")
mask = inputs['attention_mask'].to(device)
input_id = inputs['input_ids'].squeeze(1).to(device)
output = model(input_id, mask)[0].argmax(dim=1).item()
print(output)
use_cuda = torch.cuda.is_available()
device = torch.device("cuda" if use_cuda else "cpu")
model_path = f'{model_adapter_path}{task_name}'
# Load trained adapter
trained_adapter_model = BertClassifierWithAdapter(task_id=task_name)
adapter_name = trained_adapter_model.bert.load_adapter(model_path)
trained_adapter_model.bert.set_active_adapters(adapter_name)
trained_adapter_model.to(device)
trained_adapter_model.eval()
# Predict test data
predict(data[6900], trained_adapter_model)
结论
在本文中,我们已经看到瓶颈适配器在大型模型的微调过程中是如何有帮助的。使用瓶颈适配器,我们能够加快微调速度,同时保持模型的最终性能。这些适配器也有助于避免通常与微调模型相关的灾难性遗忘风险。此外,这些适配器不会占用大量内存空间。
我希望这篇文章对你在使用瓶颈适配器时有所帮助。如果你想查看本文中实现的所有代码,可以通过这个笔记本访问。
数据集参考
在 Neo4j 中高效的语义搜索
将新添加的向量索引集成到 LangChain 中,以提升你的 RAG 应用程序
·
关注 发表在 Towards Data Science ·7 分钟阅读·2023 年 8 月 23 日
–
自从六个月前 ChatGPT 问世以来,技术领域经历了变革性的转变。ChatGPT 出色的概括能力减少了对专业深度学习团队和大量训练数据集的需求,从而使得创建定制的 NLP 模型变得更加容易。这使得诸如摘要和信息提取等 NLP 任务的获取变得比以往任何时候都更加容易。然而,我们很快意识到类似 ChatGPT 模型的局限性,如知识截止日期和无法访问私人信息。在我看来,紧接着出现的是生成性 AI 转型的第二波浪潮,即检索增强生成(RAG)应用的兴起,你可以在查询时向模型提供相关信息,以构建更好、更准确的答案。
RAG 应用流程。图像由作者提供。图标来自www.flaticon.com/
如前所述,RAG 应用程序需要一个智能搜索工具,能够根据用户输入检索额外信息,这使得 LLMs 能够生成更准确和最新的答案。最初,重点主要是使用语义搜索从非结构化文本中检索信息。然而,很快就明显看出,结构化和非结构化数据的组合是 RAG 应用程序的最佳方法,如果你想要超越“与 PDF 对话”应用。
Neo4j 曾经并且现在仍然非常适合处理结构化信息,但由于其粗暴的方法,它在语义搜索方面有些吃力。然而,这种困难已经成为过去,因为 Neo4j 已经在5.11 版本中引入了新的向量索引,旨在高效地对非结构化文本或其他嵌入数据模式执行语义搜索。新添加的向量索引使 Neo4j 非常适合大多数 RAG 应用,因为它现在可以很好地处理结构化和非结构化数据。
在这篇博客文章中,我将向你展示如何在 Neo4j 中设置向量索引,并将其集成到LangChain 生态系统中。代码可以在GitHub上找到。
Neo4j 环境设置
你需要设置 Neo4j 5.11 或更高版本,以跟随本博客中的示例。最简单的方法是启动一个免费的Neo4j Aura实例,该平台提供 Neo4j 数据库的云实例。或者,你也可以通过下载Neo4j Desktop应用程序并创建一个本地数据库实例,来设置 Neo4j 数据库的本地实例。
实例化 Neo4j 数据库后,你可以使用 LangChain 库连接到它。
from langchain.graphs import Neo4jGraph
NEO4J_URI="neo4j+s://1234.databases.neo4j.io"
NEO4J_USERNAME="neo4j"
NEO4J_PASSWORD="-"
graph = Neo4jGraph(
url=NEO4J_URI,
username=NEO4J_USERNAME,
password=NEO4J_PASSWORD
)
设置向量索引
Neo4j 向量索引由 Lucene 提供支持,其中 Lucene 实现了一个层次导航的小世界 (HNSW) 图,以在向量空间上执行近似最近邻 (ANN) 查询。
Neo4j 对向量索引的实现旨在索引节点标签的单个节点属性。例如,如果你想要对标签为 Chunk
的节点的 embedding
属性进行索引,你可以使用以下 Cypher 程序。
CALL db.index.vector.createNodeIndex(
'wikipedia', // index name
'Chunk', // node label
'embedding', // node property
1536, // vector size
'cosine' // similarity metric
)
除了索引名称、节点标签和属性外,你还必须指定向量大小(嵌入维度)和相似度度量。我们将使用 OpenAI 的 text-embedding-ada-002 嵌入模型,该模型使用向量大小 1536 来表示嵌入空间中的文本。目前,仅提供 余弦 和 欧几里得 相似度度量。OpenAI 建议在使用其嵌入模型时使用余弦相似度度量。
填充向量索引
Neo4j 的设计是无模式的,这意味着它不强制对节点属性中的内容施加任何限制。例如,embedding
属性可以存储整数、整数列表甚至字符串。我们来尝试一下。
WITH [1, [1,2,3], ["2","5"], [x in range(0, 1535) | toFloat(x)]] AS exampleValues
UNWIND range(0, size(exampleValues) - 1) as index
CREATE (:Chunk {embedding: exampleValues[index], index: index})
这个查询为列表中的每个元素创建一个 Chunk
节点,并将元素用作 embedding
属性值。例如,第一个 Chunk
节点的 embedding
属性值为 1,第二个节点为 [1,2,3],依此类推。Neo4j 对节点属性下可以存储的内容没有强制规则。然而,向量索引对它应该索引的值类型和嵌入维度有明确的指示。
我们可以通过执行向量索引搜索来测试哪些值已被索引。
CALL db.index.vector.queryNodes(
'wikipedia', // index name
3, // topK neighbors to return
[x in range(0,1535) | toFloat(x) / 2] // input vector
)
YIELD node, score
RETURN node.index AS index, score
如果你运行这个查询,你将只返回一个单独的节点,即使你请求返回前 3 个邻居。这是为什么呢?向量索引仅索引属性值,其中值是具有指定大小的浮点数列表。在这个示例中,只有一个 embedding
属性值具有浮点数列表类型,并且长度为 1536。
如果满足以下所有条件,则节点会按向量索引进行索引:
-
节点包含配置的标签。
-
节点包含配置的属性键。
-
相应的属性值类型为
LIST<FLOAT>
。 -
相应值的
[size()](https://neo4j.com/docs/cypher-manual/current/functions/scalar/#functions-size)
与配置的维度是相同的。 -
该值是配置的相似度函数的有效向量。
将向量索引集成到 LangChain 生态系统中
现在我们将实现一个简单的自定义 LangChain 类,该类将使用 Neo4j 向量索引来检索相关信息,以生成准确和最新的答案。但首先,我们必须填充向量索引。
使用 Neo4j 向量索引在 RAG 应用中的数据流。图像由作者提供。图标来自 flaticons。
任务将包括以下步骤:
-
检索一篇维基百科文章
-
切分文本
-
在 Neo4j 中存储文本及其向量表示
-
实现一个自定义 LangChain 类以支持 RAG 应用
在这个例子中,我们将只获取一篇维基百科文章。我决定使用Baldur’s Gate 3 页面。
import wikipedia
bg3 = wikipedia.page(pageid=60979422)
接下来,我们需要对文本进行切分和嵌入。我们将通过双换行符分隔符按部分切分文本,然后使用 OpenAI 的嵌入模型为每个部分表示一个合适的向量。
import os
from langchain.embeddings import OpenAIEmbeddings
os.environ["OPENAI_API_KEY"] = "API_KEY"
embeddings = OpenAIEmbeddings()
chunks = [{'text':el, 'embedding': embeddings.embed_query(el)} for
el in bg3.content.split("\n\n") if len(el) > 50]
在我们继续讲解 LangChain 类之前,需要将文本块导入 Neo4j。
graph.query("""
UNWIND $data AS row
CREATE (c:Chunk {text: row.text})
WITH c, row
CALL db.create.setVectorProperty(c, 'embedding', row.embedding)
YIELD node
RETURN distinct 'done'
""", {'data': chunks})
有一点值得注意的是,我使用了db.create.setVectorProperty
过程来将向量存储到 Neo4j 中。此过程用于验证属性值确实是浮点数列表。此外,它还有助于将向量属性的存储空间减少约 50%。因此,建议始终使用此过程将向量存储到 Neo4j 中。
现在我们可以实现自定义的 LangChain 类,用于从 Neo4j 向量索引中检索信息,并用它生成答案。首先,我们将定义用于检索信息的 Cypher 语句。
vector_search = """
WITH $embedding AS e
CALL db.index.vector.queryNodes('wikipedia',$k, e) yield node, score
RETURN node.text AS result
"""
如您所见,我已硬编码了索引名称。如果您愿意,可以通过添加适当的参数使其动态化。
自定义的 LangChain 类实现得非常直接。
class Neo4jVectorChain(Chain):
"""Chain for question-answering against a Neo4j vector index."""
graph: Neo4jGraph = Field(exclude=True)
input_key: str = "query" #: :meta private:
output_key: str = "result" #: :meta private:
embeddings: OpenAIEmbeddings = OpenAIEmbeddings()
qa_chain: LLMChain = LLMChain(llm=ChatOpenAI(temperature=0), prompt=CHAT_PROMPT)
def _call(self, inputs: Dict[str, str], run_manager, k=3) -> Dict[str, Any]:
"""Embed a question and do vector search."""
question = inputs[self.input_key]
# Embed the question
embedding = self.embeddings.embed_query(question)
# Retrieve relevant information from the vector index
context = self.graph.query(
vector_search, {'embedding': embedding, 'k': 3})
context = [el['result'] for el in context]
# Generate the answer
result = self.qa_chain(
{"question": question, "context": context},
)
final_result = result[self.qa_chain.output_key]
return {self.output_key: final_result}
我省略了一些模板代码以使其更易读。实质上,当您调用 Neo4jVectorChain 时,会执行以下步骤:
-
使用相关的嵌入模型对问题进行嵌入
-
使用文本嵌入值从向量索引中检索最相似的内容
-
使用类似内容提供的上下文生成答案
我们现在可以测试我们的实现。
vector_qa = Neo4jVectorChain(graph=graph, embeddings=embeddings, verbose=True)
vector_qa.run("What is the gameplay of Baldur's Gate 3 like?")
响应
生成的响应。图像由作者提供。
通过使用verbose
选项,您还可以评估从向量索引中检索的上下文,这些上下文用于生成答案。
总结
利用 Neo4j 的新向量索引功能,您可以创建一个统一的数据源,有效支持检索增强生成应用。这不仅使您能够实现“与 PDF 或文档聊天”解决方案,还能进行实时分析,所有这些都来自一个强大的数据源。这种多用途的实用工具可以简化您的操作并增强数据协同,使 Neo4j 成为管理结构化和非结构化数据的绝佳解决方案。
一如既往,代码可在GitHub上找到。
高效服务开源 LLM
原文:
towardsdatascience.com/efficiently-serving-open-source-llms-5f0bf5d8fd59
·发布在 Towards Data Science ·阅读时长 5 分钟·2023 年 8 月 14 日
–
图片来源:Mariia Shalabaieva 于 Unsplash
本文解释了我个人使用 6 种常见方法服务开源 LLM 的经验:AWS Sage Maker、Hugging Face、Together.AI、VLLM 和 Petals.ml。
挣扎…
你已经感受到了服务自己微调的开源 LLM 的痛苦、挣扎和荣耀,但你最终因为成本、推理时间、可靠性和技术挑战而决定回到 Open AI 或 Anthropic 😦 你也放弃了租用 A100 GPU(许多供应商的 GPU 已经被预订到 2023 年底!)。你也没有 10 万美元去购买一个 2 级 A100 服务器。尽管如此,你仍在梦想,你真的希望开源能够为你的解决方案服务。也许你的公司不愿意将私人数据发送给 Open AI,或者你需要一个针对特定任务微调的模型?在本文中,我将概述并比较一些最有效的推理方法/平台,用于服务开源 LLM。在 2023 年,我将对 6 种方法进行比较,并解释何时应该使用其中一种或另一种。我亲自尝试了这 6 种方法,并将详细介绍我的个人经验:AWS Sage Maker、Hugging Face 推理端点、Together.AI、VLLM 和 Petals.ml。我没有所有的答案,但我会尽力详细说明我的经验。我与这些供应商没有任何经济联系,仅仅是分享我的经验以造福他人。请分享你的经验!
为什么选择开源?
开源模型有许多优点,包括控制、隐私和潜在的成本降低。例如,你可以针对特定的使用案例微调一个较小的开源模型,从而获得准确的结果和快速的推理时间。隐私控制意味着推理可以在自己的服务器上完成。另一方面,成本降低比你想象的要困难得多。OpenAI 拥有规模经济,定价具有竞争力。他们的 GPT-3.5 Turbo 定价模式很难与之竞争,并且已被证明类似于电力成本。不过,你仍然可以采用一些方法和技巧来节省开支,并用开源模型获得优秀的结果。例如,我的微调模型 Stable Beluga 2 目前显著优于 GPT-3.5 Turbo,并且在我的应用中更便宜。因此,我强烈建议你尝试使用开源模型。
Hugging Face 推理端点
这是服务开源 LLM 最常见且最简单的方法。只需点击几下即可,且几乎没有错误。毕竟,Hugging Face 最初是一家 NLP 公司。你的模型很可能已经存在于 Hugging Face 上,因此这是快速测试模型的首选选项。GPU 服务器成本往往较高。例如,如果你仅使用 RunPod.io 部署模型,你将有更多的提供商选择,并且成本更低。Hugging Face 已开源了他们的 Transformers 推理库,并提供了易于修改的 Docker 镜像。因此,如果你需要更多控制,可以选择在 RunPod 上的自定义解决方案。 这是一个关于如何在 RunPod 上操作的教程。
VLLM
这个解决方案由于其推理速度而非常有趣。他们声称比 Hugging Face 的 Transformers 快 24 倍!在我个人使用时,发现速度大约是 Hugging Face Transformers 的 10 倍。不过,我发现这里有一些小 bug。这个项目正在积极开发中,尚未完善。不过,我仍然强烈建议你试试这个解决方案。由于推理速度更快,相较于 HF Transformers,成本将显著降低。
来源: github.com/vllm-project/vllm
Petals.ml
这个是最有趣的解决方案。Petals.ml 的开发者发现了一种在家运行 LLMs 的方法,类似于 BitTorrent。这使得微调和推理的速度比卸载快最多 10 倍。实际上,这意味着模型的只有一小部分会加载到你自己的 GPU 上,其余部分会存在于 GPU 网络群中。换句话说,一个 GPU 网络将协作进行计算。这非常有趣,因为它在一定程度上使 LLM 的使用得到民主化,即任何人都可以运行大型 LLM 而不花一分钱!相关技术的论文可以在这里找到。我强烈建议你试试 Petals.ml!
Together.AI
他们提供了一个具有出色定价的开源模型 API。你可以使用 Together.AI 计算集群对开源模型进行微调和部署。他们的定价是 AWS 的 20%。他们的平台简单直观,容易上手。因此,我强烈推荐这个平台。他们的 API 价格大约是GPT-3.5 turbo 的 1/10。这是我现在最喜欢的开源模型部署方式!
AWS Sagemaker
部署 ML 模型的成熟方法。Sagemaker 对初学者不太友好,而且与上述方法相比,它可能是最昂贵的。它也是最复杂的。然而,如果你的业务已经在使用 AWS,这可能是你唯一的选择。此外,如果你像我一样在 AWS 上有免费的计算资源,为什么不尝试一下呢?这是 AI Anytime 的教程:www.youtube.com/watch?v=A9Pu4xg-Nas&ab_channel=AIAnytime
。
结论:
总结来说,我强烈建议尝试 Together.AI 和 Petals.ml,因为使用这些平台有许多优势。如果你需要隐私和非常快的推理速度,我建议使用 VLLM。如果你被迫使用 AWS,那么选择 SageMaker。如果你想要简单高效的方案(特别是用于测试),可以选择 HF transformers 端点。
📢 嗨!如果你觉得这篇文章有帮助,请考虑:
👏 鼓掌 50 次(这很有帮助!)
✏️ 留下评论
🌟 突出你觉得有见地的部分
👣 关注我
有任何问题吗?🤔 不要犹豫,尽管问。以这种方式支持我是对我的详细教程文章的一种免费而简单的感谢方式!😊
最终说明
如果你对仅使用 Python 开发全栈 AI 应用感兴趣,请随时注册这里。一如既往,请在下面留下你的经验和评论。我期待阅读所有评论。
就这些了,如果你读到这里,请在下面评论并在LinkedIn上添加我。
我的 Github 在这里。
其他深度学习博客
安德鲁·吴的计算机视觉 — 11 个经验教训
安德鲁·吴的深度学习专业化 — 21 个经验教训
荷兰电动车:使用 Python 和 SQLAlchemy 的探索性数据分析(第二部分)
使用 Python、SQLAlchemy 和 Bokeh 进行数据分析和可视化
·发表于Towards Data Science ·阅读时间 17 分钟·2023 年 3 月 10 日
–
Smart EQ 汽车,图片来源 en.wikipedia.org/wiki/Smart_electric_drive
第一个电动车是什么时候注册的?(剧透:比大多数人想象的要早得多。)电动保时捷和捷豹哪一款更贵?探索性数据分析(EDA)不仅是构建每个数据管道的重要部分,而且是一个相当有趣的过程。在第一部分中,我使用 Python 和 Pandas 分析了 RDW(荷兰车辆管理局)数据集,其中一个挑战是数据集大小较大(约 10 GB)。作为解决方案,我指定了需要在 Pandas 中加载的列列表。这种方法有效,但如果数据集更大,内存中没有足够的 RAM 来容纳所有数据,或者数据集存放在远程数据库中怎么办?在这篇文章中,我将展示如何使用 SQLAlchemy 进行类似的分析。这将允许使用 SQL 进行“重型”数据处理,而无需将所有数据加载到 Pandas 中。
让我们开始吧。
加载数据
RDW(“Rijks Dienst Wegverkeer”, www.rdw.nl
)是一个荷兰组织,负责荷兰的机动车和驾驶执照的批准与登记。我将使用“Gekentekende voertuigen”(“带有车牌的车辆”)数据集。如第一部分所述,它在公共领域许可证下提供,可以从opendata.rdw.nl下载。数据处理将使用SQLite,它是一个免费的轻量级数据库引擎,可以轻松运行在任何 PC 上。
一开始,我们需要将 CSV 文件下载并导入到 SQLite 中。文件大小约为 10 GB;可以免费下载,无需注册。为了导入数据,我运行了“sqlite3 rdw_data.db”命令,并输入了 3 个命令:
sqlite> .mode csv
sqlite> .import Open_Data_RDW__Gekentekende_voertuigen.csv rdw_data
sqlite> .quit
这里“Open_Data_RDW__Gekentekende_voertuigen.csv”是原始的 CSV 文件,“rdw_data”是一个需要创建的表。导入过程需要一些时间,之后我们就可以结束命令行操作,回到 Jupyter Lab。首先,让我们进行必要的导入,看看我们拥有哪些数据库列:
from sqlalchemy import create_engine, MetaData, table, column, select, func
from sqlalchemy import inspectp
import pandas as pd
rdw_db = create_engine('sqlite:///rdw_data.db')
table_name = 'rdw_data'
with Session(rdw_db) as session:
insp = inspect(rdw_db)
columns = insp.get_columns("rdw_data")
display(pd.DataFrame(columns))
我使用 Pandas DataFrame 来显示结果,因为它的输出更易于阅读。例如,“display(columns)”会显示如下输出:
同时,“display(pd.DataFrame(columns))”的输出效果要好得多:
让我们检查一下结果。我们可以看到所有列都是 TEXT 类型,因此我们需要转换这些值。数据库中有 91 列,但根据实际分析,我只需要汽车的类型、车牌、型号名称、价格和注册日期。我还会使用“Number of cylinders”作为辅助来检测汽车是否是电动车。最后但同样重要的是,我只会分析“personal”(荷兰语中的“Personenauto”)汽车,而不是卡车或公交车,所以我会在 SQL 查询中使用这个过滤器。
让我们使用 SQL 进行这个转换:
with Session(rdw_db) as session:
session.execute(text('DROP TABLE IF EXISTS rdw_cars'))
session.execute(text('CREATE TABLE rdw_cars("index" INTEGER PRIMARY KEY AUTOINCREMENT, '
'"Model" TEXT, '
'"Trade name" TEXT, '
'"License Plate" TEXT, '
'"Number of Cylinders" INTEGER, '
'"Catalog price" INTEGER, '
'"First registration NL" TEXT, '
'"Is electric" INTEGER DEFAULT 0)'))
session.execute(text('BEGIN TRANSACTION'))
session.execute(text('INSERT INTO rdw_cars("Model", "Trade name", "License Plate", "Number of Cylinders", "Catalog price", "First registration NL") '
'SELECT '
'"Merk", '
'"Handelsbenaming", '
'"Kenteken", '
'(CASE WHEN LENGTH("Aantal cilinders") > 0 THEN CAST("Aantal cilinders" as INTEGER) ELSE NULL END), '
'(CASE WHEN LENGTH("Catalogusprijs") > 0 THEN CAST("Catalogusprijs" as INTEGER) ELSE NULL END), '
'DATE(SUBSTR("Datum eerste tenaamstelling in Nederland", 1, 4) || "-" || SUBSTR("Datum eerste tenaamstelling in Nederland", 5, 2) || "-" || SUBSTR("Datum eerste tenaamstelling in Nederland", 7, 2)) '
' FROM rdw_data WHERE "Voertuigsoort" = "Personenauto"'))
session.execute(text('COMMIT'))
在这里,我创建了一个新表,并将整数和日期列转换成适当的格式。我将所有空字符串替换为 NULL,并作为读者的额外奖励,我将荷兰语列名翻译成了英文。我还创建了“Is electric”列,后面会使用到。
初始转换完成,我们准备好了。
基本分析
一开始,让我们看看数据集的主要属性,如数据样本、维度和 NULL 值的数量。
使用 SQL,我们可以获取记录的总数:
with Session(rdw_db) as session:
q = session.execute(text('SELECT COUNT(*) FROM rdw_cars')).scalar()
print("Cars total:", q)
总共有 9,487,265 辆车,在撰写本文时已在荷兰注册(对于那些稍后下载数据集的读者,这个数字显然会更大)。这个总数也等于我在第一部分中得到的数字,在那里我使用 Pandas 进行了类似的分析——这是一种检查处理是否正确的简单方法。
现在我们来看数据库中的前 5 个样本;使用 SQL 很容易做到这一点。在这里和之后我将使用 Pandas 来显示表格,因为 Pandas 有原生的 SQL 绑定,这很方便。
with Session(rdw_db) as session:
df = pd.read_sql_query(text("SELECT * FROM rdw_cars LIMIT 5"), con=session.connection(), dtype={'Catalog price': pd.UInt32Dtype(), 'Number of Cylinders': pd.UInt32Dtype()})
display(df.style.hide(axis="index"))
结果如下所示:
让我们检查一下不同列中缺失/NULL 数量。Pandas DataFrame 有一个方便的方法“df.isna().sum()”,但我在 SQL 中找不到类似的东西。我们需要指定所有需要检查的列:
with Session(rdw_db) as session:
request = ('SELECT '
' SUM(CASE WHEN "Model" IS NULL OR "Model" = "" THEN 1 ELSE 0 END) AS model_no_data, '
' SUM(CASE WHEN "Trade name" = "" THEN 1 ELSE 0 END) AS trade_name_empty, '
' SUM(CASE WHEN "Trade name" IS NULL THEN 1 ELSE 0 END) AS trade_name_nulls, '
' SUM(CASE WHEN "License Plate" IS NULL OR "License Plate" = "" THEN 1 ELSE 0 END) AS lp_no_data, '
' SUM(CASE WHEN "Number of Cylinders" = 0 THEN 1 ELSE 0 END) AS num_cylinders_zeros, '
' SUM(CASE WHEN "Number of Cylinders" IS NULL THEN 1 ELSE 0 END) AS num_cylinders_nulls, '
' SUM(CASE WHEN "Catalog price" = 0 THEN 1 ELSE 0 END) AS price_zeros, '
' SUM(CASE WHEN "Catalog price" IS NULL THEN 1 ELSE 0 END) AS price_nulls, '
' SUM(CASE WHEN "First registration NL" IS NULL THEN 1 ELSE 0 END) AS registration_nulls, '
' COUNT(*) AS total '
'FROM rdw_cars')
df = pd.read_sql(text(request), con=session.connection())
display(df.style.hide(axis="index"))
使用 SQL,我计算了可能是 NULL 或空的值的总和。结果如下所示:
在这里我们可以看到汽车的总数量(9,487,265)。每辆车都有一个车牌和一个注册日期;这些字段可能是注册的必填项。但有 2,480,506 条记录没有价格,864 条记录没有“商标名称”,等等。在这里,我看到一个问题——这些 864 条空“商标名称”字段的记录与我在 Pandas 中得到的 1,405 条空记录不匹配,在第一部分中得到了这个结果。这显然是不对的,差异在哪里?不可能手动检查 9,487,265 条记录,而调试这个问题的最简单方法是将唯一的“商标名称”值保存到文本文件中,并使用“Diff”工具比较两个文件。结果表明,问题简单但有趣——在第一部分,我使用了“pd.read_csv”方法加载数据。这个方法“足够聪明”可以自动将“NULL”、“NA”、“N/A”和一些其他值(完整列表可以在手册中找到)替换为 NULL,这个转换默认是启用的。在我们的案例中,Mazda NA 是一个真实的汽车模型,而 Pandas 自动将这些车的所有“NA”名称转换为 NULL(这也让我想起了旧故事关于姓氏 Null 的人,他对计算机来说是“不可见的”)。无论如何,Mazda NA 车不是电动车,所以它不会影响第一部分的结果,但要记住这种问题可能会发生是好的。
但让我们回到分析中。使用 SQL,我们可以轻松地进行有用的请求,例如,来看一下荷兰最贵的前 10 辆车:
with Session(rdw_db) as session:
df = pd.read_sql(text('SELECT "Model", "Trade name", "Catalog price", "First registration NL" FROM rdw_cars ORDER BY "Catalog price" DESC LIMIT 10'), con = session.connection())
display(df)
结果很有趣:
我本来期待在这个列表中看到 Porsche、Mercedes 或 BMW,但看到 Peugeot 或 Fiat 在这里对我来说有点意外,不过,我不是豪华车方面的专家。
数据转换
我们已经使用 SQL 请求做了一些基本分析,但本文的目的是分析电动汽车。要检测汽车是否为电动,我们需要知道其制造商和型号名称。理想情况下,如果电动汽车的名称中有“ELECTRIC”,任务会简单得多。但在现实生活中,车型命名毫无逻辑。“Mazda MX-30”是电动的,但“Mazda MX-5”不是。“Kia Niro”是电动的,而“Kia Sorento”不是,等等。这没有规则,最简单的方法就是创建一个电动汽车型号的表格并加以使用。但首先,让我们检查一下数据集中汽车型号和商标名称是否一致。
首先让我们验证汽车 型号,例如,查看所有的 PEUGEOT 汽车:
with Session(rdw_db) as session:
df = pd.read_sql_query(text('SELECT "Model", COUNT(*) AS Count FROM rdw_cars WHERE "Model" LIKE "%PEUGEOT%" GROUP BY "Model" '), con = session.connection())
display(df.style.hide(axis="index"))
结果看起来是这样的:
数据库中的几乎所有汽车都有名称“PEUGEOT”,这很好,但有几辆车的名称较长,如“PEUGEOT BOXER”。第一个词足以知道汽车型号,因此我们可以轻松去除其余部分。这将使未来的分析更加方便;例如,我们可以按型号对汽车进行分组,看看售出了多少辆 Peugeot 汽车。在第一部分中,我已经创建了一个方法来去除型号名称中的冗余词:
def model_normalize(s_val):
""" "PEUGEOT BOXER/GLOBE-TRAVE " => "PEUGEOT" """
if s_val and isinstance(s_val, str) and len(s_val) > 0:
return s_val.replace("-", " ").replace("/", " ").split()[0].upper().strip()
return None
现在让我们检查下一列。数据集中汽车商标名称有时会与型号重复,例如这个例子中的“NISSAN”汽车:
我创建了一个方法来移除这些重复项,在这个示例中,它会将“NISSAN MURANO”字段转换为仅“ MURANO”。
def name_normalize(model: str, trade_name: str):
""" Remove duplicates and convert the name to upper case """
if isinstance(trade_name, str) and len(trade_name) > 0:
name = trade_name.upper().strip()
# Remove duplicates from model and trade name:
# ("TESLA", "TESLA MODEL 3") => ("TESLA", "MODEL 3")
if name.split()[0] == model:
# "TESLA MODEL 3" => [TESLA, MODEL, 3] => "MODEL 3"
return ' '.join(name.split()[1:])
return name
return None
现在我们终于可以弄清楚这辆车是否为电动了。在第一部分中,我已经为此创建了一个方法:
electric_cars = {
"AIWAYS": ['U5', 'U6'],
"AUDI": ['E-TRON'],
"BMW": ['I3', 'I4', 'I7', 'IX'],
"CITROEN": ['E-C4'],
"FIAT": ['500E', 'ELETTRA'],
"FORD": ['MACH-E'],
"HONDA": ['"E"', '"E ADVANCE"'],
"HYUNDAI": ['IONIQ', 'KONA'],
"JAGUAR": ['I-PACE'],
"KIA": ['NIRO', 'E-SOUL'],
"LEXUS": ['RZ'],
"LUCID": ['AIR'],
"MAZDA": ['MX-30'],
"MERCEDES": ['EQA', 'EQB', 'EQC', 'EQS', 'EQV'],
"MG": ['ZS EV'],
"MINI": ['COOPER SE'],
"NISSAN": ['ALTRA', 'ARIYA', 'EVALIA', 'LEAF', 'NUVU'],
"OPEL": ['AMPERA-E', 'COMBO-E', 'CORSA-E', 'MOKKA-E', 'VIVARO-E', 'ZAFIRA-E'],
"PEUGEOT": ['E-208', 'E-2008', 'E-RIFTER', 'E-TRAVELLER'],
"POLESTAR": ['2', '3'],
"PORSCHE": ['TAYCAN'],
"RENAULT": ['MASTER', 'TWINGO', 'KANGOO ELEC', 'ZOE'],
"SKODA": ['ENYAQ'],
"SMART": ['EQ'],
"TESLA": [''],
"TOYOTA": ['BZ'],
"VOLKSWAGEN": ['ID.3', 'ID.4', 'ID.5', 'E-GOLF'],
"VOLVO": ['C40', 'XC40']
}
def check_is_electric(model: str, trade_name: str, cylinders: int):
""" Determine if the car is electric """
if isinstance(cylinders, int) and cylinders > 0:
return False
for e_model, e_names in electric_cars.items():
if model == e_model:
for e_name in e_names:
if trade_name and (e_name in trade_name or e_name.replace('"', '') == trade_name):
return True
if trade_name is None and len(e_name) == 0:
return True
return False
在这段代码中,我搜索特定的关键词;例如,如果型号是“BMW”,那么“I3”商标名称会告诉我们这辆车是电动的。作为额外的检查(一些汽车可能是电动或混合动力),我还分析了气缸数量,对于电动汽车,气缸数量必须是 0 或 NULL。
在第一部分中已经测试过的所有 3 种方法都效果很好,例如,我可以通过一行代码轻松地将model_normalize方法应用于 Pandas 数据集:
df["Model"] = df['Model'].map(lambda s: model_normalize(s))
但是我们如何在 SQL 中使用它呢?好吧,直接用是不行的,但我们可以借助 ORM 来实现。
SQLAlchemy ORM
ORM(对象关系映射)是一种技术,用于在 OOP 语言和关系数据库之间创建一个“桥梁”。实际上,我们可以创建一个特殊的 Python 类,SQLALchemy 会自动将对这个类的所有请求转换为 SQL。这非常方便,允许开发人员用纯 Python 编写代码,而不必处理难看的括号和长 SQL 字符串。
让我们创建一个“Car”类,并在其中放入所需的方法:
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy import String, Integer, Date, Column
class Base(DeclarativeBase):
pass
class Car(Base):
__tablename__ = "rdw_cars"
index = Column("index", Integer, primary_key=True)
license_plate = Column("License Plate", String)
model = Column("Model", String)
trade_name = Column("Trade name", String)
num_cylinders = Column("Number of Cylinders", Integer)
first_registration = Column("First registration NL", Date)
price = Column("Catalog price", Integer)
is_electric = Column("Is electric", Integer)
def model_normalize(self):
""" "PEUGEOT BOXER/GLOBE-TRAVE " => "PEUGEOT" """
if self.model and isinstance(self.model, str) and len(self.model) > 0:
self.model = self.model.replace("-", " ").replace("/", " ").split()[0].upper().strip()
def name_normalize(self):
""" Remove duplicates from model and trade name: ("TESLA", "TESLA MODEL 3") => ("TESLA", "MODEL 3") """
if isinstance(self.trade_name, str) and len(self.trade_name) > 0:
name = self.trade_name.upper().strip()
if name.split()[0] == self.model:
# "TESLA MODEL 3" => [TESLA, MODEL, 3] => "MODEL 3"
self.trade_name = ' '.join(name.split()[1:])
else:
self.trade_name = name
def check_electric(self):
self.is_electric = check_is_electric(self.model, self.trade_name, self.num_cylinders)
作为这种方法的一个示例,让我们展示荷兰特定型号的最贵汽车。使用标准 SQL,我们可以发出如下请求:
with Session(rdw_db) as session:
model = "BMW"
limit = 5
df = pd.read_sql_query(text(f'SELECT "Model", "Trade name", "Catalog price", "First registration NL" FROM rdw_cars WHERE Model = "{model}" ORDER BY "Catalog price" DESC LIMIT {limit}'),
con=session.connection())
display(df.style.hide(axis="index"))
结果是我们得到了表格:
它是有效的,但 SQL 字符串相当长,我不得不使用 f-string 将所需的变量添加到请求中。在对象关系映射的帮助下,我可以直接使用标准 Python 代码:
with Session(rdw_db) as session:
model = "BMW"
limit = 5
df = pd.read_sql_query(select(Car.model, Car.trade_name, Car.price, Car.first_registration).filter(Car.model == model).order_by(Car.price.desc()).limit(limit),
con=session.connection())
display(df.style.hide(axis="index"))
SQLAlchemy 将在“幕后”创建一个适当的 SQL 请求,因此我们得到了更易读的 Python 代码。然而,主要的缺点是 SQL 或多或少是标准的;有很多关于它的资源和教程,但 SQLAlchemy 代码仅限于此库。但对于我们的任务,这种方法效果很好。
使用 ORM,我们可以轻松地将我们的“check_electric”方法应用于数据库中的所有记录:
with Session(rdw_db) as session:
cars_total = session.query(Car).count()
index = 0
batch_size = 25000
while True:
pos1, pos2 = index*batch_size, (index + 1)*batch_size
if index % 20 == 0:
print(f"Processing {pos1} to {pos2}, {100*index*batch_size//cars_total}%...")
cars = session.query(Car).filter(Car.index.between(pos1, pos2)).all()
if len(cars) == 0:
break
for car in cars:
car.model_normalize()
car.name_normalize()
car.check_electric()
session.flush()
index += 1
session.commit()
在这段代码中,我从数据库中读取记录,更新参数,并将数据保存回表中。SQLAlchemy 将使用 SQL 请求更新表格,这肯定比直接在内存中更新 Pandas Dataframe 要慢。调用 Pandas 中的相同方法只需 130 秒,而 SQLAlchemy 请求则花费了 390 秒,因此差异约为 3 倍。另一方面,对于批量更新,我们需要的内存要少得多,而且不需要将整个数据框保存在 RAM 中。
分析
更新表格后,我们终于准备好了。作为热身,让我们计算一下汽车价格的均值和百分位数。
计算均值很简单,可以用 SQLAlchemy 一行代码完成。让我们获取汽车的总数量及其算术价格均值:
with Session(rdw_db) as session:
c_total = session.query(Car).count()
print(f"Cars total: {c_total}")
c_el = session.query(Car).filter(Car.is_electric == 1).count()
print(f"Cars electric: {c_el} ({100*c_el/c_total:.2f}%)")
pm = session.query(func.avg(Car.price)).scalar()
print("Price mean:", pm)
pm_el = session.query(func.avg(Car.price)).filter(Car.is_electric == 1).scalar()
print("Electric cars price mean:", pm_el)
获取百分位数稍微复杂一点,我们有两种方法可以做到这一点。我们可以仅加载“价格”列,然后使用 NumPy 的“percentile”方法进行计算:
prices = session.query(Car.price).filter(Car.price != None).all()
print("All cars percentiles [5, 50, 95]:", np.percentile(prices, [5, 50, 95]))
prices_el = session.query(Car.price).filter((Car.price != None) & (Car.is_electric == 1)).all()
print("Electric cars percentiles [5, 50, 95]:", np.percentile(prices_el, [5, 50, 95]))
如果数据集很大,我们想要避免完全加载数据,可以通过结合“order_by”、“limit”和“offset”来使用纯 SQL 获取百分位数:
num_total = session.query(Car).filter(Car.price != None).count()
p5 = session.query(Car.price).filter(Car.price != None).order_by(Car.price).offset(num_total*5/100 - 1).limit(1).scalar()
p50 = session.query(Car.price).filter(Car.price != None).order_by(Car.price).offset(num_total*50/100 - 1).limit(1).scalar()
p95 = session.query(Car.price).filter(Car.price != None).order_by(Car.price).offset(num_total*95/100 - 1).limit(1).scalar()
print("All cars percentiles [5, 50, 95]:", p5, p50, p95)
num_el = session.query(Car).filter((Car.price != None) & (Car.is_electric == 1)).count()
p5 = session.query(Car.price).filter((Car.price != None) & (Car.is_electric == 1)).order_by(Car.price).offset(num_el*5/100 - 1).limit(1).scalar()
p50 = session.query(Car.price).filter((Car.price != None) & (Car.is_electric == 1)).order_by(Car.price).offset(num_el*50/100 - 1).limit(1).scalar()
p95 = session.query(Car.price).filter((Car.price != None) & (Car.is_electric == 1)).order_by(Car.price).offset(num_el*95/100 - 1).limit(1).scalar()
print("Electric cars percentiles [5, 50, 95]:", p5, p50, p95)
结果很有趣:
荷兰是一个平均工资相当高的国家,但在撰写本文时,只有 2.93% 的汽车是电动的。所有汽车的中位数价格为 €26,341,而电动汽车“平均”贵 2 倍;其中位数价格为 €49,975. 所有汽车的第 95 百分位数为 €73,381,这意味着 95% 的汽车价格更低。与此同时,95% 的电动汽车价格低于 €106,989。
现在让我们找点更有趣的。让我们获取荷兰前 20 名电动车:
with Session(rdw_db) as session:
n_top = 20
# Group by car model
models_amout = session.query(Car.model, func.count(Car.model)).filter(Car.is_electric == 1).group_by(Car.model).order_by(desc(func.count(Car.model))).limit(n_top).all()[::-1]
# Unzip array [('TESLA', 65896), ('VOLKSWAGEN', 28559)] to 2 parts
models, amount = zip(*models_amout)
# Show
p = figure(y_range=models, width=1200, height=500, title="Top-%d electric car manufacturers in the Netherlands (data 2023)" % n_top)
p.hbar(right=amount, y=models, height=0.8, color=Viridis256[:n_top])
p.xgrid.grid_line_color = None
p.x_range.start = 0
p.below[0].formatter.use_scientific = False
p.xaxis.axis_label = "Cars total"
show(p)
从表格中可以看到,特斯拉排名第一,注册在该国的汽车超过 55,000 辆:
前 20 名电动车制造商,图片来源:作者
我对哪个特斯拉型号最受欢迎感到好奇。为了了解这一点,我们可以更改请求:
models_amout = session.query(Car.trade_name, func.count(Car.trade_name)).filter(Car.model == "TESLA").group_by(Car.trade_name).order_by(desc(func.count(Car.trade_name))).order_by(Car.trade_name).all()[::-1]
...
很明显,“Model 3”是撰写本文时最受欢迎的电动汽车:
Tesla 车型柱状图,图像由作者提供
但我们也可以看到数据集显然需要更多清理:一些 Tesla 汽车被注册为“MODEL 3”,一些为“MODEL3”,一些汽车被保存为“ROADSTER”,一些为“RAODSTER”等等。
现在我们来按日期对电动汽车注册情况进行分组。为了使图表更清晰,我想按季度分组日期,但在 SQL 中提取季度的代码可能很庞大。相反,我将使用 SQL 按天分组注册,然后可以使用 Pandas 内部函数计算季度:
with Session(rdw_db) as session:
regs_amount = session.query(Car.first_registration, func.count(Car.first_registration)).filter(Car.is_electric == 1).group_by(Car.first_registration).order_by(Car.first_registration).all()
df = pd.DataFrame(regs_amount, columns =['First registration NL', 'Amount'])
df["First registration NL"] = df['First registration NL'].map(lambda d: datetime.datetime(d.year, d.month, d.day))
df["Quarter"] = df['First registration NL'].dt.to_period('Q')
data_per_quarter = df.groupby(['Quarter'], as_index=False)["Amount"].sum()
dates = data_per_quarter['Quarter']
amount = data_per_quarter['Amount']
p = figure(x_axis_type='datetime', width=1600, height=500,
title=f"Electric car registrations in the Netherlands, 1992-2022")
p.vbar(x=dates, top=amount, width=datetime.timedelta(days=3*22), line_color='black')
p.xaxis[0].ticker.desired_num_ticks = 20
p.yaxis.axis_label = "Cars total"
show(p)
在这段代码中,我首先将 SQL 结果转换为 Pandas dataframe;然后我将 Python 的“date”对象转换为“datetime”(因为某些原因,季度计算仅与“datetime”有效)。代码几乎与第一部分相同,但这里我使用 Pandas 的“groupby.sum()”代替“size()”,因为从 SQL 中检索的数据已经按天分组。
结果很有趣:
电动汽车注册情况,图像由作者提供
如第一部分所述,荷兰首辆电动汽车在 1992 年注册。那是一辆Fiat Panda Elettra,是一辆小型双座车,最高时速 70 km/h,续航 100 km,电源由 12 块 6V 铅酸电池提供。它是全国家唯一的电动汽车,15 年内没有其他电动汽车;接下来的 3 辆Tesla Roadster汽车直到 2009 年才注册。
现在我们来看看电动汽车的价格分布。我想绘制一个箱型图,为此,我需要了解每种车型的最小值、最大值和四分位数值:
with Session(rdw_db) as session:
request_models = session.query(Car.model).filter(Car.is_electric == 1).group_by(Car.model).all()
def q0(x):
return x.quantile(0.01)
def q1(x):
return x.quantile(0.25)
def q3(x):
return x.quantile(0.75)
def q4(x):
return x.quantile(0.99)
models_data = {}
for m in request_models:
model_name = m[0] # (AIWAYS,) => AIWAYS
print("Processing", model_name)
request_model = session.query(Car.price).filter((Car.is_electric == 1) & (Car.price > 0) & (Car.model == model_name)).all()
df = pd.DataFrame(request_model)
agg_data = {'price': ['size', 'min', q0, q1, 'median', q3, q4, 'max']}
models_data[model_name] = df.agg(agg_data)["price"]
df = pd.concat(models_data, axis=1).transpose()
display(df)
在这段代码中,我首先获取所有汽车型号的列表;然后获取每种型号的价格,并使用 Pandas 对这些价格进行汇总。然后将数据合并成一个单一的 dataframe。结果如下:
有了这个 dataframe,绘制箱型图变得简单:
# Sort models by price
df = df.sort_values(by='median', ascending=True)
models = df.index.values
v_min = df["q0"].values
q1 = df["q1"].values
q3 = df["q3"].values
v_max = df["q4"].values
# Draw
palette = (Inferno10 + Magma10 + Plasma10 + Viridis10)[:models.shape[0]]
source = ColumnDataSource(data=dict(models=models,
bottom=q1,
top=q3,
color=palette,
lower=v_min,
upper=v_max))
p = figure(x_range=models, width=1900, height=500, title="Electric car prices distribution in the Netherlands")
whisker = Whisker(base="models", upper="upper", lower="lower", source=source)
p.add_layout(whisker)
p.vbar(x='models', top='top', bottom='bottom', width=0.9, color='color', line_color="black", source=source)
p.left[0].formatter.use_scientific = False
p.y_range.start = 0
show(p)
结果如下:
电动汽车制造商和价格箱型图,图像由作者提供
借助 SQLAlchemy,还可以轻松获取所有电动汽车的价格,并使用“np.histogram”方法构建直方图。代码几乎与第一部分相同,愿意的话可以自己尝试。
结论
分析真实数据集很有趣,结果显示 SQL 和 Pandas 的配合效果很好。数据的“繁重”检索和预处理可以使用 SQLAlchemy 完成,然后这些数据可以在 Pandas 中使用。
关于数据本身的处理,还有很多工作可以做。将这些数据与Kaggle 电动车数据集结合,寻找最大行驶距离、价格和汽车发布日期之间的相关性可能会很有趣(较新的车型应有更长的行驶距离)。我尝试过这样做,但两个数据集中的车型名称不匹配,而我又不是汽车专家,无法手动对每个型号进行处理。此外,正如之前所示,RDW 数据集需要更多的清理,名称也不一致。对这方面感兴趣的读者可以自行继续这些实验。
如果你喜欢这个故事,可以随时订阅Medium,你将会收到我新文章发布的通知,并且可以全面访问其他作者的数千篇故事。
感谢阅读。
荷兰的电动汽车:使用 Python 进行探索性数据分析
使用 Python、Pandas 和 Bokeh 进行数据分析和可视化
·发表于 Towards Data Science ·16 min 阅读·2023 年 2 月 10 日
–
Smart EQ Car,图片来源 en.wikipedia.org/wiki/Smart_electric_drive
第一个电动汽车是什么时候注册的?(剧透:比大多数人想象的要早得多。)电动 Porsche 还是 Jaguar 更贵?探索性数据分析(EDA)不仅是建立每个数据管道的重要部分,而且还是一个相当有趣的过程。在本文中,我将使用荷兰 RDW(荷兰车辆管理局)公共数据集来查找有关电动汽车的信息。我们将看看哪些数据可以使用 Python、Pandas 和 Bokeh 提取和展示。
让我们开始吧。
加载数据
RDW(“Rijks Dienst Wegverkeer”,www.rdw.nl
)是一个荷兰机构,负责处理荷兰的机动车和驾驶执照的审批与登记。作为一个公共政府机构,它的数据对所有人开放。对我们最感兴趣的是“Gekentekende voertuigen”(“带有车牌的车辆”)数据集。它可以在 opendata.rdw.nl 上免费下载,使用公共领域许可证。文件大小约为 10 GB,包含自 1952 年以来在荷兰注册的所有车辆的信息。处理这样大小的文件也可能是一个挑战——这使得任务更加有趣。
我将使用 Jupyter Lab,这种方法比使用标准 IDE 更方便,因为每次启动项目时重新加载 10 GB 的文件似乎不是一个好主意。同时,我将使用 Pandas 进行处理,并使用 Bokeh 进行可视化。首先,让我们导入所需的库:
import os
import pandas as pd
import numpy as np
import datetime
from bokeh.io import show, output_notebook, export_png
from bokeh.plotting import figure, output_file
from bokeh.models import ColumnDataSource, LabelSet, Whisker
from bokeh.palettes import *
output_notebook()
现在我们准备加载数据集。我们先尝试一种“天真”的方法:
filename = "Open_Data_RDW__Gekentekende_voertuigen.csv"
df = pd.read_csv(filename)
display(df)
运行此代码后,PC 冻结了大约 30 秒……而且 Python 内核崩溃了。哎呀。它不仅加载缓慢,而且内存也不够。至少在我的电脑上,32 GB 的 RAM 对于这个任务是不够的。
如果我们无法将文件加载到内存中,可以逐行读取;这种方法从 IBM 主机和磁带驱动器时代就已经存在。让我们读取文件的前几行,看看里面有什么:
filename = "Open_Data_RDW__Gekentekende_voertuigen.csv"
with open(filename, 'r') as f:
header_str = f.readline()
print(header_str)
for _ in range(10):
print(f.readline())
结果如下:
Kenteken,Voertuigsoort,Merk,Handelsbenaming,Vervaldatum APK,Datum tenaamstelling,Bruto BPM,Inrichting,Aantal zitplaatsen,Eerste kleur,Tweede kleur,Aantal cilinders,Cilinderinhoud,Massa ledig voertuig,Toegestane maximum massa voertuig,Massa rijklaar,Maximum massa trekken ongeremd,Maximum trekken massa geremd,Datum eerste toelating,Datum eerste tenaamstelling in Nederland,Wacht op keuren,Catalogusprijs,WAM verzekerd,Maximale constructiesnelheid,Laadvermogen,Oplegger geremd,Aanhangwagen autonoom geremd,Aanhangwagen middenas geremd,Aantal staanplaatsen,Aantal deuren,Aantal wielen,Afstand hart koppeling tot achterzijde voertuig,Afstand voorzijde voertuig tot hart koppeling,Afwijkende maximum snelheid,Lengte,Breedte,Europese voertuigcategorie,Europese voertuigcategorie toevoeging,Europese uitvoeringcategorie toevoeging,Plaats chassisnummer,Technische max. massa voertuig,Type,Type gasinstallatie,Typegoedkeuringsnummer,Variant,Uitvoering,Volgnummer wijziging EU typegoedkeuring,Vermogen massarijklaar,Wielbasis,Export indicator,Openstaande terugroepactie indicator,Vervaldatum tachograaf,Taxi indicator,Maximum massa samenstelling,Aantal rolstoelplaatsen,Maximum ondersteunende snelheid,Jaar laatste registratie tellerstand,Tellerstandoordeel,Code toelichting tellerstandoordeel,Tenaamstellen mogelijk,Vervaldatum APK DT,Datum tenaamstelling DT,Datum eerste toelating DT,Datum eerste tenaamstelling in Nederland DT,Vervaldatum tachograaf DT,Maximum last onder de vooras(sen) (tezamen)/koppeling,Type remsysteem voertuig code,Rupsonderstelconfiguratiecode,Wielbasis voertuig minimum,Wielbasis voertuig maximum,Lengte voertuig minimum,Lengte voertuig maximum,Breedte voertuig minimum,Breedte voertuig maximum,Hoogte voertuig,Hoogte voertuig minimum,Hoogte voertuig maximum,Massa bedrijfsklaar minimaal,Massa bedrijfsklaar maximaal,Technisch toelaatbaar massa koppelpunt,Maximum massa technisch maximaal,Maximum massa technisch minimaal,Subcategorie Nederland,Verticale belasting koppelpunt getrokken voertuig,Zuinigheidsclassificatie,Registratie datum goedkeuring (afschrijvingsmoment BPM),Registratie datum goedkeuring (afschrijvingsmoment BPM) DT,API Gekentekende_voertuigen_assen,API Gekentekende_voertuigen_brandstof,API Gekentekende_voertuigen_carrosserie,API Gekentekende_voertuigen_carrosserie_specifiek,API Gekentekende_voertuigen_voertuigklasse
85XXXA,Personenauto,VOLKSWAGEN,CALIFORNIA,20230702,20220915,10437,kampeerwagen,,GROEN,Niet geregistreerd,5,2461,2088,2800,2188,700,2000,20010626,20010626,Geen verstrekking in Open Data,,Ja,,,,,,,0,4,0,0,,0,0,M1,,,r. in watergoot v. voorruit,2800,,,e1*96/79*0066*10,AJTCKX0,N1P00J2SGFM52B010U,1,0.03,292,Nee,Nee,,Nee,4500,0,0.00,2022,Logisch,00,Ja,07/02/2023 12:00:00 AM,09/15/2022 12:00:00 AM,06/26/2001 12:00:00 AM,06/26/2001 12:00:00 AM,,,,,,,,,,,,,,,,,,,,,,,,https://opendata.rdw.nl/resource/3huj-srit.json,https://opendata.rdw.nl/resource/8ys7-d773.json,https://opendata.rdw.nl/resource/vezc-m2t6.json,https://opendata.rdw.nl/resource/jhie-znh9.json,https://opendata.rdw.nl/resource/kmfi-hrps.json
85XXXB,Personenauto,PEUGEOT,3*RFN*,20230920,20210224,5162,hatchback,5,ZWART,Niet geregistreerd,4,1997,1194,1719,1294,625,1300,20010720,20010720,Geen verstrekking in Open Data,,Ja,,,,,,,4,4,0,0,,420,0,M1,,,op r. schroefveerkoker onder motorkap,1719,,,e2*98/14*0244*00,C,B,0,0.08,261,Nee,Nee,,Nee,3019,0,,2022,Logisch,00,Ja,09/20/2023 12:00:00 AM,02/24/2021 12:00:00 AM,07/20/2001 12:00:00 AM,07/20/2001 12:00:00 AM,,,,,,,,,,,,,,,,,,,,,D,,,https://opendata.rdw.nl/resource/3huj-srit.json,https://opendata.rdw.nl/resource/8ys7-d773.json,https://opendata.rdw.nl/resource/vezc-m2t6.json,https://opendata.rdw.nl/resource/jhie-znh9.json,https://opendata.rdw.nl/resource/kmfi-hrps.json
...
85XXXN,Personenauto,NISSAN,NISSAN MURANO,20240106,20111126,18921,stationwagen,5,ZWART,Niet geregistreerd,6,3498,1833,2380,1933,750,1585,20081206,20081206,Geen verstrekking in Open Data,,Ja,,,,,,,4,4,0,0,,484,0,M1,,,r. voorzitting by dwarsbalk,2380,Z51,,e1*2001/116*0478*00,A,A01,0,0.1,283,Nee,Nee,,Nee,3965,0,,2023,Logisch,00,Ja,01/06/2024 12:00:00 AM,11/26/2011 12:00:00 AM,12/06/2008 12:00:00 AM,12/06/2008 12:00:00 AM,,,,,,,,,,,,,,,,,,,,,E,,,https://opendata.rdw.nl/resource/3huj-srit.json,https://opendata.rdw.nl/resource/8ys7-d773.json,https://opendata.rdw.nl/resource/vezc-m2t6.json,https://opendata.rdw.nl/resource/jhie-znh9.json,https://opendata.rdw.nl/resource/kmfi-hrps.json
正如我们所看到的,有许多不同的数据字段,而我们实际上不需要所有这些字段。关于每辆车,我只想了解其类型、车牌、型号、价格和注册日期。这个数据库已经足够旧了,没有字段表示汽车是否为电动汽车。但至少,有一个字段包含“Number of cylinders”,这可以帮助我们排除不是电动汽车的车辆。
现在我们只有 7 个字段需要加载,在 Pandas 中,我们可以指定列列表,这会大幅减少数据大小。第二个技巧是将pd.UInt32Dtype指定给“Number of cylinders”和“Price”字段。我还想只查看“个人”汽车(荷兰语中的“Personenauto”),而不是卡车或公共汽车:
cols = ['Kenteken', 'Voertuigsoort', 'Merk', 'Handelsbenaming', 'Aantal cilinders', 'Catalogusprijs']
cols_date = ['Datum eerste tenaamstelling in Nederland']
filename = "Open_Data_RDW__Gekentekende_voertuigen.csv"
df = pd.read_csv(filename, usecols=cols + cols_date, parse_dates=cols_date,
dtype={"Catalogusprijs": pd.UInt32Dtype(),
"Aantal cilinders": pd.UInt32Dtype()})
display(df)
df = df[df['Voertuigsoort'] == 'Personenauto']
df.info(memory_usage="deep")
现在文件已正确加载,正如“info”方法所示,内存使用量为 2.5 GB:
Dataset information, Image by author
由于文件大小较大,数据加载仍然需要较长时间。最简单的方法是将筛选后的数据集保存为新文件,并使用该文件进行进一步的实验:
df.to_csv("Open_Data_RDW__Gekentekende_voertuigen_short.csv", sep=',',
encoding='utf-8')
这个文件只有 580 KB 大小,比原始的 10 GB 小得多,并且加载时没有造成任何问题。
我们也不再需要“Voertuigsoort”字段,删除这一列将释放一些 RAM 和屏幕空间。最后一步,让我们将数据字段从荷兰语翻译成英语——这对分析不是强制性的,但对读者会更方便:
df = df.drop('Voertuigsoort', axis=1)
translations_dict_en_nl = {
'Kenteken': 'License plate',
'Merk': 'Model',
'Handelsbenaming': 'Trade name',
'Aantal cilinders': 'Number of Cylinders',
'Catalogusprijs': 'Catalog price',
'Datum eerste tenaamstelling in Nederland': 'First registration NL',
}
df.rename(translations_dict_en_nl, axis='columns', inplace=True)
现在我们准备好了。
基本分析
开始时,让我们看看数据集的主要属性,例如数据样本和维度:
display(df)
display(df.shape[0])
display(df.isna().sum())
*display(df)*方法向我们显示数据集的第一行和最后一行,这样我们可以看到数据的样子。第二行显示了记录的总数,这对计算可能有用,最后的请求将返回每列的空值数量。
输出如下:
Dataframe properties, Image by author
我们有 9,487,265 条记录,每辆车都有车牌、型号和登记日期(这些字段可能是注册的必填项),但其他字段,如“贸易名称”或“目录价格”,在一些汽车中缺失。从技术上讲,我们现在不需要任何清理,但对于某些请求(如价格分布),在进行请求之前我们应该去除 Null 值。
作为这种方法的示例,让我们排序数据,以查看荷兰最贵和最便宜的汽车:
df[df['Catalog price'].notna()].sort_values(by=['Catalog price'],
ascending=False)
按价格排序的数据框,作者图片
结果很有趣。第一名是“PEUGEOT 5008”,价格为 9,700,305 欧元,这很奇怪,因为在 Google 上它的价格大约是 41,000 欧元——可能是数据库中的错误,或者车主为升级花了很多钱 😉 或者这可能是全新的电动“PEUGEOT E-5008”,但它计划在 2024 年才发布。不管怎样,已经可以看出公共数据并不总是一致的。第二名的“PORSCHE CAYENNE”的价格可能是实际的。对于其他车型,很难判断,我不是豪华车专家,如果有人知道更多,请在下面的评论中写出来。至于最便宜的汽车,它们的价格为 1 欧元。可能它们作为“零件”从二手市场进口到荷兰,因此车主申报了最低可能的价值。
数据转换
让我们检查一下数据是否适合进一步分析。列表中的第一个汽车型号是“PEUGEOT”,让我们显示所有具有相同名称的汽车。“unique”方法将仅返回列中的唯一值:
display(df[df['Model'].str.contains("PEUGEOT", case=False)]['Model'].unique())
输出如下:
“Peugeot”型号请求,作者图片
我们可以看到数据库中的模型名称不一致。一些汽车的名称为“PEUGEOT”,其他汽车则被保存为“PEUGEOT BOXER”或“PEUGEOT/MOBILCAR”。为了按模型名称分组汽车,首个单词“PEUGEOT”就足够了,名称的右侧部分可以去掉。将所有字符转换为大写字母也更好,因为理论上汽车型号可以写作“PEUGEOT”或“Peugeot”。为了确保没有多余的字符,我会调用“strip”方法,该方法可以去除字符串中的多余空格。我创建了一个名为“name_normalize”的方法,它执行这种类型的转换:
def model_normalize(s_val: str):
""" Convert 'PEUGEOT BOXER/GLOBE-TRAVE ' to 'PEUGEOT' """
if s_val and isinstance(s_val, str) and len(s_val) > 0:
return s_val.replace("-", " ").replace("/", " ").split()[0].upper().strip()
return None
可以使用正则表达式进行更灵活的转换,但这段代码对于我们的任务来说已经足够了。当我们有了这个方法后,我们可以使用“map”函数转换 Pandas 数据框中的所有行:
df["Model"] = df['Model'].map(lambda s: model_normalize(s))
现在让我们处理“贸易名称”字段:
“贸易名称”样本,作者图片
如我们所见,大多数汽车在第一个字段中有制造商名称,在第二个字段中有商品名称,如截图中的“VOLVO” + “C30”。但一些其他汽车在两个字段中都有重复的制造商名称,如“NISSAN” + “NISSAN MURANO”。通过删除重复项使其更一致,并且作为奖励,这也会使数据集稍微变小:
def name_normalize(model: str, trade_name: str):
""" Remove duplicates and convert the name to upper case """
if isinstance(trade_name, str) and len(trade_name) > 0:
name = trade_name.upper().strip()
# Remove duplicates from model and trade name:
# ("TESLA", "TESLA MODEL 3") => ("TESLA", "MODEL 3")
if name.split()[0] == model:
# "TESLA MODEL 3" => [TESLA, MODEL, 3] => "MODEL 3"
return ' '.join(name.split()[1:])
return name
return None
这里的isinstance检查很重要,因为“商品名称”字段是可选的,一些记录中有 None 而不是字符串,获取*len(None)*显然会导致方法崩溃。
要更新数据框,我们可以使用 Pandas 中的“apply”方法:
df["Trade name"] = df.apply(lambda x: name_normalize(model=x['Model'],
trade_name=x['Trade name']),
axis=1)
让我们检查一下结果。拥有这些数据后,我们可以提取一些有用的信息,例如,看看荷兰最受欢迎的前 50 款汽车:
n_top = 50
all_models = df_models["Model"].to_numpy()
models, counts = np.unique(all_models, return_counts=True)
cs = counts.argsort() # Take sort indexes from 'counts' array
x = counts[cs][-n_top:]
y = models[cs][-n_top:]
p = figure(y_range=y, width=1400, height=600,
title="Top-%d cars in the Netherlands (data 2023)" % n_top)
p.hbar(right=x, y=y, height=0.8, color=Viridis256[:n_top])
p.xgrid.grid_line_color = None
p.x_range.start = 0
p.below[0].formatter.use_scientific = False
show(p)
np.unique方法可以计算每个模型的数量,我们不需要手动进行。这里第二个棘手的部分是同时对两个数组(汽车数量和汽车模型)进行排序,我们使用counts.argsort方法获得排序索引序列,然后将相同的索引应用于“models”数组。
Bokeh库非常适合绘制这样的图表:
顶级汽车模型条形图,作者提供的图像
数据转换的下一部分更棘手——我们需要确定汽车是否电动。这很棘手,因为每个制造商都有自己命名系统,并且没有通用规则。对于一些品牌,如“TESLA”,这很简单——所有特斯拉汽车都是电动的。对于其他型号,如“HYUNDAI IONIQ”或“NISSAN LEAF”,名称中存在特定关键字,而对于其他一些汽车,根本没有明确的规则(“HONDA E”是电动的,但“HONDA EE8”则不是)。
通过 Google 搜索和汽车制造商的网站,我创建了这个字典:
electric_cars = {
"AIWAYS": ['U5', 'U6'],
"AUDI": ['E-TRON'],
"BMW": ['I3', 'I4', 'I7', 'IX'],
"CITROEN": ['E-C4'],
"FIAT": ['500E', 'ELETTRA'],
"FORD": ['MACH-E'],
"HONDA": ['"E"', '"E ADVANCE"'],
"HYUNDAI": ['IONIQ', 'KONA'],
"JAGUAR": ['I-PACE'],
"KIA": ['NIRO', 'E-SOUL'],
"LEXUS": ['RZ'],
"LUCID": ['AIR'],
"MAZDA": ['MX-30'],
"MERCEDES": ['EQA', 'EQB', 'EQC', 'EQS', 'EQV'],
"MG": ['ZS EV'],
"MINI": ['COOPER SE'],
"NISSAN": ['ALTRA', 'ARIYA', 'EVALIA', 'LEAF', 'NUVU'],
"OPEL": ['AMPERA-E', 'COMBO-E', 'CORSA-E', 'MOKKA-E', 'VIVARO-E', 'ZAFIRA-E'],
"PEUGEOT": ['E-208', 'E-2008', 'E-RIFTER', 'E-TRAVELLER'],
"POLESTAR": ['2', '3'],
"PORSCHE": ['TAYCAN'],
"RENAULT": ['MASTER', 'TWINGO', 'KANGOO ELEC', 'ZOE'],
"SKODA": ['ENYAQ'],
"SMART": ['EQ'],
"TESLA": [''],
"TOYOTA": ['BZ'],
"VOLKSWAGEN": ['ID.3', 'ID.4', 'ID.5', 'E-GOLF'],
"VOLVO": ['C40', 'XC40']
}
现在我可以轻松检查特定关键字是否出现在汽车模型中,或者是否有模型名称的直接匹配。最后检查,我可以使用数据库中拥有的气缸数。如果这个值大于零,那么我们知道这辆车不是完全电动的。最终的方法(好吧,也许不是最终的,但对于我们的任务来说或多或少有效)如下所示:
def is_electric(model: str, trade_name: str, cylinders: int):
""" Determine if the car is electric """
if isinstance(cylinders, int) and cylinders > 0:
return False
for e_model, e_names in electric_cars.items():
if model == e_model:
for e_name in e_names:
if trade_name and (e_name in trade_name or e_name.replace('"', '') == trade_name):
return True
if trade_name is None and len(e_name) == 0:
return True
return False
作为一种单元测试,我们可以使用不同的参数来使用此方法:
print(is_electric("AUDI", "E-TRON S SPORTBACK 55 QUATTRO"))
print(is_electric("AUDI", "80 COUPE"))
print(is_electric("HONDA", "E"))
print(is_electric("HONDA", "EE 8"))
print(is_electric("HONDA", "INTEGRA TYPE R"))
print(is_electric("NISSAN", "MICRA"))
print(is_electric("NISSAN", "LEAF 62KWH"))
print(is_electric("TESLA", "ANY"))
使用这个函数,我们可以轻松地将新字段添加到数据框中,并仅保留电动汽车:
df["Electric"] = df.apply(lambda x: is_electric(model=x['Model'],
trade_name=x['Trade name'],
cylinders=x['Number of Cylinders']),
axis=1)
df_electric = df.query("Electric == True").drop(columns=['Number of Cylinders',
'Electric'])
如果一切都做得正确,我们应该得到这样的结果:
电动汽车数据框,作者提供的图像
分析
现在我们终于准备好开始分析荷兰的电动汽车了。
作为热身,计算均值、标准差和百分位数是很简单的:
print(f"Cars total: {df.shape[0]}")
print(f"Cars electric: {df_electric.shape[0]} ({100*df_electric.shape[0]/df.shape[0]:.2f}%)")
# Calculate percentiles - all cars
prices = df[df['Catalog price'].notna()]['Catalog price'].to_numpy()
print("Price mean:", np.mean(prices))
print("Price standard deviation:", np.std(prices))
print("Percentiles [5, 25, 50, 75, 95]:", np.percentile(prices, [5, 25, 50, 75, 95]))
# Calculate percentiles - electric cars
prices = df_electric[df_electric['Catalog price'].notna()]['Catalog price'].to_numpy()
print("Electric cars price mean:", np.mean(prices))
print("Electric cars price standard deviation:", np.std(prices))
print("Electric cars percentiles [5, 25, 50, 75, 95]:", np.percentile(prices, [5, 25, 50, 75, 95]))
输出结果如下:
均值、标准差和百分位数结果,作者提供的图像
我们可以看到荷兰共有 9,487,265 辆汽车,其中仅有 278,141 辆(2.93%)是电动汽车。好吧,到 2023 年,我们只是这个时代的开始。根据rvo.nl的报告,2019 年电动汽车占比为 1.22%,2020 年为 1.98%,2021 年为 2.55%,因此这些数字在增长,未来 10-20 年比较结果会很有趣。至于非电动汽车的价格,第 95 百分位数为€71,381。这意味着荷兰 95%的汽车价格低于此值。电动汽车则处于更“高端”的区间——平均价格为€49,975,第 95 百分位数为€106,989。
荷兰第一辆电动汽车是什么时候出现的,这个数量随着时间的推移有何变化?这个问题很容易回答。让我们构建一个每季度汽车注册数量的柱状图。为此,我需要在 Pandas 数据框中创建一个新的Quarter字段,并按此字段分组数据。我们可以从 Python 的“datetime”对象中提取季度号,但 Pandas 已经有所有需要的转换器:
reg_dates = df_electric[["Model", "Trade name", "First registration NL"]].copy()
reg_dates["Quarter"] = reg_dates['First registration NL'].dt.to_period('Q')
data_per_year = reg_dates.groupby(['Quarter'], as_index=False).size()
dates = data_per_year['Quarter']
amount = data_per_year['size']
p = figure(x_axis_type='datetime', width=1600, height=500,
title=f"Electric car registrations in the Netherlands, 1992-2022")
p.vbar(x=dates, top=amount, width=datetime.timedelta(days=3*22), line_color='black')
p.xaxis[0].ticker.desired_num_ticks = 30
p.xgrid.grid_line_color = None
show(p)
结果很有趣:
电动汽车注册情况,作者提供的图片
第一辆(在接下来的 15 年里全国唯一的一辆!)电动汽车于 1992 年在荷兰注册,距今已有 30 多年。我们可以轻松地在数据集中找到,它是一辆Fiat Panda Elettra,这是一款最高时速 70 公里、续航 100 公里的两座小车,电源由 12 个 6V 铅酸电池提供。接下来的 3 辆Tesla Roadster仅在 2009 年注册。第二件有趣的事情是季节性模式——很容易看到每年年底的注册数量最大(更详细的图表将在本文末尾展示)。
拥有电动汽车数据框后,我们也很容易看到价格分布:
df_prices = df_electric[df_electric['Catalog price'].notna()]
prices_to_display = df_prices.query('`Catalog price` < 170000')['Catalog price'].to_numpy()
hist_e, edges_e = np.histogram(prices_to_display, density=False, bins=50)
# Draw
p = figure(width=1400, height=500,
title=f"Electric cars price distribution in the Netherlands ({df_electric.shape[0]} cars total)")
p.quad(top=hist_e, bottom=0, left=edges_e[:-1], right=edges_e[1:], line_color="darkblue")
p.x_range.start = 15000
p.x_range.end = 150000
p.y_range.start = 0
p.xaxis[0].ticker.desired_num_ticks = 20
p.left[0].formatter.use_scientific = False
p.below[0].formatter.use_scientific = False
p.xaxis.axis_label = "Price, EUR"
p.yaxis.axis_label = "Amount"
show(p)
输出如下:
电动汽车价格分布,作者提供的图片
如我们所见,分布大多向右偏斜。在计算直方图之前,我去除了右侧的异常值,否则由于 2–3 辆价格超过 1 百万欧元的汽车,图像几乎是空的。
使用直方图我们可以看到,大多数荷兰的电动汽车价格在€40–70K 范围内,但我们不知道具体是哪些车型。我们可以更详细地探索价格——让我们按车型名称分组价格:
df_price = df_electric[df_electric['Catalog price'].notna()]
def q0(x):
return x.quantile(0.01)
def q1(x):
return x.quantile(0.25)
def q3(x):
return x.quantile(0.75)
def q4(x):
return x.quantile(0.99)
agg_data = {'Catalog price': ['size', 'min', q0, q1, 'median', q3, q4, 'max']}
prices = df_price[['Model', 'Catalog price']].groupby('Model', as_index=False).agg(agg_data)
display(prices)
在这里,我将所有汽车按型号分组,并将数据汇总到一个表中:
按车型名称分组的电动汽车,作者提供的图片
让我们以箱形图的形式绘制这些数据:
prices = prices.sort_values(by=('Catalog price', 'median'), ascending=True)
models = prices["Model"].to_numpy()
q1 = prices["Catalog price"]["q1"].to_numpy()
q3 = prices["Catalog price"]["q3"].to_numpy()
v_min = prices["Catalog price"]["q0"].to_numpy()
v_max = prices["Catalog price"]["q4"].to_numpy()
palette = (Inferno10 + Magma10 + Plasma10 + Viridis10)[:models.shape[0]]
source = ColumnDataSource(data=dict(models=models,
bottom=q1,
top=q3,
color=palette,
lower=v_min,
upper=v_max))
p = figure(x_range=models, width=1400, height=500,
title=f"Electric cars price distribution in the Netherlands")
whisker = Whisker(base="models", upper="upper", lower="lower", source=source)
p.add_layout(whisker)
p.vbar(x='models', top='top', bottom='bottom', width=0.9, color='color',
line_color="black", source=source)
p.left[0].formatter.use_scientific = False
p.y_range.start = 0
show(p)
在这里,我按价格对所有车型进行了排序。结合箱线图,我们可以清楚地看到价格分布的情况:
电动汽车制造商和价格箱线图,图片由作者提供
不足为奇的是,分布顶部的是著名的豪华车,如保时捷、捷豹或 Lucid(顺便说一下,我之前从未听说过)。但更令人惊讶的是,这个分布中最便宜的车并不是最受欢迎的。例如,在荷兰只有 1,269 辆“Smart”和 15,414 辆“Renault”,相比之下,65,885 辆“Tesla”模型不到 25%。我甚至怀疑图表中是否有错误,但2021 年英国汽车销售分布总体上看起来是一样的。
最大续航 **(公里)**可能是选择电动汽车时一个重要的因素,构建一个显示续航与价格相关性的图表会很有趣。但遗憾的是,RDW 数据集中没有“续航”字段。一些不同电动汽车的值可以从Kaggle 数据集中获得。但实际上,两张表之间没有直接匹配。例如,数据集中有一个“E-TRON SPORTBACK 50 QUATTRO”模型。在 RDW 数据中,有两个类似名称的车型,“E-TRON SPORTBACK 50”和“Q4 SPORTBACK 50 E-TRON”,但我不确定这些车型是否实际上是一样的。每个型号名称中的字母可能都有自己的含义,没有汽车专家的知识,很难匹配所有汽车制造商的所有名称。不过,有兴趣的读者可以自行尝试。
至少,拥有来自 RDW 数据的汽车价格和注册日期,我们可以构建散点图:
df_data = df_electric[df_electric['Catalog price'].notna()]
models = df_data['Model'].unique()
p = figure(x_axis_type='datetime', width=1400, height=800,
title="Electric car prices and registrations in the Netherlands")
palette = (Inferno10 + Magma10 + Plasma10 + Viridis10)[:len(models)]
draw_ratio = 15
for ind, model in enumerate(models):
df_model = df_data[df_data['Model'] == model]
if df_model.shape[0]//draw_ratio == 0:
continue
df_model = df_model.sample(df_model.shape[0]//draw_ratio)
x = df_model['First registration NL'].to_numpy()
y = df_model['Catalog price'].to_numpy()
p.scatter(x, y, size=2, color=palette[ind], legend_label=model[:3])
p.left[0].formatter.use_scientific = False
p.legend.orientation = "horizontal"
p.legend.location = "top_center"
p.legend.click_policy = "mute"
p.xaxis[0].ticker.desired_num_ticks = 20
show(p)
在这里,我将所有汽车按型号名称分组,然后从每个子集随机抽取 1:15 的样本(显然,我们不能在一个图上绘制所有 216316 辆车)。Bokeh 的一个非常棒的功能是能够通过点击“静音”标签,例如,仅用鼠标点击突出显示 Tesla 汽车:
价格和注册日期散点图,图片由作者提供
看到季节性模式相当有趣——看起来汽车注册每年“波动”4 次,每年年底会有大量新车注册。也许有些客户特别等待年末促销,或者只是想以新车开始新的一年?第二个有趣的点是,在 2019 年出现了大量中等价格范围的电动汽车。在此之前,选择仅限于便宜的和高端的车型(当然,相对便宜,因为大多数电动汽车与汽油车相比都属于高端细分市场)。
结论
如我们所见,使用来自“现实世界”的数据集带来了一些挑战。这不仅仅是数据规模,还有数据不完整或不一致,甚至数据字段中的语法错误(例如,我看到一个“Tesla Raodster”而不是“Tesla Roadster”),等等。与此同时,探索这些数据并发现其中有趣的模式要有趣得多,我建议读者自己做类似的实验。这项分析是为了娱乐和自我教育目的,显然还有很大的改进空间,例如寻找更好的检测方法以判断汽车是否是电动的,找出不同型号的续航里程,等等。而荷兰政府保持这些数据公开并对所有人开放,这很好。如果有人知道其他国家的数据集,请在下方评论中添加链接,我将尝试做类似的分析并在下一个帖子中比较结果。
如果你喜欢这个故事,可以随时订阅 Medium,你将获得新文章发布的通知,并可以全面访问其他作者的数千个故事。
感谢阅读。
使用 spacy-llm 进行优雅的提示版本管理和 LLM 模型配置
使用 spacy-llm 简化提示管理并创建数据提取任务
·
关注 发表在 Towards Data Science ·5 分钟阅读·2023 年 7 月 26 日
–
一张 整洁的桌子,如果你使用 spacy-llm,你的代码将会像这样哈哈
管理提示和处理 OpenAI 请求失败可能是一个具有挑战性的任务。幸运的是,spaCy 发布了 spacy-llm,这是一个强大的工具,可以简化提示管理,并消除了从头创建自定义解决方案的需求。
在本文中,你将学习如何利用 spacy-llm 创建一个从文本中提取数据的任务。我们将深入了解 spaCy 的基础知识,并探索一些 spacy-llm 的功能。
spaCy 和 spacy-llm 101
spaCy 是一个用于 Python 和 Cython 的高级 NLP 库。在处理文本数据时,通常需要几个处理步骤,例如分词和词性标注。为了执行这些步骤,spaCy 提供了 nlp
方法,它会调用一个处理管道。
spaCy v3.0 引入了 config.cfg
,这是一个我们可以在其中包括这些管道详细设置的文件。
config.cfg
使用了 confection,这是一个允许创建任意对象树的配置系统。例如,confection 解析以下 config.cfg
:
[training]
patience = 10
dropout = 0.2
use_vectors = false
[training.logging]
level = "INFO"
[nlp]
# This uses the value of training.use_vectors
use_vectors = ${training.use_vectors}
lang = "en"
进入:
{
"training": {
"patience": 10,
"dropout": 0.2,
"use_vectors": false,
"logging": {
"level": "INFO"
}
},
"nlp": {
"use_vectors": false,
"lang": "en"
}
}
每个管道使用组件,spacy-llm 将管道组件存储到使用 catalogue 的注册表中。这个库,同样来自 Explosion,引入了函数注册表,以便高效地管理组件。llm
组件被定义在 两个主要设置 中:
要在我们的管道中包含一个使用 LLM 的组件,我们需要遵循几个步骤。首先,我们需要创建一个任务并将其注册到注册表中。接下来,我们可以使用模型来执行提示并检索响应。现在是时候完成这些操作,以便我们可以运行管道了
创建一个从文本中提取数据的任务
我们将使用 dummyjson.com/
上的引用,并创建一个任务来从每个引用中提取上下文。我们将创建提示、注册任务,并最终创建配置文件。
1. 提示
spacy-llm 使用 Jinja 模板来定义指令和示例。 {{ text }}
将被我们提供的引用所替换。这是我们的提示:
You are an expert at extracting context from text.
Your tasks is to accept a quote as input and provide the context of the quote.
This context will be used to group the quotes together.
Do not put any other text in your answer and provide the context in 3 words max.
{# whitespace #}
{# whitespace #}
Here is the quote that needs classification
{# whitespace #}
{# whitespace #}
Quote:
'''
{{ text }}
'''
Context
2. 任务类
现在让我们创建任务的类。这个类应该实现两个函数:
-
**generate_prompts(docs: Iterable[Doc]) -> Iterable[str]**
:一个函数,它接受一个 spaCy[Doc](https://spacy.io/api/doc)
对象的列表,并将其转换为一个提示的列表 -
**parse_responses(docs: Iterable[Doc], responses: Iterable[str]) -> Iterable[Doc]**
:一个将 LLM 输出解析为 spaCy[Doc](https://spacy.io/api/doc)
对象的函数
**generate_prompts**
将使用我们的 Jinja 模板,而 **parse_responses**
将为我们的 Doc 添加上下文属性。这是 QuoteContextExtractTask
类:
from pathlib import Path
from spacy_llm.registry import registry
import jinja2
from typing import Iterable
from spacy.tokens import Doc
TEMPLATE_DIR = Path("templates")
def read_template(name: str) -> str:
"""Read a template"""
path = TEMPLATE_DIR / f"{name}.jinja"
if not path.exists():
raise ValueError(f"{name} is not a valid template.")
return path.read_text()
class QuoteContextExtractTask:
def __init__(self, template: str = "quotecontextextract.jinja", field: str = "context"):
self._template = read_template(template)
self._field = field
def _check_doc_extension(self):
"""Add extension if need be."""
if not Doc.has_extension(self._field):
Doc.set_extension(self._field, default=None)
def generate_prompts(self, docs: Iterable[Doc]) -> Iterable[str]:
environment = jinja2.Environment()
_template = environment.from_string(self._template)
for doc in docs:
prompt = _template.render(
text=doc.text,
)
yield prompt
def parse_responses(
self, docs: Iterable[Doc], responses: Iterable[str]
) -> Iterable[Doc]:
self._check_doc_extension()
for doc, prompt_response in zip(docs, responses):
try:
setattr(
doc._,
self._field,
prompt_response.replace("Context:", "").strip(),
),
except ValueError:
setattr(doc._, self._field, None)
yield doc
现在我们只需将任务添加到 spacy-llm 的 llm_tasks
注册表中:
@registry.llm_tasks("my_namespace.QuoteContextExtractTask.v1")
def make_quote_extraction() -> "QuoteContextExtractTask":
return QuoteContextExtractTask()
3. config.cfg 文件
我们将使用 OpenAI 的 GPT-3.5 模型。spacy-llm 为此提供了一个模型,所以我们只需确保秘密密钥作为环境变量可用:
export OPENAI_API_KEY="sk-..."
export OPENAI_API_ORG="org-..."
为了构建运行管道的 nlp
方法,我们将使用 spacy-llm 的 assemble
方法。该方法从 .cfg
文件中读取。文件应引用 GPT-3.5 模型(它已在注册表中)和我们创建的任务:
[nlp]
lang = "en"
pipeline = ["llm"]
batch_size = 128
[components]
[components.llm]
factory = "llm"
[components.llm.model]
@llm_models = "spacy.GPT-3-5.v1"
config = {"temperature": 0.1}
[components.llm.task]
@llm_tasks = "my_namespace.QuoteContextExtractTask.v1"
4. 运行管道
现在我们只需将所有内容组合在一起并运行代码:
import os
from pathlib import Path
import typer
from wasabi import msg
from spacy_llm.util import assemble
from quotecontextextract import QuoteContextExtractTask
Arg = typer.Argument
Opt = typer.Option
def run_pipeline(
# fmt: off
text: str = Arg("", help="Text to perform text categorization on."),
config_path: Path = Arg(..., help="Path to the configuration file to use."),
verbose: bool = Opt(False, "--verbose", "-v", help="Show extra information."),
# fmt: on
):
if not os.getenv("OPENAI_API_KEY", None):
msg.fail(
"OPENAI_API_KEY env variable was not found. "
"Set it by running 'export OPENAI_API_KEY=...' and try again.",
exits=1,
)
msg.text(f"Loading config from {config_path}", show=verbose)
nlp = assemble(
config_path
)
doc = nlp(text)
msg.text(f"Quote: {doc.text}")
msg.text(f"Context: {doc._.context}")
if __name__ == "__main__":
typer.run(run_pipeline)
然后运行:
python3 run_pipeline.py "We must balance conspicuous consumption with conscious capitalism." ./config.cfg
>>>
Quote: We must balance conspicuous consumption with conscious capitalism.
Context: Business ethics.
如果你想更改提示,只需创建另一个 Jinja 文件,并以与第一次创建相同的方式创建 my_namespace.QuoteContextExtractTask.v2
任务。如果你想更改温度,只需在 config.cfg
文件中更改参数。不错,对吧?
最后的思考
处理 OpenAI REST 请求的能力及其直接存储和版本控制提示的方法是我最喜欢 spacy-llm 的地方。此外,该库提供了一个用于缓存每个文档的提示和响应的缓存功能,一个为少量示例提示提供示例的方法,以及日志记录功能等。
你可以在这里查看今天的完整代码:github.com/dmesquita/spacy-llm-elegant-prompt-versioning
。
一如既往,谢谢你的阅读!
提升你的商业分析:季节调整的逐步指南
·发布于 Towards Data Science ·7 分钟阅读·2023 年 11 月 27 日
–
我们都理解将时间序列拆解为其组成部分以进行预测的重要性,但在商业绩效分析中却没有得到足够的重视。
作为一名商业绩效分析师,我经常报告月度收入绩效并跟踪商业周期趋势。为了处理季节性变化的问题,我依赖于同比比较。问题在于,这些比较依赖于 12 个月前的数据,这意味着你会晚于趋势,这可能会带来毁灭性的后果。经济学家和统计学家有更复杂的方法来应对季节性波动,并在业务周期发生变化后尽快捕捉到这些变化。
经济学家分解宏观经济数据以报告季节调整后的数据,并依赖于季节调整后的指标的月度(或季度)变化,以及时了解经济活动。
图片由 Stephen Dawson 提供,来源于 Unsplash
你无需成为统计学家或经济学家来跟上你的商业趋势。美国人口普查局将他们的 X-13ARIMA-SEATS 季节调整软件公开发布,下面是如何在 Python 中利用它来提升你的商业分析。
下载 X 13 ARIMA SEATS
你可以利用 Statsmodels X13_arima_analysis 这个 Python 封装器来调整你的商业数据以应对季节波动。
首先,你需要从 Census 网站 下载 X-13ARIMA-SEATS 可执行文件。
最新版本——60 版(撰写时)对我不起作用,所以我下载了之前的版本——59 版。
下载完成后,你可以在你选择的文件夹中解压文件。
解压后,你应该得到一个像这样的文件夹。(图像来源于作者)
设置你的 Python 笔记本。
除了导入你通常用于数据分析的包外,你还需要设置环境变量 X13PATH 为解压文件夹的路径。如果跳过这一步,你在运行分析时会出现错误。
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from statsmodels.tsa.x13 import x13_arima_analysis
from datetime import datetime
from dateutil.relativedelta import relativedelta
import os
# Set the X13PATH environmental variable to the folder where you unzipped the X-13 executables
os.environ['X13PATH'] = r"C:\...\x13as_ascii-v1-1-b59\x13as"
导入并准备你的数据
在这个示例中,我使用了公开报告的特斯拉季度收入。
要运行 X13_arima_analysis,你需要至少 3 年的数据以便模型捕捉季节性模式。你的数据应为每月或每季度。你需要将日期列设置为数据框的索引,并确保指定频率。
#load data
df = pd.read_excel("TSLA_Revenue.xlsx")
#set date as index
df.set_index('date', inplace=True)
#set frequency as quarter
df= df.resample('Q').asfreq()
#View index
df.index
确保频率设置为季度或每月
# display data
df.head()
收入单位为百万美元
这些是你需要的唯一转换。
实例化 x_13_arima_analysis
# Run X-13ARIMA-SEATS decomposition
results = x13_arima_analysis(df['revenue'])
x_13_arima_analysis 结合了 ARIMA 建模和 SEATS 过滤来分解时间序列数据。分析中的 ARIMA(自回归积分滑动平均)部分基于数据的过去值和误差对数据进行建模。SEATS(ARIMA 时间序列中的信号提取)部分专注于隔离时间序列成分(趋势、周期、季节性、异常)
x_13_arima 提供通过从实际收入中去除季节性成分来获得季节调整后的收入。
# Get the seasonally adjusted series
seasonally_adjusted = results.seasadj
# Visualize revenue and seasonally adjusted revenue
plt.figure(figsize=(10, 6))
plt.subplot(311)
plt.plot(df.index, df['revenue'], label='Original Data')
plt.legend()
plt.subplot(312)
plt.plot(df.index, seasonally_adjusted, label='Seasonally Adjusted')
plt.legend()
plt.tight_layout()
plt.show()
季节性调整去除了季节性模式(图像来源于作者)
验证
在这次分析中,我们不专注于预测能力。相反,我们想要分析季节调整后的数据,以便紧密跟随我们的业务趋势。
然而,我们仍需检查模型是否成功分解了时间序列。
你可以使用 QS 统计量来评估分析的稳健性。目标是使 QS 统计量低于 1。你的 QS 统计量结果越接近 0,就越表示残差与白噪声不可区分或不相关。
print(results.results)
结果打印了大量信息。你需要滚动直到找到 QS 统计量。
解读结果
现在我们有了季节调整后的数据,我们可以用两种方式进行业务趋势分析。
首先,我们可以关注季节调整后收入的季度环比增长。
# Calculate the % chg to the previous quarter
df['QoQ %chg'] = df['revenue'].pct_change() * 100
df['QoQ% chg adjusted'] = df['seasadj'].pct_change() * 100
# Getting the index positions for x-axis locations
x = range(len(df.index))
# Plotting the bar chart
plt.figure(figsize=(10, 6))
bar_width = 0.40
plt.bar(x, df['QoQ %chg'], width=bar_width, align='center', label='% chg', color='blue', alpha=0.7)
plt.bar([i + bar_width for i in x], df[f'QoQ% chg adjusted'], width=bar_width, align='center', label='% chg adjusted', color='teal', alpha=0.7)
# Enhance the visualization
plt.axhline(y=0, color='gray', linestyle='--', linewidth=1)
plt.xlabel('Date')
plt.ylabel('% Change')
plt.title(f'Comparison of {metric_name} % chg and % chg adjusted')
plt.legend()
plt.xticks([i + bar_width/2 for i in x], df.index.strftime('%Y-%m-%d'), rotation=45)
plt.tight_layout()
plt.show()
QoQ 变化(图像来源于作者)
2023 年第一季度环比百分比变化突出显示了使用季节调整数据的重要性。未经调整的情况下,收入相比上季度有所下降;然而,一旦调整季节性模式后,我们看到收入增长,表明业务趋势强劲。
第三季度情况正好相反;季节调整后的收入下降幅度超过预期,这应引发进一步的分析。
第二项分析涉及计算季节调整年化率(SAAR)
SAAR = ((季节调整后的收入 * 4 )/ 去年收入) - 1
对于季度数据,我们将数据乘以 4 以年化;如果是月度数据,我们将乘以 12。这个措施有助于提供全年数据的平滑、标准化视图。
请记住,SAAR 不是预测。 但它可以通过提供更清晰的财务状况来帮助你做出明智的业务决策。
知道这两条路径后,我们可以定义一个函数来自动化分析。
def results_analysis(result = results, analysis_date= '2023-09-30', freq ='Quarter'):
"""
This function takes the results from X13 arima analysis and returns a dataframe with:
- Revenue
- Seasonally adjusted Revenue
- Revenue vs previous period
- Seasonally adjusted Revenue vs previous period
- SAAR : Seasonally adjusted annual rate
- SAAR %chg vs last year
The funtion also print key financial output
- Revenue
- Revenue vs last year
- Revenue vs previous period
- Seasonally adjusted Revenue vs previous period
- SAAR
- SAAR %chg vs last year
Parameters
----------
result : statsmodels.tsa.x13.X13ArimaAnalysisResult object
the result from instantiating x13_arima_analysis
analysis_date : str
the date for analysis
freq : str, optional
the frequency of our data, either "Quarter" or "Month" (default is Quarter)
"""
#get the observed & Seasonally adjusted data into Dataframe
observed = pd.DataFrame(result.observed)
seasonal_adj = pd.DataFrame(result.seasadj)
df = pd.concat([observed,seasonal_adj],axis=1)
# get data from previous Year until analysis_date
analysis_date = datetime.strptime(analysis_date, '%Y-%m-%d') # convert variable to datetime
last_year = analysis_date.year -1
df = df[df.index.year >= last_year].copy()
#Calculate QoQ or MoM revenue change and Sesonally adjusted revenue change
metric_name = 'QoQ' if freq == 'Quarter' else 'MoM'
df[f'{metric_name} %chg'] = df['revenue'].pct_change() * 100
df[f'{metric_name}% chg adjusted'] = df['seasadj'].pct_change() * 100
#calculate LY revenue
ly_revenue = df[df.index.year == last_year]['revenue'].sum()
#Calculate Seasonally Adjusted Annual Rate and chg
annual_factor = 4 if freq == 'Quarter' else 12 # assing annual factor for SAAR calculation
df['SAAR'] = df.apply(lambda row: row['seasadj'] * annual_factor if row.name.year == analysis_date.year else None, axis=1)
df['SAAR % Chg'] = df.apply(lambda row: (row['SAAR'] / ly_revenue - 1)*100 if row.name.year == analysis_date.year else None, axis=1)
data = df[df.index==analysis_date]# get the data for the analysis date
ly_data = df[df.index==(analysis_date - relativedelta(years=1))]# get the data for the previous year analysis date
#Print results
print(f'{freq} Revenue: {data["revenue"][0]}')
print(f'{freq} Revenue YoY %chg: {(data["revenue"][0]/ly_data["revenue"][0]-1)*100 :.1f}')
print(f'{freq} Revenue {metric_name} %chg: {data[f"{metric_name} %chg"][0] :.1f}')
print(f'{freq} Seasonally adjusted Revenue {metric_name} %chg: {data[f"{metric_name}% chg adjusted"][0] :.1f}')
print(f'Seasonally adjusted annual rate: {data["SAAR"][0]}')
print(f'Seasonally adjusted annual rate %chg: {data["SAAR % Chg"][0] :.1f}')
return df
df_results = results_analysis(results)
这个功能可以帮助你将所有相关的比较放在一起(图像由作者提供)
这些步骤提供了一个简单的框架,可以通过及早捕捉和应对业务趋势变化来提升您的业务分析能力。
参考文献
[1] 美国人口普查局。X-13ARIMA-SEATS 文档。获取自www2.census.gov/software/x-13arima-seats/x-13-data/documentation/docx13as.pdf
[2] Statsmodels. X-13 ARIMA 分析文档。获取自www.statsmodels.org/dev/_modules/statsmodels/tsa/x13.html#x13_arima_analysis
[3] Singstat。季节调整。获取自www.singstat.gov.sg/find-data/quizzes/seasonal-adjustment
[4] Macrotrends。特斯拉财务报表。获取自www.macrotrends.net/stocks/charts/TSLA/tesla/income-statement?freq=Q
[5] JDemetra+文档。季节调整输出 — X13。获取自jdemetradocumentation.github.io/JDemetra-documentation/pages/reference-manual/sa-output-X13.html
[6] Conerly, Bill (2014 年 12 月 17 日)。如何调整您的业务数据以适应季节性变化。Forbes。获取自www.forbes.com/sites/billconerly/2014/12/17/how-to-adjust-your-business-data-for-seasonality/?sh=3b3522ed421c
[7] Investopedia。季节调整。获取自www.investopedia.com/terms/s/seasonal-adjustment.asp
[8] 达拉斯联邦储备银行。季节性调整数据。获取自 www.dallasfed.org/research/basics/seasonally
提升你的数据科学职业生涯:如何成为一名高级数据科学家
作者概述了五种策略,这些策略将使你的数据科学实践提升到高级角色。
·发表于 Towards Data Science ·阅读时间 8 分钟·2023 年 11 月 4 日
–
你已经在数据科学领域工作了几年,你的目标是达到下一个层级。在你目前的数据科学家角色中表现出色是至关重要的,但在许多组织中,仅靠这点并不足以推动你向前发展。你需要做得更多——或者有所不同。在这篇文章中,我旨在提供一些宝贵的想法和例子,指导你朝着高级数据科学家的方向发展。不论你是在追求内部晋升还是考虑外部机会,希望这些内容能帮助你取得成功!
图片由 Ante Hamersmit 提供,来源于 Unsplash
数据科学家与高级数据科学家的区别
虽然每个组织在定义不同级别的数据科学家时会有所不同,但许多组织在每个级别的基本职责和工作范围上存在共识。
在这篇文章中,我概述了数据科学家三级等级体系的工作范围差异。某些组织可能有更细化的等级,但我相信我描述的职业发展路径在许多公司中是适用的。
[## 解码数据科学家等级体系:从初级到高级——区分所在角色的关键]
揭示初级、中级和高级数据科学家的工作范围期望
towardsdatascience.com
成为高级数据科学家必须掌握技术数据科学技能。经过几年的经验,你的技术工具包应该大幅增长,涵盖各种数据处理和准备技术,以及广泛的机器学习模型,你不仅能应用,还能微调、评估性能并理解其行为。此外,多年的经验也应让你对你所参与的业务有深入的了解。
但仅凭这一点可能无法让你达到高级/首席数据科学家的水平。
你需要扩展工作范围,涵盖构建数据科学解决方案的技术方面以外的内容。
特别是,我坚信下面五个领域将对你成为你想要的高级数据科学家至关重要:
-
重新思考你的终点线
-
了解你的利益相关者
-
创造机会
-
掌握流程
-
成为一名教师
1 — 重新思考你的终点线
“从头到尾领导数据科学项目”
Anton Shuvalov 的照片,来源于 Unsplash
以分类模型作为数据科学工作的一个例子。假设是一个客户流失模型——那这项工作会是什么样的呢?
-
对于被分配这项工作的初级数据科学家,他们的终点线将是在开发环境中训练和验证过的模型。然后,他们将与高级人员合作进行 QA,并达到可用输出的阶段。
-
对于承担相同工作的数据科学家,他们的终点线可能是训练和验证过的模型已经在 QA 环境中测试并生产化:输出在生产环境中以定义的节奏生成。然后,他们将与高级人员或经理合作,确保这些输出被使用。
-
对于高级数据科学家而言,终点线看起来有所不同:他们不仅需要训练、验证、QA、生产化模型,还需确保输出被利益相关者使用,并且模型的价值被衡量。在我们的流失例子中,这可能意味着设置并部署一个保留活动或项目,测量使用模型输出的效果,并与业务沟通,做出推广或继续执行项目的决定。
高级数据科学家项目只有在利益相关者将其用于日常操作时才算成功。
尽管我使用了“项目”术语,但必须认识到,高级数据科学家的项目是持续进行的:一旦部署并证明成功,模型使用需要被监控和优化,模型本身也需要定期刷新和测试。这就是为什么高级数据科学家在项目实施后可能会将其视为程序。
通常,程序的技术方面(如模型开发或更新)是最短的阶段。与利益相关者合作进行模型部署并确保其有效使用通常需要更多的时间、精力和协调。这就是高级数据科学家往往需要戴上项目管理帽子,领导程序走向成功的采用和利用的原因。
2 — 了解你的利益相关者
“与更广泛的跨职能领域建立关系”
照片由 mauro mora 提供,刊登于 Unsplash
凭借多年的经验,数据科学家很可能已经与他们的直接利益相关者建立了宝贵的关系——这可能是工程团队、市场、战略或财务部门的常规利益相关者。
高级数据科学家应当证明他们积极地与组织内更广泛的领域建立了关系。
高级数据科学家了解不同部门面临的业务挑战,他们知道不断变化的优先级和运作方式。
培养这些关系的各种方法可以是与关键利益相关者建立定期聊天或联系点,被邀请参加其他部门的定期绩效会议,或者主动向其他团队介绍自己,并对他们的工作和挑战表现出真正的好奇心。
高级数据科学家还利用他们对利益相关者的理解和深厚的商业洞察力来进行有效沟通。他们非常了解他们的受众,并量身定制他们的沟通和演讲风格以及细节的水平,以适应每种情况。他们在制定内容之前会考虑“对他们有什么好处”,并讲述与利益相关者产生共鸣的故事。例如,他们理解市场总监优先考虑经过验证的项目或程序价值,而财务副总裁则关注方法论的稳健性。
3 — 创造机会
“识别并推动新的项目机会”
照片由 Lukas Tennie 提供,刊登于 Unsplash
了解利益相关者及其挑战使高级数据科学家能够找到支持他们的方法。他们能够理解利益相关者面临的业务问题,并主动构思有效、相关和可操作的数据科学解决方案。
尽管高级数据科学家的“终点线”远远超出了技术部分的完成,“起点线”则位于比数据收集或模型构建准备阶段更上游的位置。
这是一种“始终在线”的态度,数据科学家与利益相关者或团队成员的每一次互动都可能带来新机会。高级数据科学家总是问自己:“我们如何利用已有的资源,或者我们是否可以开发一些新的东西来做得更好?”
一旦你对自己可以做什么有了清晰的想法——尤其是它如何被使用以及会带来什么价值,就该向你的经理提出这个想法以获得批准。随后,你可以回到利益相关者那里,评估他们的兴趣,并启动一个更正式的项目。投资一些时间构建一个初步的原型或概念验证来说明你的想法可能会很有帮助。
凭借对分析/数据科学能力和不同业务领域的深刻理解,高级数据科学家能够连接各个环节,并创造机会以解决业务问题或改善业务功能。
4 — 精通流程
“建议改进流程和程序”
图片由Sam Moghadam Khamseh拍摄,来源于Unsplash
高级数据科学家的深刻业务理解扩展到他们组织的流程和程序。
高级数据科学家知道如何请求新数据,数据的摄取、整合和建模步骤。他们知道将结果投入生产所需的步骤,对这些结果部署到各种业务平台非常熟悉,并确保结果对业务利益相关者是可访问和可操作的。
他们不仅知道并遵循这些流程和程序,还识别改进并实施黄金标准的数据、分析和机器学习操作流程,供整个数据团队遵循。
高级数据科学家有效地驾驭组织结构,利用可用的支持功能——项目经理、业务分析师、产品负责人——以推动他们的项目向前发展。
5 — 成为一名教师
“指导和培训数据科学家和数据分析师”
图片由Markus Spiske提供,Unsplash
正如在这篇精彩的文章中提到的,指导初级人员或数据科学家有很多好处,是被视为高级数据科学家的一个重要前提。
高级数据科学家是数据科学家或数据分析师的参考点。
通过花时间支持更多初级团队成员,高级数据科学家在团队内培养了知识共享的文化,建立了他们的信誉,赢得了信任,增强了合作,并获得了未来可能在人事管理中的经验。
结论
我们已经看到,技术专长不足以让你晋升到高级或首席数据科学家的级别。
高级数据科学家主动识别业务中的数据科学项目或产品机会,并推动这些数据科学解决方案的业务使用,而不仅仅是构建它们。
提高他们在组织内的可见性也是高级数据科学家不可或缺的职责:他们通过作为数据科学家和数据分析师的参考点,理解和支持广泛的职能领域、他们的过程和利益相关者,积极作为一个优秀的团队成员。
我希望这能给你提供想法和方向,帮助你在数据科学家的职业阶梯上晋升!
你怎么看?我有没有遗漏什么?请在下面的评论中告诉我!
来源
[1] G.Colley,《解码数据科学家等级:从初级到高级——他们有什么不同?》(2023)
阐明初级、中级和高级数据科学家的工作期望范围
towardsdatascience.com
[2] E.Berge,《成为数据科学家所需的软技能》(2023)
能推动你职业发展的前五大软技能
towardsdatascience.com
并且不要犹豫关注我,以获取更多数据科学职业/领导力内容!
## 5 个促进数据科学家/分析师参与的想法,而不让会议压得喘不过气来
作者分享了他们成功实施的策略,以实现这一平衡。
[towardsdatascience.com