TowardsDataScience 2023 博客中文翻译(六十一)

原文:TowardsDataScience

协议:CC BY-NC-SA 4.0

使用 Streamlit 构建 Medium 统计跟踪器

原文:towardsdatascience.com/building-a-medium-stats-tracker-with-streamlit-dfe75f69b8fc

使用 Python Streamlit 库跟踪和监控 Medium 统计数据

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 Andy McDonald

·发表于 Towards Data Science ·9 分钟阅读·2023 年 2 月 27 日

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

照片由 Markus Winkler 提供,来自 Unsplash

我已经在 Medium 上写作了一段时间,最近开始使用 Excel 跟踪我的统计数据。但最近,我在考虑使用 Streamlit 构建一个应用,以提供更好的体验。所以我想,为什么不试试,并在构建的过程中写一篇文章呢?

在本文中,我们将介绍构建一个简单的 Streamlit 仪表板的过程,并提供一种方法,让最终用户通过表单输入他们的最新统计数据。到文章末尾,我们将拥有一个可以进一步构建并在以后变得更加完善的基本仪表板。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

用于查看和分析 Medium 统计数据的 Streamlit 仪表板。图片由作者提供

如果你刚刚开始使用 Streamlit,你可能想查看我下面的文章。

## 入门 Streamlit 网络应用

温和地介绍创建 Streamlit 网络应用

towardsdatascience.com ## 入门 Streamlit:开始时需要知道的 5 个函数

利用这 5 个函数简化你的 Streamlit 学习

[towardsdatascience.com

导入库并设置 CSV 文件

第一步是导入我们的关键 Python 库:streamlitpandasplotly_express。接着,我们需要将应用程序配置为默认的宽屏显示,同时添加应用程序的标题。

import streamlit as st
import pandas as pd
import plotly_express as px

st.set_page_config(layout='wide')

st.header('Medium Stats Dashboard')

接下来,我们需要在与应用程序相同的目录中创建一个新的 CSV 文件,并将其命名为medium_stats

我们需要打开文件并填写四列。我们可以通过代码完成此操作,但这是一种快速且简单的方式来设置文件。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

Streamlit 中的 Medium Stats 仪表板的 CSV 起始文件。图像由作者提供。

从 Streamlit 应用程序向 CSV 文件中写入统计数据的功能

我们将设置的第一个功能是允许用户在仪表板中输入他们的统计数据并更新 CSV 文件。

def update_stats_csv(date, followers, email_subs, ref_members):
    with open('medium_stats.csv', 'a+') as f:
        f.write(f'\n{date},{followers},{email_subs},{ref_members}')

这段代码将打开 CSV 文件,并在其上追加一行,其中每个统计数据由逗号分隔。

接下来,我们需要创建一个简单的表单,允许用户输入统计数据。这是通过 Streamlit 中的st.form功能实现的。然后我们可以使用 with 语句,开始添加我们希望在仪表板中跟踪的每个关键统计数据的输入框数量。

我们还会将其添加到侧边栏中,以免主显示区域显得杂乱。

with st.sidebar.form('Enter the latest stats from your Medium dashboard'):

    date = st.date_input('Date')

    follower_num = st.number_input('Followers', step=1)
    email_subs = st.number_input('Email Subscribers', step=1)
    ref_members = st.number_input('Referred Members', step=1)

    submit_stats = st.form_submit_button('Write Stats to CSV')

    if submit_stats:
        update_stats_csv(date, follower_num, email_subs, ref_members)
        st.info(f'Stats for {date} have been added to the csv file')

当我们重新运行 Streamlit 应用时,应该会在侧边栏看到以下表单。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

Streamlit 侧边栏中的 Medium Stats 录入表单。图像由作者提供。

当我们选择一个日期并添加几个数字时,我们可以点击Write Stats to CSV按钮,将值提交到 CSV 文件中。我们还会看到一个弹出提示,表示统计数据已被添加到文件中。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

添加一个月统计数据后的 Streamlit Medium Stats 录入表单。图像由作者提供。

如果我们返回到 CSV 文件中,将会看到文件中新增了一行,我们刚刚添加的统计数据就在其中。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

从 Streamlit 应用程序写入统计数据后的 CSV 文件。图像由作者提供。

很好!我们确认可以直接将统计数据添加到 CSV 文件中。现在我们可以继续加载 CSV 文件,并开始创建所有月份的图表。

读取包含所有统计数据的 CSV 文件

在 Streamlit 应用的主要部分,我们将添加一个展开器。当点击并展开它时,我们将能够在 pandas 数据框中查看 CSV 文件中的原始数据。

df = pd.read_csv('medium_stats.csv')

with st.expander('View Raw Data'):
    st.dataframe(df)

如果我们返回到 Streamlit 应用中,我们将看到展开窗口,并查看 CSV 文件中的所有统计数据。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

使用 Streamlit 的 st.expander 查看来自统计 CSV 文件的原始数据。图片由作者提供。

创建 Plotly 图表以查看 Medium 统计数据

现在我们已经成功将所有统计数据加载到 Streamlit 中,我们可以开始制作一些互动图表,以帮助我们理解趋势。对于这个应用程序,我们将使用 Plotly Express 创建图表。

使用起来非常简单,并且只需很少的代码就能获得强大且互动的图表。

我们将创建的第一个图表是每月关注者数量的线图。

fig_followers = px.line(df, x=df.date, y='followers', title='Followers')
st.plotly_chart(fig_followers, use_container_width=True)

当我们回到 Streamlit 应用程序时,我们现在可以看到我们的图表。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

Streamlit 中的基础 Plotly Express 图表。图片由作者提供。

我们的文件中只有一个数据点,因此在线图中没有可查看的内容。为了解决这个问题,我们需要在 CSV 文件中添加更多数据。

在下面的示例中,我添加了从八月到二月底的多个月份。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

添加多个日期后的 medium_stats CSV 文件。图片由作者提供。

现在我们可以回到 Streamlit 应用程序,查看 Plotly Express 线图。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

Plotly Express 线图显示 Medium 关注者随时间的变化。图片由作者提供。

在我们的数据集中,我们跟踪了另外两个统计数据:电子邮件订阅者数量和推荐成员数量。我们将这两个数据并排显示在两个柱状图中。

为此,我们首先需要使用 st.columns() 函数创建两列,然后使用 px.bar 来设置这两个柱状图。

为了确保图表填满每一列,我们需要包括 use_container_width 参数并将其设置为 True

plot_col1, plot_col2 = st.columns(2)

fig_subscribers = px.bar(df, x=df.date, y='email_subs', title='Email Subscribers')
plot_col1.plotly_chart(fig_subscribers, use_container_width=True)

fig_subscribers = px.bar(df, x=df.date, y='referred_members', title='Referred Members')
plot_col2.plotly_chart(fig_subscribers, use_container_width=True)

我们的应用程序现在有了两个额外的柱状图,并且我们可以很好地概览 Medium 统计数据随时间的变化。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

Medium 统计仪表板,包括跟踪关注者数、电子邮件订阅者和推荐成员的图表。图片由作者提供。

随着我们开始向 CSV 文件中添加更多日期,我们可能需要考虑引入日期过滤器或按年分组数据,尤其是当我们在平台上待了很长时间时。

将最新月份的指标添加到 Streamlit 应用程序中

拥有互动图表是很棒的,然而,如果我们真的想知道当前月份的关注者数或电子邮件订阅者数,我们必须查看图表并尝试解决这个问题。我们需要为每个图表进行这项工作。

一种替代且更快捷的方法是使用 Streamlit 的指标将最新月份的统计数据作为突出数字显示在仪表板上。

如果我们假设数据框中的最后一行是最新月份,我们可以使用 pandas 的 iloc 函数提取每一列的最后值。

def get_latest_monthly_metrics(df):
    # Assumes the last row is the latest entry

    df['date'] = pd.to_datetime(df['date'])
    df['month'] = df['date'].dt.month_name()
    latest_month = df['month'].iloc[-1]
    latest_follower_count = df['followers'].iloc[-1]
    latest_sub_count = df['email_subs'].iloc[-1]
    latest_refs_count = df['referred_members'].iloc[-1]

    return latest_month, latest_follower_count, latest_sub_count, latest_refs_count

我们可以通过确保数据始终按日期排序来使这段代码更健壮。

下一步是通过调用 get_latest_monthly_metrics() 函数来获取最新统计数据,然后将这些统计数据传递给 Streamlit 的 metric 调用,如下所示:

month, followers, subs, refs = get_latest_monthly_metrics(df)

st.write(f'### Medium Stats for {month}')

col1, col2, col3 = st.columns(3)
col1.metric('Followers', followers)
col2.metric('Email Subscribers', subs)
col3.metric('Referred Members', refs)

当我们回到应用时,我们现在可以在应用顶部显著地看到最新统计数据。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

在包含 Streamlit 指标后的 Medium Stats 仪表盘。图片由作者提供。

Streamlit 指标函数的一个优点是我们可以包含每个指标的变化量。

为了准备我们的数据,我们可以创建一个函数,为每个统计数据的 dataframe 添加一个差异列,如下所示:

def calculate_differences(df):
    for x in ['followers', 'email_subs', 'referred_members']:
        df[f'{x}_increase'] = df[x].diff()
    return df

然后我们需要更新 get_latest_monthly_metrics() 函数,以考虑新的列。我们将返回每个最新的统计数据作为包含两个值的列表:一个是实际的统计数据值,另一个是变化量或增量。

def get_latest_monthly_metrics(df):
    # Assumes the last row is the latest entry

    df['date'] = pd.to_datetime(df['date'])
    df['month'] = df['date'].dt.month_name()
    latest_month = df['month'].iloc[-1]
    latest_follower_count = [df['followers'].iloc[-1],
                             df['followers_increase'].iloc[-1]]

    latest_sub_count = [df['email_subs'].iloc[-1], 
                        df['email_subs_increase'].iloc[-1]]

    latest_refs_count = [df['referred_members'].iloc[-1], 
                         df['referred_members_increase'].iloc[-1]]

    return latest_month, latest_follower_count, latest_sub_count, latest_refs_count

最后,我们更新了对这个函数创建的变量的调用,以便引用每个列表中的正确值:

col1, col2, col3 = st.columns(3)
col1.metric('Followers', followers[0], delta=round(followers[1]))
col2.metric('Email Subscribers', subs[0], delta=round(subs[1]))
col3.metric('Referred Members', refs[0], delta=round(refs[1]))

当我们回到 Streamlit 应用时,我们现在将拥有每个统计数据的增量。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

Medium Stats 仪表盘,正确显示了过去一个月和当前月之间的差异。图片由作者提供。

就这样——一个基本的 Streamlit 仪表盘,用于可视化我们 Medium 账户的关键统计数据。

Medium Stats Streamlit 应用的完整代码

如果你想复制这个仪表盘并进行一些操作,这里是应用的完整代码。

import streamlit as st
import pandas as pd
import plotly_express as px

st.set_page_config(layout='wide')

st.header('Medium Stats Dashboard')

#Main Functions
def update_stats_csv(date, followers, email_subs, ref_members):
    with open('medium_stats.csv', 'a+') as f:
        f.write(f'\n{date},{followers},{email_subs},{ref_members}')

def calculate_differences(df):
    for x in ['followers', 'email_subs', 'referred_members']:
        df[f'{x}_increase'] = df[x].diff()
    return df

def get_latest_monthly_metrics(df):
    # Assumes the last row is the latest entry

    df['date'] = pd.to_datetime(df['date'])
    df['month'] = df['date'].dt.month_name()
    latest_month = df['month'].iloc[-1]
    latest_follower_count = [df['followers'].iloc[-1],
                             df['followers_increase'].iloc[-1]]

    latest_sub_count = [df['email_subs'].iloc[-1], 
                        df['email_subs_increase'].iloc[-1]]

    latest_refs_count = [df['referred_members'].iloc[-1], 
                         df['referred_members_increase'].iloc[-1]]

    return latest_month, latest_follower_count, latest_sub_count, latest_refs_count

#Sidebar
with st.sidebar.form('Enter the latest stats from your Medium dashboard'):

    date = st.date_input('Date')

    follower_num = st.number_input('Followers', step=1)
    email_subs = st.number_input('Email Subscribers', step=1)
    ref_members = st.number_input('Referred Members', step=1)

    submit_stats = st.form_submit_button('Write Stats to CSV')

    if submit_stats:
        update_stats_csv(date, follower_num, email_subs, ref_members)
        st.info(f'Stats for {date} have been added to the csv file')

# Main page
df = pd.read_csv('medium_stats.csv')

with st.expander('View Raw Data'):
    st.dataframe(df)

df = calculate_differences(df)

month, followers, subs, refs = get_latest_monthly_metrics(df)

st.write(f'### Medium Stats for {month}')

col1, col2, col3 = st.columns(3)
col1.metric('Followers', followers[0], delta=round(followers[1]))
col2.metric('Email Subscribers', subs[0], delta=round(subs[1]))
col3.metric('Referred Members', refs[0], delta=round(refs[1]))

fig_followers = px.line(df, x=df.date, y='followers', title='Followers')
st.plotly_chart(fig_followers, use_container_width=True)

plot_col1, plot_col2 = st.columns(2)

fig_subscribers = px.bar(df, x=df.date, y='email_subs', title='Email Subscribers')
plot_col1.plotly_chart(fig_subscribers, use_container_width=True)

fig_subscribers = px.bar(df, x=df.date, y='referred_members', title='Referred Members')
plot_col2.plotly_chart(fig_subscribers, use_container_width=True)

总结

在本文中,我们看到如何利用我们的 Medium 统计数据来创建直接在 Streamlit 内部的交互式图表。这是一个基本的应用,但通过样式和更多功能(如包括 Medium 会员收入),可以变得更好。

通过 Medium API 自动更新数据是非常好的。然而,我不相信所有的统计数据都可以通过 API 访问。

感谢阅读。在你离开之前,你应该订阅我的内容,将我的文章发送到你的收件箱。 你可以在这里做到这一点!或者,你可以 注册我的新闻通讯 以免费获取额外的内容。

其次,你可以通过注册会员来获得完整的 Medium 体验,支持我和成千上万的其他作家。这仅需每月$5,你将可以访问所有精彩的 Medium 文章,还可以通过写作赚取收入。如果你通过 我的链接注册你将通过你的费用的一部分直接支持我,而且不会额外增加费用。如果你这样做了,非常感谢你的支持!

构建一个问答 PDF 聊天机器人

原文:towardsdatascience.com/building-a-question-answering-pdf-chatbot-3e3b6372528c?source=collection_archive---------0-----------------------#2023-04-09

LangChain + OpenAI + Panel + HuggingFace

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 Sophia Yang, Ph.D.

·

关注 发表在 Towards Data Science ·6 分钟阅读·2023 年 4 月 9 日

让我们来构建一个用于回答外部 PDF 文件问题的聊天机器人。通过 5 个简单的步骤,你应该能够构建一个像这样的问答 PDF 聊天机器人:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

😊 想试用这个应用吗?我已经在 Hugging Face 上托管了这个应用:sophiamyang-panel-pdf-qa.hf.space/LangChain_QA_Panel_App

💻 你可以在这里找到我所有的代码:

huggingface.co/spaces/sophiamyang/Panel_PDF_QA/tree/main

📒 你可以在这里找到我的笔记本文件:

huggingface.co/spaces/sophiamyang/Panel_PDF_QA/blob/main/LangChain_QA_Panel_App.ipynb

好的,开始构建这个问答 PDF 聊天机器人吧!

第一步:设置

  • 安装所需的包
!pip install langchain openai chromadb pypdf panel notebook
  • 导入所需的包
import os 
from langchain.chains import RetrievalQA
from langchain.llms import OpenAI
from langchain.document_loaders import TextLoader
from langchain.document_loaders import PyPDFLoader
from langchain.indexes import VectorstoreIndexCreator
from langchain.text_splitter import CharacterTextSplitter
from langchain.embeddings import OpenAIEmbeddings
from langchain.vectorstores import Chroma
import panel as pn
  • 定义 OpenAI API 密钥:在 OpenAI 创建一个账户并生成 API 密钥 platform.openai.com/account。请注意,OpenAI API 不是免费的。你需要在这里设置账单信息才能使用 OpenAI API。或者,你可以使用 HuggingFace Hub 或其他地方的模型。查看我之前的 博客文章 和 视频 了解如何使用其他模型。
import os 
os.environ["OPENAI_API_KEY"] = "COPY AND PASTE YOUR API KEY HERE"

第一步:定义 Panel 小部件

为了制作这个应用程序,我们将需要以下小部件:

  • file_input 用于上传文件

  • openaikey 输入你的 OpenAI 密钥

  • widgets 结合了所有其他小部件:prompt 用于输入问题文本,run_button 用于点击并运行模型,select_k 用于选择相关块的数量,以及 select_chain_type 用于选择使用的链类型。

file_input = pn.widgets.FileInput(width=300)

openaikey = pn.widgets.PasswordInput(
    value="", placeholder="Enter your OpenAI API Key here...", width=300
)
prompt = pn.widgets.TextEditor(
    value="", placeholder="Enter your questions here...", height=160, toolbar=False
)
run_button = pn.widgets.Button(name="Run!")

select_k = pn.widgets.IntSlider(
    name="Number of relevant chunks", start=1, end=5, step=1, value=2
)
select_chain_type = pn.widgets.RadioButtonGroup(
    name='Chain type', 
    options=['stuff', 'map_reduce', "refine", "map_rerank"]
)

widgets = pn.Row(
    pn.Column(prompt, run_button, margin=5),
    pn.Card(
        "Chain type:",
        pn.Column(select_chain_type, select_k),
        title="Advanced settings", margin=10
    ), width=600
)

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

第二步:定义问答函数

我们将使用 LangChain 和 OpenAI 进行问答。在 LangChain 中至少有四种方式进行问答。查看我之前的博客文章和视频,了解 LangChain 中的四种问答方式。

在这个函数中,我们使用了 RetrievalQA 来检索相关的文本块,然后仅将这些文本块传递给语言模型。我们定义了四个参数:—

  • file 是你感兴趣的输入 PDF 文件

  • query 是你的问题

  • chain_type 让你将链类型定义为四种选项之一:“stuff”、“map reduce”、“refine”、“map_rerank”。查看我之前的博客文章以了解每种链类型。

  • k 定义了相关文本块的数量

def qa(file, query, chain_type, k):
    # load document
    loader = PyPDFLoader(file)
    documents = loader.load()
    # split the documents into chunks
    text_splitter = CharacterTextSplitter(chunk_size=1000, chunk_overlap=0)
    texts = text_splitter.split_documents(documents)
    # select which embeddings we want to use
    embeddings = OpenAIEmbeddings()
    # create the vectorestore to use as the index
    db = Chroma.from_documents(texts, embeddings)
    # expose this index in a retriever interface
    retriever = db.as_retriever(search_type="similarity", search_kwargs={"k": k})
    # create a chain to answer questions 
    qa = RetrievalQA.from_chain_type(
        llm=OpenAI(), chain_type=chain_type, retriever=retriever, return_source_documents=True)
    result = qa({"query": query})
    print(result['result'])
    return result

下面是一个使用此函数的示例:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

第三步:将输出显示为 Panel 对象

上述函数返回语言模型的查询、结果和源文档。为了在 Panel 应用中显示结果和源文档,我们需要将它们转换为 Panel 对象。在 qa_result 函数中,我将查询(prompt_text)、结果(result[“result”])和源文档(result["source_documents"])追加到一个名为 convos 的列表中,然后将其转换为 Panel 对象 pn.Column(*convos)

convos = []  # store all panel objects in a list

def qa_result(_):
    os.environ["OPENAI_API_KEY"] = openaikey.value

    # save pdf file to a temp file 
    if file_input.value is not None:
        file_input.save("/.cache/temp.pdf")

        prompt_text = prompt.value
        if prompt_text:
            result = qa(file="/.cache/temp.pdf", query=prompt_text, chain_type=select_chain_type.value, k=select_k.value)
            convos.extend([
                pn.Row(
                    pn.panel("\U0001F60A", width=10),
                    prompt_text,
                    width=600
                ),
                pn.Row(
                    pn.panel("\U0001F916", width=10),
                    pn.Column(
                        result["result"],
                        "Relevant source text:",
                        pn.pane.Markdown('\n--------------------------------------------------------------------\n'.join(doc.page_content for doc in result["source_documents"]))
                    )
                )
            ])
            #return convos
    return pn.Column(*convos, margin=15, width=575, min_height=400)

第四步:运行按钮执行函数

接下来,我想让 qa_result 函数具有交互性,也就是说,当我点击运行按钮时,我希望运行这个函数。这在 Panel 中非常简单。我们使用 pn.bindqa_result 函数绑定到 run_button 小部件。请注意,在 qa_result 函数中,我们传入了一个参数 _,实际上在函数中并未使用。这只是一个占位符,让 pn.bind 知道我们可以将一个小部件绑定到这个函数上。

qa_interactive = pn.panel(
    pn.bind(qa_result, run_button),
    loading_indicator=True,
)

然后我想将输出格式化到一个框中,因此我使用了 pn.WidgetBox

output = pn.WidgetBox('*Output will show up here:*', qa_interactive, width=630, scroll=True)

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

第 5 步:定义布局

现在我们可以使用 pn.Column 将所有小部件和输出组合成一列。你可以运行 panel serve LangChain_QA_Panel_App.ipynb 来服务这个应用程序。现在你应该有一个可以运行的应用程序!

# layout
pn.Column(
    pn.pane.Markdown("""
    ## \U0001F60A! Question Answering with your PDF file

    Step 1: Upload a PDF file \n
    Step 2: Enter your OpenAI API key. This costs $$. You will need to set up billing info at [OpenAI](https://platform.openai.com/account). \n
    Step 3: Type your question at the bottom and click "Run" \n

    """),
    pn.Row(file_input,openaikey),
    output,
    widgets

).servable()

最终步骤:托管到 Hugging Face Space

如你所见,在我的视频开始时,我在 Hugging Face 上托管了我的应用:sophiamyang-panel-pdf-qa.hf.space/LangChain_QA_Panel_App

查看我之前的 博客文章 和 视频,了解如何在 Hugging Face 上托管 Panel 应用。

这是我为这个应用程序所做的:

  • 我上传了我的 Jupyter Notebook 文件

  • 我创建了一个 Dockerfile。这个应用程序的特别之处在于,它为 Chroma 向量数据库创建了一个 .chroma 目录。因此,我们需要创建这个目录并赋予它权限:RUN mkdir .chromaRUN chomd 777 .chroma

  • 我在一个 [requirements.txt](https://huggingface.co/spaces/sophiamyang/Panel_PDF_QA/blob/main/requirements.txt) 文件 中包含了所有所需的包

结论

希望在这篇文章结束时,你能了解如何使用 LangChain、OpenAI 和 Panel 构建一个问答 PDF 聊天机器人,并知道如何将应用程序部署到 Hugging Face Space。

致谢:

感谢 Jim Bednar 和 Philipp Rudiger 的指导和反馈!

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图片由 Volodymyr Hryshchenko 提供,来源于 Unsplash

. . .

Sophia Yang 于 2023 年 4 月 8 日发布

Sophia Yang 是一位高级数据科学家。通过 LinkedInTwitterYouTube 联系我,并加入 DS/ML 书友会 ❤️

使用机器学习构建推荐系统

原文:towardsdatascience.com/building-a-recommender-system-using-machine-learning-2eefba9a692e

Kaggle 蓝图

在 Python 中使用共访矩阵和 GBDT 排名模型的“候选项重排序”方法

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 Leonie Monigatti

·发表于Towards Data Science ·阅读时长 6 分钟·2023 年 3 月 1 日

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

“一个绝佳的选择,女士!我们的汉堡非常适合配菜和饮料。我可以推荐一些选项吗?”(作者提供的图像)

欢迎来到新文章系列的第一版,称为“Kaggle 蓝图”,在这里我们将分析Kaggle比赛的顶级解决方案,以便提取可以应用于我们自己数据科学项目的经验教训。

本期内容将回顾“OTTO — 多目标推荐系统”比赛中的技术和方法,该比赛于 2023 年 1 月底结束。

问题陈述:多目标推荐系统

“OTTO — 多目标推荐系统”比赛的目标是构建基于大量隐式用户数据的多目标推荐系统(RecSys)

具体来说,在电子商务的使用案例中,竞争者们处理了以下细节:

  • 多目标:点击、购物车添加和订单

  • 大型数据集:超过 2 亿个事件,涉及约 180 万项

  • 隐式用户数据:用户会话中的先前事件

## OTTO — 多目标推荐系统

基于真实世界电子商务会话构建推荐系统

www.kaggle.com

如何处理大量项目的推荐系统

这次比赛的主要挑战之一是选择项目的数量众多。将所有可用的信息输入复杂模型需要大量计算资源。

因此,大多数竞争者遵循的通用基线是两阶段候选生成/重新排序技术[3]:

  1. 阶段:候选生成——这一步将每个用户的潜在推荐(候选项)数量从数百万减少到大约 50 到 200 个[2]。为了处理数据量,通常使用简单模型。

  2. 阶段:重新排序——你可以在这一步使用更复杂的模型,例如机器学习(ML)模型。一旦你对减少后的候选项进行排名,你可以选择排名最高的项目作为推荐。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

两阶段推荐候选生成/重新排序技术(图像来源于作者,灵感来自[3])

第一阶段:使用共访矩阵生成候选项

两阶段方法的第一步是将潜在推荐(候选项)的数量从数百万减少到大约 50 到 200 个[2]。为了处理大量项目,第一阶段的模型应保持简单[5]。

你可以选择和结合不同的策略来减少项目数量[3]:

  • 通过用户历史

  • 通过流行度——这种策略也可以作为一个强有力的基线[5]

  • 通过基于共访矩阵的共现

生成候选项的最直接方法是利用用户历史:如果用户查看过某个项目,他们很可能也会购买它。

然而,如果用户查看的项目数(例如五个项目)少于我们希望为每个用户生成的候选项数量(例如 50 到 200),我们可以通过项目流行度或共现来填充候选项列表[7]。由于基于流行度的选择较为直接,我们将在本节中重点关注基于共现的候选项生成。

通过共现生成的候选项可以通过共访矩阵进行处理:如果user_1购买了item_a,并且在不久后购买了item_b,我们会存储这些信息[6, 7]。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

推荐系统的用户购买行为的最小示例(图像来源于作者)

  1. 对于每个项目,计算在指定时间框架内每个其他项目的出现次数。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

共访矩阵的最小示例(图像来源于作者)

2. 对于每个项目,找到在该项目之后最常访问的 50 到 200 个项目。

如上图所示,共访矩阵不一定是对称的。例如,购买了汉堡的人也很可能会购买饮料——但相反的情况可能不成立。

你还可以根据接近度为共访矩阵分配权重。例如,同一会话中一起购买的项目可能比用户在不同购物会话中购买的项目有更高的权重。

共访矩阵类似于通过计数进行的矩阵分解 [6]。矩阵分解是推荐系统的常用技术。具体来说,它是一种协同过滤方法,用于发现项目和用户之间的关系。

## 推荐系统 — 矩阵分解

矩阵分解推荐系统的步骤

towardsdatascience.com

阶段 2:使用 GBDT 模型重新排序

第二步是重新排序。虽然你可以通过手工规则 [1] 达到良好的效果,但理论上使用 ML 模型应该更有效 [5]。

你可以使用不同的梯度提升决策树(GBDT)排名器,如 XGBRankerLGBMRanker [2, 3, 4]。

训练数据和特征工程的准备

GBDT 排名模型的训练数据应包含以下列类别 [2]:

  • 来自候选生成的用户和项目对 — 数据框的基础将是第一阶段生成的候选列表。对于每个用户,你应该得到 N_CANDIDATES 个项目,因此起点应该是形状为 (N_USERS * N_CANDIDATES, 2) 的数据框。

  • 用户特征 — 计数、聚合特征、比例特征等。

  • 项目特征 — 计数、聚合特征、比例特征等。

  • 用户-项目特征(可选)— 你可以创建用户-项目互动特征,如‘点击的项目’

  • 标签 — 对于每个用户-项目对,合并标签(例如,‘购买’或‘未购买’)。

生成的训练数据框应如下所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

用于训练推荐系统 GDBT 排名模型的训练数据结构(作者提供的图片)

GBDT 排名模型

此步骤旨在训练 GBDT 排名模型以选择 top_N 推荐。

GBDT 排名器将接受三个输入:

  • X_trainX_val:包含 FEATURES 的训练和验证数据框

  • y_trainy_val:包含 LABELS 的训练和验证数据框

  • group :注意 FEATURES 不包含 useritem 列 [2]。因此,模型需要知道在何种组内对项目进行排名:group = [N_CANDIDATES] * (len(train_df) // N_CANDIDATES)

以下是使用 XGBRanker [2] 的示例代码。

import xgboost as xgb

dtrain = xgb.DMatrix(X_train,
                     y_train, 
                     group = group) 

# Define model
xgb_params = {'objective' : 'rank:pairwise'} 

# Train
model = xgb.train(xgb_params, 
                  dtrain = dtrain,
                  num_boost_round = 1000)

以下是带有LGBMRanker的示例代码[4]:

from lightgbm.sklearn import LGBMRanker

# Define model
ranker = LGBMRanker(
    objective="lambdarank",
    metric="ndcg",
    n_estimators=1000)

# Train
model = ranker.fit(X_train, 
                   y_train,
                   group = group)

GBDT 排名模型将对指定组内的项目进行排序。要检索top_N推荐项,您只需按用户对输出进行分组,并按项目的排名排序即可。

总结

从回顾 Kagglers 在“OTTO——多目标推荐系统”比赛过程中创建的学习资源中,还可以学到更多的课程。对于这种类型的问题陈述,还有许多不同的解决方案。

在本文中,我们集中讨论了在许多竞争者中流行的一般方法:通过协同访问矩阵进行候选生成,以减少潜在推荐项目的数量,然后进行 GBDT 重排序。

享受这个故事了吗?

免费订阅 以在我发布新故事时获得通知。

[## 当 Leonie Monigatti 发布新内容时,获取电子邮件通知。

当 Leonie Monigatti 发布新内容时,您将收到电子邮件通知。通过注册,您将创建一个 Medium 账户(如果您还没有的话)……

medium.com](https://medium.com/@iamleonie/subscribe?source=post_page-----2eefba9a692e--------------------------------)

LinkedInTwitter Kaggle上找到我!

参考文献

[1] Chris Deotte (2022). “候选重排序模型——[LB 0.575]”在 Kaggle 笔记本中。(访问日期:2023 年 2 月 26 日)

[2] Chris Deotte (2022). “如何构建 GBT 排序模型”在 Kaggle 讨论中。(访问日期:2023 年 2 月 21 日)

[3] Ravi Shah (2022). “大型数据集的推荐系统”在 Kaggle 讨论中。(访问日期:2023 年 2 月 21 日)

[4] Radek Osmulski (2022). “[polars] 概念验证:LGBM Ranker”在 Kaggle 笔记本中。(访问日期:2023 年 2 月 26 日)

[5] Radek Osmulski (2022). “在 Kaggle 上的 OTTO 比赛介绍(RecSys)”在 YouTube 上。(访问日期:2023 年 2 月 21 日)

[6] Radek Osmulski (2022). “协同访问矩阵究竟是什么?”在 Kaggle 讨论中。(访问日期:2023 年 2 月 21 日)

[7] Vladimir Slaykovskiy (2022). “协同访问矩阵”在 Kaggle 笔记本中。(访问日期:2023 年 2 月 21 日)

使用开源工具和 Databricks 构建单一客户视图

原文:towardsdatascience.com/building-a-single-customer-view-using-open-source-tools-and-databricks-ecc7d020ef4f?source=collection_archive---------1-----------------------#2023-11-06

一个可扩展的数据质量和记录链接工作流,使客户数据科学成为可能

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 Robert Constable

·

关注 发表在 Towards Data Science ·11 分钟阅读·2023 年 11 月 6 日

介绍

单一客户视图是对客户与业务所有互动的全面数据表示。它将来自多个来源的数据,如购买记录、网站互动、社交媒体、电子邮件咨询、反馈和其他数据源,整合为每个独特客户的单一记录[1]。一旦实现单一客户视图,就能为精准营销、提高客户保留率和最大化客户生命周期价值提供许多机会[2]。然而,实现并维护单一客户视图可能会面临挑战,包括数据质量限制、多种第三方主数据管理(MDM)工具的许可成本,以及处理个人数据时的客户隐私和合规性考虑,这些都使得构建单一客户视图变得复杂。本文提供了一种构建单一客户视图的数据质量和记录链接管道的方法,考虑了这些挑战,并提供了使用基于 Spark 的云计算平台 Databricks 的低成本开源方法。

解决方案概述

下图总结了一种使用 Databricks 生成单一客户视图的开源方法。在一系列连接在工作流中的 Databricks 笔记本中,我们可以借助 Great Expectations 库实现数据分析和验证检查,解决任何数据质量问题,并确保在清洗和合规笔记本中遵守数据保护和保留政策,使用 Splink 库进行客户数据表之间或内部的概率记录链接,最终应用黄金记录逻辑并连接所有客户记录,以去重并链接不同的客户数据集,从而生成单一客户视图。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

Databricks 中开源数据质量和记录链接工作流的概述。图像作者提供

所有转换都在使用 Pyspark/Spark SQL 编写的查询中实现,或利用任何开源组件的 Spark 后端,从而利用 Databricks 的并行计算能力来扩展各阶段所需的处理,以适应可能的大规模客户数据集。通过使用 Lakehouse 架构来存储所有中间表和最终输出,我们可以从对所有数据操作的审计日志中受益,如果需要遵守合规性要求,可以永久删除客户数据,同时保留用于分析目的的匿名信息,并享受 Delta 表格式的性能和可靠性[3]。数据从源系统(例如,Dynamics 365 或 Salesforce 等客户关系管理(CRM)工具)进入流程,并以单一客户视图的形式输出,准备用于分析或操作用例,例如客户画像仪表板、推荐引擎等机器学习应用,以及通过电子邮件、短信或邮寄的自动化营销和追加销售。

使用 Great Expectations 进行数据分析和验证

Great Expectations 是一个开源的 Python 库 [4],它允许你以编程方式为数据管道创建自动化测试,确保数据的有效性、完整性以及一致的模式。用户可以为数据中的字段定义期望字典,然后将数据与这些期望进行测试。

# contactid
custom_expectation_suite.add_expectation(
ExpectationConfiguration(
expectation_type = "expect_column_values_to_not_be_null", 
kwargs = {'column':'contactid'}, 
meta = {'reason':'contactid is the unique ID for D365 contacts, 
this should not be null'})
)

使用 Great Expectations 定义期望的示例

该库提供了多种现成的期望,如测试空值、唯一值和数值范围;如果标准集没有提供所需的期望,用户还可以创建自定义期望。Great Expectations 支持各种数据源,包括数据库、CSV 文件,以及 Pandas 或 Pyspark 数据框,并在使用 Databricks 时可以利用 Spark 后端。运行验证测试后,用户将获得一个基于网页的数据验证报告,报告中突出显示了哪些字段通过了期望套件,哪些字段未通过,并说明了原因,如下例所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

使用 Great Expectations 生成的数据验证报告 docs.greatexpectations.io/docs/

Great Expectations 还提供数据分析功能,使用户能够深入了解其数据集,例如总结统计信息(均值、中位数和标准差)、完整性和唯一性,并提供一套可视化工具,帮助用户探索数据并识别潜在问题。这些洞察可能会突出需要在期望套件中添加额外期望或向数据管理员发送警报的需求,如果客户数据的后续迭代超出了预期值。

在构建单一客户视图的背景下,Great Expectations 可以帮助你了解客户数据集的组成和质量,并在数据传递到管道的后续步骤或交给下游应用程序之前验证输出。Great Expectations 可以用来在第一次处理客户数据集时初步发现数据质量问题,并且在生产环境中可以与 CI/CD 工具集成,以便将数据集的单元测试作为部署管道的一部分。

清洗和合规性

清洗和合规阶段涉及解决分析和验证中突出的数据质量问题,并确保存储的客户数据符合数据保留的任何限制(例如 GDPR [5])。在 Databricks 中,可以使用 Pyspark/Spark SQL 查询,通过选择、过滤或映射字段或值来提供所需的数据清洗操作。

客户数据集包含个人身份信息(姓名、电子邮件地址、电话号码和邮政地址),这些信息对于从多个来源链接客户数据至关重要。每个客户数据集还将包含与每个客户相关的属性,例如交易日志、偏好设置、指示会员资格/状态的标志和指示与他们沟通时间的时间戳。为了准备单一客户视图的数据,这些数据集可能需要汇总,以便将任何时间序列或事件日志数据集总结成每个实体的单行表,这些表随后可以在概率匹配阶段进行连接和/或去重/链接。

数据保留策略也可以在此阶段程序化实施,并且可以处理特定用户删除请求的代码。Delta Lake 提供了优化,使得数据湖中的点删除更加高效,使用数据跳过 [6]。如果个人身份信息对于客户数据的后续使用不是必要的,它可以在此阶段被永久匿名化,或者被伪匿名化/拆分为具有不同权限级别的敏感和非敏感表,从而限制对原始客户数据的访问 [3]。

%sql
DELETE FROM data WHERE email = 'customer@exampledomain.com';
VACUUM data;

Delta 表中客户数据的点删除示例

使用 Splink 的概率记录匹配

客户数据集通常包含每个唯一客户的重复记录;客户可能手动输入他们的详细信息,导致拼写错误,随着时间的推移,客户可能更换地址或更改账户信息,造成多个记录中只有一个是有效且最新的,并且当客户在多个数据集中出现时,这些表之间显然会有多个记录需要进行链接。这个去重和记录链接的问题可以通过使用 Splink [7]来解决,Splink 是一个开源的概率记录匹配算法,基于 Spark 实现,由英国司法部团队创建,用于协助司法应用中的记录匹配。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

需要去重和链接的客户数据集示例 github.com/moj-analytical-services/splink

Splink 使用贝叶斯算法将多个字段中的模糊匹配分数组合成总体匹配概率,使用户能够使用这些匹配概率阈值标记客户记录在表格之间的链接或表格内给定个体的重复记录。用户可以定义一套需要计算的模糊匹配分数,例如 Jaccard 相似性和 Levenshtein 距离,应用于各种个人数据字段,如姓名、电子邮件、电话号码和地址,以及阻断规则,这些规则通过将模糊匹配比较限制在其他字段上有精确匹配的记录,从而降低匹配过程的计算复杂性。使用期望最大化训练概率记录链接模型,并用来生成预测阻断规则中每对比较的匹配概率。此预测阶段的输出表示一个图数据结构,其中所有记录的配对比较作为节点,用边连接,边表示它们的配对匹配概率,这个图可以通过连接组件算法被解析成匹配概率大于用户定义阈值的高度连接相似记录的簇。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

典型 Splink 去重工作流概述。 github.com/moj-analytical-services/splink 经作者许可编辑。

尽管 Splink 需要用户做出大量决策以成功进行记录匹配,尤其是在选择接受或拒绝跨匹配概率范围的匹配的置信水平时,但作为一个开源项目,Splink 不需要商业记录匹配工具所需的许可费用,只要选择合适的匹配规则和阻断规则,并适当设置匹配概率阈值,它就能很好地工作。为了评估 Splink 提供的链接质量,数据管理员理想情况下应该手动验证一组随机样本的匹配质量,以建立对输出结果的信心,并根据数据最终应用的关键性来权衡假阳性和假阴性率。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

通过检查不同匹配概率下的随机样本对的匹配准确性。 github.com/moj-analytical-services/splink 经作者许可编辑。

黄金记录逻辑

一旦识别出重复记录或表之间的关联记录,构建单一客户视图的最后阶段是实施黄金记录逻辑,将每个唯一客户的所有记录统一为一行,并定义一组字段以包含所有相关的客户数据用于后续使用[8]。决定哪些字段和哪些客户信息副本作为黄金记录继续使用,将取决于数据的使用方式、记录实践的知识和相关性标准。在下面的示例中,在 Splink 中去重的客户数据集上应用了自定义的黄金记录逻辑(在此处,唯一客户已分配了唯一的“cluster_id”),选择每个客户的最新和最完整的重复记录。其他应用可能会看到多个记录和字段,来自多个表,合并成一个复合记录。此外,可能会在相同的数据上应用多个黄金记录定义,并用于不同的目的(例如,与非会员相比,会员可能会应用不同的黄金记录逻辑)。通常,给定的营销用例会需要特定的客户数据子集,并且可能从同一数据集中派生出多个营销用例;对于这些营销用例中的每一个,可以在此阶段实施不同的标志,以便在后续选择时方便使用。

# count nulls
df_nulls_counted = df.withColumn('numNulls', sum(df[col].isNull().cast('int') for col in df.columns)*-1)

# flag most complete
df_most_complete = df_nulls_counted.withColumn("row_number",f.row_number()\
.over(Window.partitionBy(df_nulls_counted.cluster_id)\
.orderBy(df_nulls_counted.numNulls.desc()))).cache()\
.withColumn('most_complete', f.when(f.col("row_number")==1, 1).otherwise(0)).drop("row_number")

# flag most recent
df_most_complete_most_recent = df_most_complete.withColumn("row_number",f.row_number()\
.over(Window.partitionBy(df_most_complete.cluster_id)\
.orderBy(df_most_complete.createdon_timestamp.desc()))).cache()\
.withColumn('most_recent', f.when(f.col("row_number")==1, 1).otherwise(0)).drop("row_number")

# order by number of nulls
df_golden = df_most_complete_most_recent.withColumn("row_number",f.row_number()\
.over(Window.partitionBy(df_most_complete_most_recent.cluster_id)\
.orderBy(*[f.desc(c) for c in ["numNulls","createdon_timestamp"]]))).cache()\
.withColumn('golden_record', f.when(f.col("row_number")==1, 1).otherwise(0)).drop("row_number")

# add splink duplicate flag
df_golden = df_golden.select('*', f.count('cluster_id')\
.over(Window.partitionBy('cluster_id')).alias('dupeCount'))\
.withColumn('splink_duplicate', f.when(f.col('dupeCount') > 1, 1).otherwise(0))

使用 Pyspark 窗口函数和二进制标志实现的示例黄金记录逻辑

集成与应用案例

如下所示的 Azure 架构可以用于单一客户视图的云部署,作为客户分析和数据科学用例的平台 [9]。使用像 Azure 这样的云平台,可以以具有成本效益的方式进行扩展,同时简化与存储和使用客户数据相关的数据保护和合规方面。Azure 还提供了一套 ETL 和数据科学组件来实施解决方案。各种客户数据源,如 CRM 和销售点系统,可以通过 Azure 中的编排工具,如 Synapse Analytics 和 Data Factory 进行批量加载,Event Hubs 和 Delta Live Tables 用于流数据源,将数据存储在数据湖存储账户中。使用 Databricks lakehouse 架构,使得将多种客户数据类型合并到一个共同的存储账户中变得更加容易,并根据奖牌模式结构化后续转换,包括一个原始铜区、一个具有定义模式的银色单一客户视图区和一个用于任何分析视图或数据科学输出的金色区域,这些区域可以进行维度建模,以便于下游用例,如 CRM 系统中的数据操作用于营销活动、客户分析仪表板帮助你定量理解客户基础,以及基于客户数据构建的其他应用程序,如流失模型或推荐引擎。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

Azure 上专注于单一客户视图的分析和数据科学平台的云架构。 https://learn.microsoft.com/en-us/azure/architecture/solution-ideas/articles/azure-databricks-modern-analytics-architecture 经作者许可编辑。

通过在云中存储和处理客户数据,你将部分客户数据安全的责任委托给云服务提供商(如微软),并可以在可能的情况下使用 Azure 环境的私有网络和更高的加密标准,以确保客户数据的安全。通过配置访问控制,可以限制对客户数据的访问,因此,虽然企业内部的广泛受众可以查看匿名化或聚合的客户数据,但原始客户数据的访问可以限制在需要用于营销目的或解决方案维护的业务人员中,从而最大限度地减少泄漏风险。

一旦整理好,单一客户视图就会开启许多数据科学应用的门路,比如按人口统计进行客户画像分析、客户生命周期价值分析或近期-频率-货币价值分析、市场篮子分析(购买模式和共同购买商品组的倾向分析),以及用于流失预测的 ML 建模,从而进行及时的电子邮件干预,如折扣和优惠,以提高客户保留率和推荐引擎,匹配客户和他们在客户生命周期中的特定时间点最有可能购买的产品。单一客户视图的其他分析应用包括汇总客户数据的仪表板,以理解购买趋势和季节性,以及对客户反馈的文本分析,以了解业务可以改进服务的领域;从 CRM 系统的操作角度来看,单一客户视图也是非常有用的,因为它将提高营销活动和其他干预措施的效率,特别是在联系客户的成本较高的情况下,如邮寄营销或冷拨电话/SMS 消息,通过减少对重复客户或过时地址/电话号码的联系。

结论

  • 使用开源工具、Databricks 和其他 Azure 组件,可以建立一种具有成本效益且可扩展的单一客户视图,并以安全且合规的方式将其部署到云端。

  • 提议的解决方案是高代码的,需要领域专业知识,并且涉及数据管理员在清洗和处理不同客户数据集时的决策。

  • 然而,这种解决方案相较于商业产品也有许多优势,如较低的许可费用和运行成本、解决方案的可定制性,以及与其他云组件的便捷集成,以部署分析和数据科学用例。

  • 单一客户视图开启了多种具有影响力的数据科学和分析用例的大门,这些用例可以帮助企业更有效地进行市场营销,理解客户并提供更好的服务。

感谢阅读,如果你有兴趣讨论或进一步阅读,请联系我或查看下面的一些参考资料。

www.linkedin.com/in/robert-constable-38b80b151/

参考文献

[1] www.informatica.com/content/dam/informatica-com/en/collateral/white-paper/improve-service-with-single-view-of-customer_white-paper_2446.pdf

[2] www.experian.co.uk/assets/about-us/white-papers/single-customer-view-whitepaper.pdf

[3] www.databricks.com/blog/2022/03/23/implementing-the-gdpr-right-to-be-forgotten-in-delta-lake.html

[4] docs.greatexpectations.io/docs/

[5] gdpr-info.eu/

[6] docs.databricks.com/en/security/privacy/gdpr-delta.html

[7] github.com/moj-analytical-services/splink

[8] www.informatica.com/blogs/golden-record.html

[9] learn.microsoft.com/en-us/azure/architecture/solution-ideas/articles/azure-databricks-modern-analytics-architecture

使用 LangChain、Google Maps API 和 Gradio 构建智能旅行行程建议器(第一部分)

原文:towardsdatascience.com/building-a-smart-travel-itinerary-suggester-with-langchain-google-maps-api-and-gradio-part-1-4175ff480b74?source=collection_archive---------1-----------------------#2023-09-26

了解如何构建一个可能激发你下一次公路旅行灵感的应用程序

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 Robert Martin-Short

·

关注 发表在 Towards Data Science ·13 分钟阅读·2023 年 9 月 26 日

本文是一个三部分系列的第一部分,我们将使用 OpenAI 和 Google APIs 构建一个旅行行程建议器应用程序,并在 Gradio 生成的简单 UI 中展示它。在这一部分,我们首先讨论了这个项目的提示工程。只想查看代码?请点击 这里.

1. 动机

自 2022 年底 ChatGPT 发布以来,对大型语言模型(LLMs)及其在面向消费者的产品(如聊天机器人和搜索引擎)中的应用兴趣激增。不足一年,我们已经可以访问大量来自Hugging Face的开源 LLM、Lamini等模型托管服务以及 OpenAI 和 PaLM 等付费 API。看到这一领域的发展如此迅速,新的工具和开发范式似乎每几周就会出现,既令人兴奋又有些不知所措。

在这里,我们将仅仅采样这些工具中的一小部分,构建一个有用的应用程序,帮助我们进行旅行规划。在计划度假时,得到曾经去过那里的人建议往往很不错,看到这些建议在地图上展示更好。在没有这种建议的情况下,我有时会浏览 Google 地图,在我想要访问的一般区域内随意选择一些看起来有趣的地方。也许这个过程很有趣,但它效率低且可能会遗漏一些东西。拥有一个能够根据几个高层次偏好提供大量建议的工具岂不是很好吗?

这正是我们尝试构建的系统:一个能够根据一些高层次的偏好提供旅行行程建议的系统,例如*“我有 3 天时间探索旧金山,并且喜欢艺术博物馆”*。Google 搜索的生成 AI 功能和 ChatGPT 已经可以为类似的查询提供创造性的结果,但我们希望更进一步,生成一个包含旅行时间和美观地图的实际行程,帮助用户定位。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

这就是我们将构建的系统:一个生成旅行建议的系统,配有基本地图,显示由 LLM 提供的路线和中途点。

目标更多是熟悉构建此类服务所需的工具,而不是实际部署应用程序,但在此过程中我们将了解一些关于提示工程、与 LangChain 协调的 LLM、使用 Google Maps API 提取方向以及使用 leafmapgradio 显示结果的知识。令人惊叹的是,这些工具能够如此快速地为此类系统构建一个 POC,但真正的挑战总是出现在评估和边缘情况管理上。我们将构建的工具远非完美,如果有人有兴趣进一步帮助我开发,那将是非常棒的。

2. 提示策略

该项目将使用 OpenAI 和 Google PaLM API。你可以通过在这里这里注册账户来获取 API 密钥。撰写时,Google API 的通用可用性有限,并且有等待名单,但获取访问权限通常只需几天时间。

使用 [dotenv](https://pypi.org/project/python-dotenv/) 是避免将 API 密钥复制粘贴到开发环境中的一种简单方法。在创建了一个包含以下行的 .env 文件之后

OPENAI_API_KEY = {your open ai key}
GOOGLE_PALM_API_KEY = {your google palm api key}

我们可以使用这个函数来加载变量,以便 LangChain 等下游使用。

from dotenv import load_dotenv
from pathlib import Path

def load_secets():
    load_dotenv()
    env_path = Path(".") / ".env"
    load_dotenv(dotenv_path=env_path)

    open_ai_key = os.getenv("OPENAI_API_KEY")
    google_palm_key = os.getenv("GOOGLE_PALM_API_KEY")

    return {
        "OPENAI_API_KEY": open_ai_key,
        "GOOGLE_PALM_API_KEY": google_palm_key,
    }

那么,我们应该如何设计旅行代理服务的提示呢?用户将可以输入他们想要的任何文本,因此我们首先要能够确定他们的查询是否有效。我们肯定要标记任何包含有害内容的查询,例如具有恶意意图的行程请求。

我们还想过滤掉与旅行无关的问题——毫无疑问,大语言模型可以回答这些问题,但这些问题超出了本项目的范围。最后,我们还希望识别出不合理的请求,例如 “我想飞到月球”“我想进行从纽约到东京的三天公路旅行”。对于这样的不合理请求,如果模型能够解释为何不合理并提出有帮助的修改建议,那将是很好的。

一旦请求被验证,我们可以继续提供建议的行程,理想情况下应该包含路线点的具体地址,以便可以将其发送到诸如 Google Maps 的映射或导航 API。

行程应该是人类可读的,包含足够的细节,使用户能够将其作为独立建议使用。像 ChatGPT 这样的庞大、经过指令调优的大语言模型似乎在提供这样的回应方面表现出色,但我们需要确保路线点地址以一致的方式提取。

因此,这里有三个不同的阶段:

  1. 验证查询

  2. 生成行程

  3. 以 Google Maps API 可以理解的格式提取路线点

可能设计一个能够一次完成所有三项工作的提示,但为了便于调试,我们将其拆分为三个大语言模型调用,每个部分一个。

幸运的是,LangChain 的 [PydanticOutputParser](https://python.langchain.com/docs/modules/model_io/output_parsers/pydantic) 确实可以提供帮助,通过提供一组预设的提示,鼓励大语言模型以符合输出模式的方式格式化其回应。

3. 验证提示

让我们看看验证提示,我们可以将其包装在一个模板类中,以便更容易包含和迭代不同的版本。

from langchain.prompts.chat import (
    ChatPromptTemplate,
    SystemMessagePromptTemplate,
    HumanMessagePromptTemplate,
)
from langchain.output_parsers import PydanticOutputParser
from pydantic import BaseModel, Field

class Validation(BaseModel):
    plan_is_valid: str = Field(
        description="This field is 'yes' if the plan is feasible, 'no' otherwise"
    )
    updated_request: str = Field(description="Your update to the plan")

class ValidationTemplate(object):
    def __init__(self):
        self.system_template = """
      You are a travel agent who helps users make exciting travel plans.

      The user's request will be denoted by four hashtags. Determine if the user's
      request is reasonable and achievable within the constraints they set.

      A valid request should contain the following:
      - A start and end location
      - A trip duration that is reasonable given the start and end location
      - Some other details, like the user's interests and/or preferred mode of transport

      Any request that contains potentially harmful activities is not valid, regardless of what
      other details are provided.

      If the request is not valid, set
      plan_is_valid = 0 and use your travel expertise to update the request to make it valid,
      keeping your revised request shorter than 100 words.

      If the request seems reasonable, then set plan_is_valid = 1 and
      don't revise the request.

      {format_instructions}
    """

        self.human_template = """
      ####{query}####
    """

        self.parser = PydanticOutputParser(pydantic_object=Validation)

        self.system_message_prompt = SystemMessagePromptTemplate.from_template(
            self.system_template,
            partial_variables={
                "format_instructions": self.parser.get_format_instructions()
            },
        )
        self.human_message_prompt = HumanMessagePromptTemplate.from_template(
            self.human_template, input_variables=["query"]
        )

        self.chat_prompt = ChatPromptTemplate.from_messages(
            [self.system_message_prompt, self.human_message_prompt]
        )

我们的 Validation 类包含查询的输出模式定义,这将是一个包含两个键 plan_is_validupdated_request 的 JSON 对象。在 ValidationTemplate 中,我们使用 LangChain 的有用模板类来构造提示,并创建一个带有 PydanticOutputParser 的解析器对象。这将 Pydantic 代码转换为可以与查询一起传递给 LLM 的一组指令。然后,我们可以在系统模板中引用这些格式指令。每次调用 API 时,我们希望 system_message_prompthuman_message_prompt 都发送给 LLM,这就是为什么我们将它们打包在 chat_prompt 中。

由于这实际上并不是一个聊天机器人应用程序(虽然可以将其制作成一个!),我们可以将系统和人工模板放在同一个字符串中,以获得相同的响应。

现在,我们可以创建一个使用 LangChain 调用 LLM API 的 Agent 类。这里我们使用 ChatOpenAI,但如果你更喜欢,也可以用 GooglePalm 替代。

请注意,我们这里还使用了 Langchain 的 LLMChainSequentialChain,尽管我们只是进行了一次 LLM 调用。这可能有些过度,但如果未来我们想添加另一个调用(例如,在验证链运行之前调用 OpenAI moderation API),这可能会有帮助。

import openai
import logging
import time
# for Palm
from langchain.llms import GooglePalm
# for OpenAI
from langchain.chat_models import ChatOpenAI
from langchain.chains import LLMChain, SequentialChain

logging.basicConfig(level=logging.INFO)

class Agent(object):
    def __init__(
        self,
        open_ai_api_key,
        model="gpt-3.5-turbo",
        temperature=0,
        debug=True,
    ):
        self.logger = logging.getLogger(__name__)
        self.logger.setLevel(logging.INFO)
        self._openai_key = open_ai_api_key

        self.chat_model = ChatOpenAI(model=model, temperature=temperature, openai_api_key=self._openai_key)
        self.validation_prompt = ValidationTemplate()
        self.validation_chain = self._set_up_validation_chain(debug)

    def _set_up_validation_chain(self, debug=True):

        # make validation agent chain
        validation_agent = LLMChain(
            llm=self.chat_model,
            prompt=self.validation_prompt.chat_prompt,
            output_parser=self.validation_prompt.parser,
            output_key="validation_output",
            verbose=debug,
        )

        # add to sequential chain 
        overall_chain = SequentialChain(
            chains=[validation_agent],
            input_variables=["query", "format_instructions"],
            output_variables=["validation_output"],
            verbose=debug,
        )

        return overall_chain

    def validate_travel(self, query):
        self.logger.info("Validating query")
        t1 = time.time()
        self.logger.info(
            "Calling validation (model is {}) on user input".format(
                self.chat_model.model_name
            )
        )
        validation_result = self.validation_chain(
            {
                "query": query,
                "format_instructions": self.validation_prompt.parser.get_format_instructions(),
            }
        )

        validation_test = validation_result["validation_output"].dict()
        t2 = time.time()
        self.logger.info("Time to validate request: {}".format(round(t2 - t1, 2)))

        return validation_test

要运行示例,我们可以尝试以下代码。设置 debug=True 将激活 LangChain 的调试模式,该模式会打印查询文本在通过各种 LangChain 类时的进展情况。

secrets = load_secets()
travel_agent = Agent(open_ai_api_key=secrets[OPENAI_API_KEY],debug=True)

query = """
        I want to do a 5 day roadtrip from Cape Town to Pretoria in South Africa.
        I want to visit remote locations with mountain views
        """

travel_agent.validate_travel(query)

这个查询似乎合理,因此我们得到如下结果

INFO:__main__:Validating query
INFO:__main__:Calling validation (model is gpt-3.5-turbo) on user input
INFO:__main__:Time to validate request: 1.08
{'plan_is_valid': 'yes', 'updated_request': ''}

现在我们通过将查询更改为不那么合理的内容来进行测试,例如

query = """
        I want to walk from Cape Town to Pretoria in South Africa.
        I want to visit remote locations with mountain views
        """

响应时间较长,因为 ChatGPT 正在尝试提供有关查询为何无效的解释,因此生成了更多的标记。

INFO:__main__:Validating query
INFO:__main__:Calling validation (model is gpt-3.5-turbo) on user input
INFO:__main__:Time to validate request: 4.12
{'plan_is_valid': 'no',
 'updated_request': 'Walking from Cape Town to Pretoria in South Africa is not ...' a

4. 行程提示

如果查询有效,它可以传递到下一个阶段,即行程提示。在这里,我们希望模型返回详细的建议旅行计划,形式应为包含途经地址和有关每个地点活动建议的项目符号列表。这实际上是项目的主要“生成”部分,有很多方法可以设计查询以获得良好的结果。我们的 ItineraryTemplate 看起来像这样

class ItineraryTemplate(object):
    def __init__(self):
        self.system_template = """
      You are a travel agent who helps users make exciting travel plans.

      The user's request will be denoted by four hashtags. Convert the
      user's request into a detailed itinerary describing the places
      they should visit and the things they should do.

      Try to include the specific address of each location.

      Remember to take the user's preferences and timeframe into account,
      and give them an itinerary that would be fun and doable given their constraints.

      Return the itinerary as a bulleted list with clear start and end locations.
      Be sure to mention the type of transit for the trip.
      If specific start and end locations are not given, choose ones that you think are suitable and give specific addresses.
      Your output must be the list and nothing else.
    """

        self.human_template = """
      ####{query}####
    """

        self.system_message_prompt = SystemMessagePromptTemplate.from_template(
            self.system_template,
        )
        self.human_message_prompt = HumanMessagePromptTemplate.from_template(
            self.human_template, input_variables=["query"]
        )

        self.chat_prompt = ChatPromptTemplate.from_messages(
            [self.system_message_prompt, self.human_message_prompt]
        )

请注意,这里不需要 Pydantic 解析器,因为我们希望输出是一个字符串,而不是 JSON 对象。

要使用这个,我们可以向 Agent 类添加一个新的 LLMChain,如下所示

 travel_agent = LLMChain(
            llm=self.chat_model,
            prompt=self.itinerary_prompt.chat_prompt,
            verbose=debug,
            output_key="agent_suggestion",
        )

我们在实例化 chat_model 时没有设置 max_tokens 参数,这允许模型决定其输出的长度。特别是对于 GPT4,这可能使响应时间相当长(有时超过 30 秒)。有趣的是,PaLM 的响应时间明显较短。

5. 航点提取提示

使用行程提示可能会给我们一个很好的航点列表,可能是这样的。

- Day 1:
  - Start in Berkeley, CA
  - Drive to Redwood National and State Parks, CA (1111 Second St, Crescent City, CA 95531)
  - Explore the beautiful redwood forests and enjoy nature
  - Drive to Eureka, CA (531 2nd St, Eureka, CA 95501)
  - Enjoy the local cuisine and explore the charming city
  - Overnight in Eureka, CA

- Day 2:
  - Start in Eureka, CA
  - Drive to Crater Lake National Park, OR (Crater Lake National Park, OR 97604)
  - Marvel at the stunning blue lake and hike the scenic trails
  - Drive to Bend, OR (Bend, OR 97701)
  - Indulge in the local food scene and explore the vibrant city
  - Overnight in Bend, OR

- Day 3:
  - Start in Bend, OR
  - Drive to Mount Rainier National Park, WA (55210 238th Ave E, Ashford, WA 98304)
  - Enjoy the breathtaking views of the mountain and hike the trails
  - Drive to Tacoma, WA (Tacoma, WA 98402)
  - Sample the delicious food options and explore the city's attractions
  - Overnight in Tacoma, WA

- Day 4:
  - Start in Tacoma, WA
  - Drive to Olympic National Park, WA (3002 Mount Angeles Rd, Port Angeles, WA 98362)
  - Explore the diverse ecosystems of the park and take in the natural beauty
  - Drive to Seattle, WA (Seattle, WA 98101)
  - Experience the vibrant food scene and visit popular attractions
  - Overnight in Seattle, WA

- Day 5:
  - Start in Seattle, WA
  - Explore more of the city's attractions and enjoy the local cuisine
  - End the trip in Seattle, WA

现在我们需要提取航点的地址,以便可以进行下一步操作,即将它们绘制在地图上,并调用 Google Maps 方向 API 以获取它们之间的路线。

为此,我们将进行另一次 LLM 调用,并再次使用PydanticOutputParser以确保我们的输出格式正确。为了理解这里的格式,简要考虑一下我们在项目的下一阶段(第二部分中介绍)要做的事情是有用的。我们将调用Google Maps Python API,其形式如下。

import googlemaps

gmaps = googlemaps.Client(key=google_maps_api_key)

directions_result = gmaps.directions(
            start,
            end,
            waypoints=waypoints,
            mode=transit_type,
            units="metric",
            optimize_waypoints=True,
            traffic_model="best_guess",
            departure_time=start_time,
)

其中,start 和 end 是地址字符串,waypoints 是要访问的中间地址列表。

我们请求的航点提取提示的模式因此如下所示。

class Trip(BaseModel):
    start: str = Field(description="start location of trip")
    end: str = Field(description="end location of trip")
    waypoints: List[str] = Field(description="list of waypoints")
    transit: str = Field(description="mode of transportation")

这将使我们能够将 LLM 调用的输出插入到方向调用中。

对于这个提示,我发现添加一个一次性示例真的有助于模型符合期望的输出。对较小的开源 LLM 进行微调以使用这些 ChatGPT/PaLM 的结果提取航点列表可能是一个有趣的衍生项目。

class MappingTemplate(object):
    def __init__(self):
        self.system_template = """
      You an agent who converts detailed travel plans into a simple list of locations.

      The itinerary will be denoted by four hashtags. Convert it into
      list of places that they should visit. Try to include the specific address of each location.

      Your output should always contain the start and end point of the trip, and may also include a list
      of waypoints. It should also include a mode of transit. The number of waypoints cannot exceed 20.
      If you can't infer the mode of transit, make a best guess given the trip location.

      For example:

      ####
      Itinerary for a 2-day driving trip within London:
      - Day 1:
        - Start at Buckingham Palace (The Mall, London SW1A 1AA)
        - Visit the Tower of London (Tower Hill, London EC3N 4AB)
        - Explore the British Museum (Great Russell St, Bloomsbury, London WC1B 3DG)
        - Enjoy shopping at Oxford Street (Oxford St, London W1C 1JN)
        - End the day at Covent Garden (Covent Garden, London WC2E 8RF)
      - Day 2:
        - Start at Westminster Abbey (20 Deans Yd, Westminster, London SW1P 3PA)
        - Visit the Churchill War Rooms (Clive Steps, King Charles St, London SW1A 2AQ)
        - Explore the Natural History Museum (Cromwell Rd, Kensington, London SW7 5BD)
        - End the trip at the Tower Bridge (Tower Bridge Rd, London SE1 2UP)
      #####

      Output:
      Start: Buckingham Palace, The Mall, London SW1A 1AA
      End: Tower Bridge, Tower Bridge Rd, London SE1 2UP
      Waypoints: ["Tower of London, Tower Hill, London EC3N 4AB", "British Museum, Great Russell St, Bloomsbury, London WC1B 3DG", "Oxford St, London W1C 1JN", "Covent Garden, London WC2E 8RF","Westminster, London SW1A 0AA", "St. James's Park, London", "Natural History Museum, Cromwell Rd, Kensington, London SW7 5BD"]
      Transit: driving

      Transit can be only one of the following options: "driving", "train", "bus" or "flight".

      {format_instructions}
    """

        self.human_template = """
      ####{agent_suggestion}####
    """

        self.parser = PydanticOutputParser(pydantic_object=Trip)

        self.system_message_prompt = SystemMessagePromptTemplate.from_template(
            self.system_template,
            partial_variables={
                "format_instructions": self.parser.get_format_instructions()
            },
        )
        self.human_message_prompt = HumanMessagePromptTemplate.from_template(
            self.human_template, input_variables=["agent_suggestion"]
        )

        self.chat_prompt = ChatPromptTemplate.from_messages(
            [self.system_message_prompt, self.human_message_prompt]
        )

现在,让我们向Agent类添加一个新方法,该方法可以使用 SequentialChain 顺序调用ItineraryTemplateMappingTemplate来调用 LLM。

def _set_up_agent_chain(self, debug=True):

    # set up LLMChain to get the itinerary as a string
    travel_agent = LLMChain(
            llm=self.chat_model,
            prompt=self.itinerary_prompt.chat_prompt,
            verbose=debug,
            output_key="agent_suggestion",
        )

    # set up LLMChain to extract the waypoints as a JSON object
    parser = LLMChain(
            llm=self.chat_model,
            prompt=self.mapping_prompt.chat_prompt,
            output_parser=self.mapping_prompt.parser,
            verbose=debug,
            output_key="mapping_list",
        )

    # overall chain allows us to call the travel_agent and parser in
    # sequence, with labelled outputs.
    overall_chain = SequentialChain(
            chains=[travel_agent, parser],
            input_variables=["query", "format_instructions"],
            output_variables=["agent_suggestion", "mapping_list"],
            verbose=debug,
        )

    return overall_chain

要进行这些调用,我们可以使用以下代码。

agent_chain = travel_agent._set_up_agent_chain()
mapping_prompt = MappingTemplate()

agent_result = agent_chain(
                {
                    "query": query,
                    "format_instructions": mapping_prompt.parser.get_format_instructions(),
                }
            )

trip_suggestion = agent_result["agent_suggestion"]
waypoints_dict = agent_result["mapping_list"].dict()

waypoints_dict中的地址应该已经足够格式化以便与 Google Maps 一起使用,但它们也可以进行地理编码,以减少调用方向 API 时出现错误的可能性。航点字典应该类似于这样。

{
'start': 'Berkeley, CA', 
'end': 'Seattle, WA', 
'waypoints': [
'Redwood National and State Parks, 1111 Second St, Crescent City, CA 95531', 
'Crater Lake National Park, Crater Lake National Park, OR 97604', 
'Mount Rainier National Park, 55210 238th Ave E, Ashford, WA 98304', 
'Olympic National Park, 3002 Mount Angeles Rd, Port Angeles, WA 98362'
], 
'transit': 'driving'
}

6. 将所有内容整合在一起

我们现在能够使用 LLM 验证旅行查询,生成详细的行程并提取航点作为可以传递下游的 JSON 对象。你会看到在代码中,几乎所有这些功能都由Agent类处理,该类在TravelMapperBase中实例化并如下使用。

travel_agent = Agent(
   open_ai_api_key=openai_api_key,
   google_palm_api_key=google_palm_api_key,
   debug=verbose,
)

itinerary, list_of_places, validation = travel_agent.suggest_travel(query)

使用 LangChain 使得替换使用的 LLM 变得非常简单。对于 PALM,我们只需声明。

from langchain.llms import GooglePalm

Agent.chat_model = GooglePalm(
   model_name="models/text-bison-001",
   temperature=0,
   google_api_key=google_palm_api_key,
)

对于 OpenAI,我们可以使用上述部分中描述的ChatOpenAIOpenAI

现在,我们准备进入下一阶段:我们如何将地点列表转换为一组方向,并在地图上绘制它们以供用户查看?这将在本三部分系列的第二部分中介绍。

感谢阅读!请随时在这里探索完整的代码库 github.com/rmartinshort/travel_mapper。任何改进建议或功能扩展将不胜感激!

使用 LangChain、Google Maps API 和 Gradio 构建智能旅行路线建议器(第二部分)

原文:towardsdatascience.com/building-a-smart-travel-itinerary-suggester-with-langchain-google-maps-api-and-gradio-part-2-86e9d2bcae5?source=collection_archive---------3-----------------------#2023-09-26

学习如何构建一个可能激发你下次公路旅行灵感的应用程序

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 Robert Martin-Short

·

关注 发布于 Towards Data Science ·11 分钟阅读·2023 年 9 月 26 日

这篇文章是三部分系列中的第二部分,我们使用 OpenAI 和 Google API 构建了一个旅行路线建议应用程序,并通过 gradio 生成的简单 UI 展示。在这一部分,我们讨论了如何使用 Google Maps API 和 folium 从一组途经点生成交互式路线地图。只想看看代码?在 这里

1. 第一部分回顾

在第一部分的三部分系列中,我们使用 LangChain 和提示工程构建了一个系统,该系统顺序调用 LLM API——无论是谷歌的 PaLM 还是 OpenAI 的 ChatGPT——将用户的查询转换为旅行行程和格式化良好的地址列表。现在是时候看看如何将这些地址列表转换为带有路线标记的旅行路线了。为此,我们主要将使用通过googlemaps包提供的 Google Maps API。我们还将使用folium进行绘图。让我们开始吧!

2. 准备进行 API 调用

要生成 Google Maps 的 API 密钥,你首先需要在 Google Cloud 上创建一个账户。他们提供90 天免费试用期,之后你将按使用的 API 服务支付费用,类似于你在 OpenAI 上的操作。完成后,你可以创建一个项目(我的项目叫 LLMMapper),并导航到 Google Cloud 网站上的 Google Maps Platform 部分。从那里,你应该能访问“密钥与凭据”菜单以生成 API 密钥。你还应该查看“API 和服务”菜单,探索 Google Maps Platform 提供的众多服务。在这个项目中,我们只会使用方向和地理编码服务。我们将对每个途经点进行地理编码,然后查找它们之间的路线。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

截图显示了如何导航到 Google Maps Platform 网站的密钥和凭据菜单。在这里你将生成一个 API 密钥。

现在,可以将 Google Maps API 密钥添加到我们之前设置的 .env 文件中

OPENAI_API_KEY = {your open ai key}
GOOGLE_PALM_API_KEY = {your google palm api key}
GOOGLE_MAPS_API_KEY = {your google maps api key here}

要测试这是否有效,请使用第一部分中描述的方法从 .env 文件加载机密。然后我们可以尝试如下进行地理编码调用

import googlemaps

def convert_to_coords(input_address):
    return self.gmaps.geocode(input_address)

secrets = load_secets()
gmaps = googlemaps.Client(key=secrets["GOOGLE_MAPS_API_KEY"])

example_coords = convert_to_coords("The Washington Moment, DC")

谷歌地图能够将提供的字符串与实际地点的地址和详细信息匹配,并应返回如下列表

[{'address_components': [{'long_name': '2',
    'short_name': '2',
    'types': ['street_number']},
   {'long_name': '15th Street Northwest',
    'short_name': '15th St NW',
    'types': ['route']},
   {'long_name': 'Washington',
    'short_name': 'Washington',
    'types': ['locality', 'political']},
   {'long_name': 'District of Columbia',
    'short_name': 'DC',
    'types': ['administrative_area_level_1', 'political']},
   {'long_name': 'United States',
    'short_name': 'US',
    'types': ['country', 'political']},
   {'long_name': '20024', 'short_name': '20024', 'types': ['postal_code']}],
  'formatted_address': '2 15th St NW, Washington, DC 20024, USA',
  'geometry': {'location': {'lat': 38.8894838, 'lng': -77.0352791},
   'location_type': 'ROOFTOP',
   'viewport': {'northeast': {'lat': 38.89080313029149,
     'lng': -77.0338224697085},
    'southwest': {'lat': 38.8881051697085, 'lng': -77.0365204302915}}},
  'partial_match': True,
  'place_id': 'ChIJfy4MvqG3t4kRuL_QjoJGc-k',
  'plus_code': {'compound_code': 'VXQ7+QV Washington, DC',
   'global_code': '87C4VXQ7+QV'},
  'types': ['establishment',
   'landmark',
   'point_of_interest',
   'tourist_attraction']}]

这非常强大!虽然请求有些模糊,但谷歌地图服务准确地匹配到了一个精确的地址,并提供了坐标以及其他可能对开发者有用的本地信息。我们只需要使用formatted_addressplace_id字段即可。

3. 构建路线

地理编码对于我们的旅行地图应用程序很重要,因为地理编码 API 似乎在处理模糊或部分完成的地址时比方向 API 更加熟练。无法保证来自 LLM 调用的地址包含足够的信息,以便方向 API 能提供良好的响应,因此首先进行地理编码步骤可以减少错误的可能性。

让我们首先对起点、终点和中间途经点列表调用地理编码器,并将结果存储在字典中

 def build_mapping_dict(start, end, waypoints):

    mapping_dict = {}
    mapping_dict["start"] = self.convert_to_coords(start)[0]
    mapping_dict["end"] = self.convert_to_coords(end)[0]

    if waypoints:
      for i, waypoint in enumerate(waypoints):
          mapping_dict["waypoint_{}".format(i)] = convert_to_coords(
                    waypoint
                )[0

    return mapping_dict

现在,我们可以利用方向 API 获取包含途经点的从起点到终点的路线

 def build_directions_and_route(
        mapping_dict, start_time=None, transit_type=None, verbose=True
    ):
    if not start_time:
        start_time = datetime.now()

    if not transit_type:
        transit_type = "driving"

      # later we replace this with place_id, which is more efficient
      waypoints = [
            mapping_dict[x]["formatted_address"]
            for x in mapping_dict.keys()
            if "waypoint" in x
      ]
      start = mapping_dict["start"]["formatted_address"]
      end = mapping_dict["end"]["formatted_address"]

      directions_result = gmaps.directions(
            start,
            end,
            waypoints=waypoints,
            mode=transit_type,
            units="metric",
            optimize_waypoints=True,
            traffic_model="best_guess",
            departure_time=start_time,
      )

      return directions_result

指南 API 的完整文档在这里,并且可以指定许多不同的选项。注意我们指定了路线的起点和终点,以及途经点的列表,并选择了optimize_waypoints=True,这样 Google Maps 就知道可以调整途经点的顺序以减少总旅行时间。我们还可以指定交通类型,默认为driving,除非另有设置。请回忆一下在第一部分中我们让 LLM 返回交通类型及其行程建议,因此理论上我们也可以在这里利用这一点。

从方向 API 调用返回的字典具有以下键

['bounds', 
'copyrights', 
'legs', 
'overview_polyline', 
'summary', 
'warnings', 
'waypoint_order'
]

在这些信息中,legsoverview_polyline 对我们最有用。legs 是一个路线段的列表,每个元素看起来像这样

['distance', 
'duration', 
'end_address', 
'end_location', 
'start_address', 
'start_location', 
'steps', 
'traffic_speed_entry', 
'via_waypoint'
]

每个 leg 进一步细分为 steps,这是逐步指示和其关联的路线段的集合。这是一个包含以下键的字典列表

['distance', 
'duration', 
'end_location', 
'html_instructions', 
'polyline', 
'start_location', 
'travel_mode'
]

polyline 键是存储实际路线信息的地方。每个 polyline 是一系列坐标的编码表示,Google Maps 生成这些编码作为将长列表的经纬度值压缩成一个字符串的方法。它们是编码字符串,看起来像

“e|peFt_ejVjwHalBzaHqrAxeEoBplBdyCzpDif@njJwaJvcHijJcIabHfiFyqMvkFooHhtE}mMxwJgqK”

你可以在这里阅读更多内容,但幸运的是,我们可以使用decode_polyline工具将它们转换回坐标。例如

from googlemaps.convert import decode_polyline

overall_route = decode_polyline(
directions_result[0]["overview_polyline"]["points"]
)
route_coords = [(float(p["lat"]),float(p["lng"])) for p in overall_route]

这将提供沿路线的经纬度点列表。

这就是我们绘制一个简单地图的所有信息,显示途经点及其连接的正确驾驶路径。我们可以使用 overview_polyline 作为起点,尽管我们稍后会看到,这可能会在高缩放级别的地图上导致分辨率问题。

假设我们从以下查询开始:

“我想从旧金山到拉斯维加斯进行为期 5 天的公路旅行。我想沿着 HW1 参观漂亮的沿海城镇,然后在南加州欣赏山景”

我们的 LLM 调用提取了一个途经点字典,我们运行了build_mapping_dictbuild_directions_and_route以从 Google Maps 获得我们的方向结果

我们可以这样提取途经点

 marker_points = []
nlegs = len(directions_result[0]["legs"])
for i, leg in enumerate(directions_result[0]["legs"]):

  start, start_address = leg["start_location"], leg["start_address"]
  end,  end_address = leg["end_location"], leg["end_address"]

  start_loc = (float(start["lat"]),float(start["lng"]))
  end_loc = (float(end["lat"]),float(end["lng"]))

  marker_points.append((start_loc,start_address))

  if i == nlegs-1:
    marker_points.append((end_loc,end_address))

现在,使用 folium 和 branca,我们可以绘制一个漂亮的交互式地图,这个地图应该会出现在 Colab 或 Jupyter Notebook 中

import folium
from branca.element import Figure

figure = Figure(height=500, width=1000)

# decode the route
overall_route = decode_polyline(
  directions_result[0]["overview_polyline"]["points"]
)
route_coords = [(float(p["lat"]),float(p["lng"])) for p in overall_route]

# set the map center to be at the start location of the route
map_start_loc = [overall_route[0]["lat"],overall_route[0]["lng"]]
map = folium.Map(
  location=map_start_loc, 
  tiles="Stamen Terrain", 
  zoom_start=9
)
figure.add_child(map)

# Add the waypoints as red markers 
for location, address in marker_points:
    folium.Marker(
        location=location,
        popup=address,
        tooltip="<strong>Click for address</strong>",
        icon=folium.Icon(color="red", icon="info-sign"),
    ).add_to(map)

# Add the route as a blue line
f_group = folium.FeatureGroup("Route overview")
folium.vector_layers.PolyLine(
    route_coords,
    popup="<b>Overall route</b>",
    tooltip="This is a tooltip where we can add distance and duration",
    color="blue",
    weight=2,
).add_to(f_group)
f_group.add_to(map)

当运行此代码时,Folium 将生成一个交互式地图,我们可以探索并点击每一个途经点。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

从 Google Maps API 调用结果生成的交互式地图

4. 优化路线

上述方法中,我们通过一个包含航点列表的单次调用来获取 Google Maps 方向 API,然后绘制 overview_polyline,作为概念验证效果很好,但仍存在一些问题:

  1. 在调用 Google Maps 时,使用 place_id 来指定起点、终点和航点名称比使用 formatted_address 更有效。幸运的是,我们在地理编码调用的结果中获得了 place_id,因此我们应该使用它。

  2. 单次 API 调用中可以请求的航点数量限制为 25(有关详细信息,请参见 developers.google.com/maps/documentation/directions/get-directions)。如果我们从 LLM 获得的行程中有超过 25 个停靠点,我们需要向 Google Maps 发出更多调用,然后合并响应。

  3. overview_polyline 在放大时分辨率有限,可能是因为它沿线的点数经过了大规模地图视图的优化。这对于一个概念验证来说不是主要问题,但如果能对路线分辨率进行更多控制,使其在高缩放级别下也能保持良好外观,那就更好了。方向 API 为我们提供了更细致的路段折线,我们可以利用这些信息。

  4. 在地图上,将路线拆分为单独的路段并允许用户查看与每个路段相关的距离和旅行时间是很好的功能。同样,Google Maps 提供了这些信息,因此我们应该加以利用。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

overview_polyline 的分辨率有限。在这里,我们已经缩放到圣巴巴拉,但尚不清楚我们应该走哪些道路。

问题 1 可以通过修改 build_directions_and_route 来使用 mapping_dict 中的 place_id 而不是 formatted_address 来轻松解决。问题 2 稍微复杂一些,需要将初始航点拆分成一些最大长度的块,从每个块中创建起点、终点和子列表,然后在这些块上运行 build_mapping_dictbuild_directions_and_route。最终结果可以在最后合并。

问题 3 和 4 可以通过使用 Google Maps 返回的每段路程的单独步骤折线来解决。我们只需遍历这两个级别,解码相关的折线,然后构建一个新的字典。这也使我们能够提取距离和持续时间值,这些值被分配给每个解码的路段,然后用于绘图。

def get_route(directions_result):
    waypoints = {}

    for leg_number, leg in enumerate(directions_result[0]["legs"]):
        leg_route = {}

        distance, duration = leg["distance"]["text"], leg["duration"]["text"]
        leg_route["distance"] = distance
        leg_route["duration"] = duration
        leg_route_points = []

        for step in leg["steps"]:
             decoded_points = decode_polyline(step["polyline"]["points"])
            for p in decoded_points:
              leg_route_points.append(f'{p["lat"]},{p["lng"]}')

            leg_route["route"] = leg_route_points
            waypoints[leg_number] = leg_route

    return waypoints

现在的问题是 leg_route_points 列表可能会变得非常长,当我们在地图上绘制这些点时,可能会导致 folium 崩溃或运行非常缓慢。解决方案是沿路线采样这些点,以确保有足够的点以便进行良好的可视化,但又不至于让地图加载困难。

一种简单且安全的方法是计算总路线应包含的点数(例如 5000 个点),然后确定每段路线应包含的点的比例,并均匀地从每段中采样相应数量的点。请注意,我们需要确保每段至少包含一个点,以便它能够显示在地图上。

以下函数将执行此采样,输入一个来自上面get_route函数的waypoints字典。

def sample_route_with_legs(route, distance_per_point_in_km=0.25)):

    all_distances = sum([float(route[i]["distance"].split(" ")[0]) for i in route])
    # Total points in the sample
    npoints = int(np.ceil(all_distances / distance_per_point_in_km))

    # Total points per leg
    points_per_leg = [len(v["route"]) for k, v in route.items()]
    total_points = sum(points_per_leg)

    # get number of total points that need to be represented on each leg
    number_per_leg = [
      max(1, np.round(npoints * (x / total_points), 0)) for x in points_per_leg
      ]

    sampled_points = {}
    for leg_id, route_info in route.items():
        total_points = int(points_per_leg[leg_id])
        total_sampled_points = int(number_per_leg[leg_id])
        step_size = int(max(total_points // total_sampled_points, 1.0))
        route_sampled = [
                route_info["route"][idx] for idx in range(0, total_points, step_size)
            ]

        distance = route_info["distance"]
        duration = route_info["duration"]

        sampled_points[leg_id] = {
                "route": [
                    (float(x.split(",")[0]), float(x.split(",")[1]))
                    for x in route_sampled
                ],
                "duration": duration,
                "distance": distance,
            }
    return sampled_points

在这里我们指定了我们想要的点间距——每 250 米一个点——然后相应地选择点的数量。我们还可以考虑从路线长度估算所需的点间距,但这种方法在第一次尝试中似乎效果相当好,在地图上的中等高的缩放级别下提供了可接受的分辨率。

现在我们已经将路线拆分为具有合理样本点数量的段落,我们可以继续将它们绘制在地图上,并使用以下代码对每一段进行标注。

for leg_id, route_points in sampled_points.items():
    leg_distance = route_points["distance"]
    leg_duration = route_points["duration"]

    f_group = folium.FeatureGroup("Leg {}".format(leg_id))
    folium.vector_layers.PolyLine(
                route_points["route"],
                popup="<b>Route segment {}</b>".format(leg_id),
                tooltip="Distance: {}, Duration: {}".format(leg_distance, leg_duration),
                color="blue",
                weight=2,
    ).add_to(f_group)
    # assumes the map has already been generated
    f_group.add_to(map)

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

这是一个标注并注释过的路线段示例,以便它能够出现在地图上。

5. 整合所有内容

在代码库中,以上提到的所有方法都被打包在两个类中。第一个是RouteFinder,它接受Agent的结构化输出(见第一部分),并生成采样路线。第二个是RouteMapper,它接收采样路线并绘制一个 folium 地图,可以保存为 html 文件。

由于我们几乎总是希望在请求路线时生成地图,RouteFindergenerate_route方法处理这两个任务。

class RouteFinder:
    MAX_WAYPOINTS_API_CALL = 25

    def __init__(self, google_maps_api_key):
        self.logger = logging.getLogger(__name__)
        self.logger.setLevel(logging.INFO)
        self.mapper = RouteMapper()
        self.gmaps = googlemaps.Client(key=google_maps_api_key)

    def generate_route(self, list_of_places, itinerary, include_map=True):

        self.logger.info("# " * 20)
        self.logger.info("PROPOSED ITINERARY")
        self.logger.info("# " * 20)
        self.logger.info(itinerary)

        t1 = time.time()
        directions, sampled_route, mapping_dict = self.build_route_segments(
            list_of_places
        )
        t2 = time.time()
        self.logger.info("Time to build route : {}".format((round(t2 - t1, 2))))

        if include_map:
            t1 = time.time()
            self.mapper.add_list_of_places(list_of_places)
            self.mapper.generate_route_map(directions, sampled_route)
            t2 = time.time()
            self.logger.info("Time to generate map : {}".format((round(t2 - t1, 2))))

        return directions, sampled_route, mapping_dict

回想一下在第一部分中我们构建了一个名为Agent的类,该类处理 LLM 调用。现在我们还有了RouteFinder,我们可以将它们组合到整个旅行映射器项目的基础类中。

class TravelMapperBase(object):
    def __init__(
        self, openai_api_key, google_palm_api_key, google_maps_key, verbose=False
    ):
        self.travel_agent = Agent(
            open_ai_api_key=openai_api_key,
            google_palm_api_key=google_palm_api_key,
            debug=verbose,
        )
        self.route_finder = RouteFinder(google_maps_api_key=google_maps_key)

    def parse(self, query, make_map=True):

        itinerary, list_of_places, validation = self.travel_agent.suggest_travel(query)

        directions, sampled_route, mapping_dict = self.route_finder.generate_route(
            list_of_places=list_of_places, itinerary=itinerary, include_map=make_map
        )

这可以通过以下查询运行,这也是在test_without_gradio脚本中给出的示例。

from travel_mapper.TravelMapper import load_secrets, assert_secrets
from travel_mapper.TravelMapper import TravelMapperBase

def test(query=None):
    secrets = load_secrets()
    assert_secrets(secrets)

    if not query:
        query = """
        I want to do 2 week trip from Berkeley CA to New York City.
        I want to visit national parks and cities with good food.
        I want use a rental car and drive for no more than 5 hours on any given day.
        """

    mapper = TravelMapperBase(
        openai_api_key=secrets["OPENAI_API_KEY"],
        google_maps_key=secrets["GOOGLE_MAPS_API_KEY"],
        google_palm_api_key=secrets["GOOGLE_PALM_API_KEY"],
    )

    mapper.parse(query, make_map=True)

就路线和地图生成而言,我们现在已经完成了!但是我们如何将所有这些代码打包成一个易于实验的漂亮 UI 呢?这将会在本系列的第三部分中讲解。

感谢阅读!请随时在这里探索完整的代码库github.com/rmartinshort/travel_mapper。任何改进建议或功能扩展都会非常感谢!

使用 LangChain、Google Maps API 和 Gradio 构建智能旅行行程建议器(第三部分)

原文:towardsdatascience.com/building-a-smart-travel-itinerary-suggester-with-langchain-google-maps-api-and-gradio-part-3-90dc7be627fb?source=collection_archive---------5-----------------------#2023-09-26

了解如何构建一个可能激发你下一次公路旅行灵感的应用

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 Robert Martin-Short

·

关注 发表在 Towards Data Science ·6 分钟阅读·2023 年 9 月 26 日

本文是一个三部分系列中的最后一篇,我们使用 OpenAI 和 Google API 构建了一个旅行行程建议应用,并通过 Gradio 生成的简单 UI 展示它。在这一部分,我们讨论如何构建该 UI,将我们在第一部分和第二部分中构建的 Agent 和 RouteFinder 模块组合在一起。只想查看代码?请在这里找到

1. 第二部分回顾

在这个三部分系列的第二部分,我们构建了一个系统,该系统从一系列 LLM 调用中获取解析后的路标列表(第一部分),并使用 Google Maps API 和 Folium 生成它们之间的路线,并将其绘制在交互式地图上。回顾一下,我们在这个项目中的目标是构建一个应用程序,允许用户轻松输入旅行请求,例如*“从柏林到苏黎世的四天行程,我想尝试大量当地啤酒和食物”*,并返回详细的行程安排和地图供他们探索。感谢第一部分和第二部分,我们已经组装了所有组件,现在只需将它们组合在一个使使用变得简单的 UI 中即可。

2. 将地图连接到 Gradio

gradio是一个出色的库,用于快速构建能够展示机器学习模型的交互式应用程序。它有一个gradio.Plot组件,旨在与 Matplotlib、Bokeh 和 Plotly 配合使用(详细信息这里)。然而,我们在第二部分生成的地图是使用 Folium 制作的。虽然可以使用这些其他库重新制作这些地图,但幸运的是,我们不需要这么做。相反,我们可以使用leafmap包,它允许我们重用已有的 Folium 代码,并强制输出 Gradio 可以理解的 HTML。详细信息可以在这里找到。

让我们来看一个简单的例子,了解它是如何工作的。首先,我们将创建一个函数,从中输出所需格式的 HTML。

import leafmap.foliumap as leafmap
import folium
import gradio as gr

def generate_map(center_coordinates, zoom_level):

    coords = center_coordinates.split(",")
    lat, lon = float(coords[0]), float(coords[1])
    map = leafmap.Map(location=(lat,lon), tiles="Stamen Terrain", zoom_start=zoom_level)

    return map.to_gradio()

在这里,函数generate_map接收一个格式为*“lat,lon”*的坐标字符串和一个 Folium 地图的缩放级别。它生成地图并将其转换为 Gradio 可以读取的格式。

接下来,让我们构建一个非常简单的 Gradio 界面来展示我们的地图。

demo = gr.Blocks()

with demo:
    gr.Markdown("## Generate a map")
    with gr.Row():
      with gr.Column():
        # first col is for buttons
        coordinates_input = gr.Textbox(value="",label="Your center coordines",lines=1)
        zoom_level_input = gr.Dropdown(choices=[1,2,3,4,5,6,7,8,9],label="choose zoom level")
        map_button = gr.Button("Generate map")
      with gr.Column():
        # second col is for the map
        map_output = gr.HTML(label="Travel map")

    map_button.click(generate_map, inputs=[coordinates_input,zoom_level_input], outputs=[map_output])

# run this in a notebook to display the UI
demo.queue().launch(debug=True)

在这里,我们利用Blocks API,它为我们提供了设置应用程序 UI 的灵活性。我们创建了一行组件,分为两列。第一列包含三个元素:一个文本框供用户输入所需的中心坐标,一个下拉菜单选择缩放级别,以及一个名为*“生成地图”*的按钮,用户需要点击这个按钮。

在第二列,我们有map_output,这是一个gradio.HTML()组件,用于显示地图的 HTML。

然后,我们需要做的就是定义点击 map_button 时发生的事情。当发生这种情况时,我们将运行 generate_map 函数,传入从 coordinates_inputzoom_input 中选择的值。结果将被发送到 map_output 变量。

运行此代码会产生以下用户界面

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

使用 leafmap 和 gradio 生成的基本映射用户界面

这确实不复杂也不精美,但它包含了使用 gradio 构建映射工具的基本元素。

3. 为我们的旅行代理商创建一个简单的用户界面

让我们首先看看 gradio 应用程序在我们检查代码之前的一些功能。不过要注意,gradio 提供了大量组件来创建复杂且美观的用户界面,而这个旅行地图用户界面仍然处于 POC 阶段。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

旅行地图应用最终版本中所有组件的描述

我们的应用程序本质上有两列。第一列包含一个文本框,供用户输入查询,一个单选按钮组,允许我们在模型之间切换,以及一个显示验证检查输出的文本框。

第二列包含由 leafmap.folium 生成的地图和一个显示 LLM 调用完整文本行程输出的文本框。“生成地图” 按钮在底部,截图中不可见。

多亏了 gradio 在后台完成的所有工作,所有这些代码都非常简洁。

import gradio as gr
from travel_mapper.TravelMapper import TravelMapperForUI, load_secrets, assert_secrets
from travel_mapper.user_interface.utils import generate_generic_leafmap
from travel_mapper.user_interface.constants import EXAMPLE_QUERY

def main():

    # load the AP keys
    secrets = load_secrets()
    assert_secrets(secrets)

    # set up travel mapper (see part 2)
    travel_mapper = TravelMapperForUI(
        openai_api_key=secrets["OPENAI_API_KEY"],
        google_maps_key=secrets["GOOGLE_MAPS_API_KEY"],
        google_palm_api_key=secrets["GOOGLE_PALM_API_KEY"],
    )

    # build the UI in gradio
    app = gr.Blocks()

    # make a generic map to display when the app first loads 
    generic_map = generate_generic_leafmap()

    with app:
        gr.Markdown("## Generate travel suggestions")

        # make multple tabs
        with gr.Tabs():
            # make the first tab
            with gr.TabItem("Generate with map"):
                # make rows 1 within tab 1
                with gr.Row():
                    # make column 1 within row 1
                    with gr.Column():
                        text_input_map = gr.Textbox(
                            EXAMPLE_QUERY, label="Travel query", lines=4
                        )

                        radio_map = gr.Radio(
                            value="gpt-3.5-turbo",
                            choices=["gpt-3.5-turbo", "gpt-4", "models/text-bison-001"],
                            label="models",
                        )

                        query_validation_text = gr.Textbox(
                            label="Query validation information", lines=2
                        )

                    # make column 2 within row 1
                    with gr.Column():
                        # place where the map will appear
                        map_output = gr.HTML(generic_map, label="Travel map")
                        # place where the suggested trip will appear
                        itinerary_output = gr.Textbox(
                            value="Your itinerary will appear here",
                            label="Itinerary suggestion",
                            lines=3,
                        )
                # generate button
                map_button = gr.Button("Generate")

            # make the second tab
            with gr.TabItem("Generate without map"):
                # make the first row within the second tab
                with gr.Row():
                    # make the first column within the first row
                    with gr.Column():
                        text_input_no_map = gr.Textbox(
                            value=EXAMPLE_QUERY, label="Travel query", lines=3
                        )

                        radio_no_map = gr.Radio(
                            value="gpt-3.5-turbo",
                            choices=["gpt-3.5-turbo", "gpt-4", "models/text-bison-001"],
                            label="Model choices",
                        )

                        query_validation_no_map = gr.Textbox(
                            label="Query validation information", lines=2
                        )
                    # make the second column within the first row
                    with gr.Column():
                        text_output_no_map = gr.Textbox(
                            value="Your itinerary will appear here",
                            label="Itinerary suggestion",
                            lines=3,
                        )
                # generate button
                text_button = gr.Button("Generate")

        # instructions for what happens whrn the buttons are clicked 
        # note use of the "generate_with_leafmap" method here. 
        map_button.click(
            travel_mapper.generate_with_leafmap,
            inputs=[text_input_map, radio_map],
            outputs=[map_output, itinerary_output, query_validation_text],
        )
        text_button.click(
            travel_mapper.generate_without_leafmap,
            inputs=[text_input_no_map, radio_no_map],
            outputs=[text_output_no_map, query_validation_no_map],
        )

    # run the app
    app.launch()

4. 创建包

从 github 上查看存储库可以看出,旅行地图代码是通过 cookiecutter 的标准模板构建的,但模板中的一些重要部分尚未填充。理想情况下,我们会包括单元测试和集成测试,并完成存储库设置,以便使用持续集成/持续交付(CI/CD)概念。如果项目在这个 POC 阶段之后进一步发展,这些方面将在未来添加。

代码可以通过几种方式在本地运行。如果我们将上述 main 函数放入一个名为 driver.py 的脚本中,我们应该能够从 travel_mapper 项目的顶层从终端运行它。如果包成功运行,终端中应出现类似以下的消息

Running on local URL:  http://127.0.0.1:7860

将此网址复制粘贴到网络浏览器中应该会显示出在本地运行的 gradio 应用。当然,如果我们真的想将其部署到网络上(我不推荐,因为 API 调用的费用),需要更多的步骤,但这超出了这些文章的范围。

驱动程序也可以从名为 run.sh 的 bash 脚本中运行,该脚本可以在代码库的 user_interface 模块中找到。

# Run the UI
# run this from the top level directory of the travel mapper project
export PYTHONPATH=$PYTHONPATH:$(pwd)
echo "Starting travel mapper UI"
$(pwd)/travel_mapper/user_interface/driver.py

从项目的顶层运行时,这也会正确设置PYTHONPATH,确保项目特定的导入语句始终被识别。

这就是本系列的全部内容,感谢你一直看到最后!请随时在这里探索完整的代码库 github.com/rmartinshort/travel_mapper。任何改进建议或功能扩展的意见都非常受欢迎!

使用 Redshift Serverless 和 Kinesis 构建流数据管道

原文:towardsdatascience.com/building-a-streaming-data-pipeline-with-redshift-serverless-and-kinesis-04e09d7e85b2

面向初学者的完整教程

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 💡Mike Shakhomirov

·发表于Towards Data Science ·阅读时间 9 分钟·2023 年 10 月 6 日

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图片由Sebastian Pandelache拍摄,来源于Unsplash

在本文中,我将讨论最受欢迎的数据管道设计模式之一——事件流。除了其他好处,它还支持超快的数据分析,我们可以创建实时更新结果的报告仪表盘。我将演示如何通过构建一个使用 AWS Kinesis 和 Redshift 的流数据管道来实现这一点,并且可以通过几次点击使用基础设施即代码进行部署。我们将使用 AWS CloudFormation 来描述我们的数据平台架构并简化部署过程。

想象一下,作为数据工程师,你的任务是创建一个将服务器事件流与数据仓库解决方案(Redshift)连接起来的数据管道,以便转换数据并创建分析仪表盘。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

管道基础设施。图片来源:作者。

什么是数据管道?

它是一个数据处理步骤的序列。由于这些阶段之间的逻辑数据流连接,每个阶段生成一个输出,作为下一个阶段的输入

我之前在这篇文章中写过相关内容:

## 数据管道设计模式

选择合适的架构及示例

towardsdatascience.com

例如,事件数据可以由后端的源创建,使用 Kinesis Firehose 或 Kafka 流构建事件流。然后它可以馈送到多个不同的消费者或目的地。

流式处理是企业数据的“必备”解决方案,因其流数据处理能力。它能够实现实时数据分析。

在我们的用例场景中,我们可以设置一个ELT 流式数据管道到 AWS Redshift。AWS Firehose 流可以提供这种无缝集成,当数据流被直接上传到数据仓库表时。然后,数据可以被转换以使用 AWS Quicksight 作为 BI 工具来创建报告,例如。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

添加了 BI 组件。图片来源于作者。

本教程假设学习者熟悉 AWS CLI 并且具有基本的 Python 知识。

工作流程

1. 首先,我们将使用 AWS CloudFormation 创建 Kinesis 数据流。

2. 我们将使用 AWS Lambda 向此事件流发送示例数据事件。

3. 最后,我们将配置 AWS Redshift 集群并测试我们的流式管道。

创建 AWS Kinesis 数据流

AWS Kinesis Data Streams 是一个 Amazon Kinesis 实时数据流解决方案。它提供了出色的可扩展性和耐用性,数据流可以被任何消费者访问。

我们可以使用 CloudFormation 模板来创建它。下面的命令行脚本将触发 AWS CLI 命令进行部署:

KINESIS_STACK=YourRedshiftDataStream
ENV=staging
aws \
cloudformation deploy \
--template-file kinesis-data-stream.yaml \
--stack-name $KINESIS_STACK \
--capabilities CAPABILITY_IAM \
--parameter-overrides \
"Environment"=$ENV

并且模板 kinesis-data-stream.yaml 将如下所示:

AWSTemplateFormatVersion: 2010-09-09
Description: >
  Firehose resources relating to statistics generation.
  Repository - https://github.com/your_repository.

Parameters:
  Environment:
    AllowedValues:
      - staging
      - production
    Description: Target environment
    Type: String
    Default: 'staging'

Resources:
  MyKinesisStream:
    Type: AWS::Kinesis::Stream
    Properties: 
      Name: !Sub 'your-data-stream-${Environment}'
      RetentionPeriodHours: 24 
      StreamModeDetails:
        StreamMode: ON_DEMAND
      # ShardCount: 1
      Tags: 
        -
          Key: Environment
          Value: Production

非常简单。如果一切顺利,我们将看到我们的 Kinesis 流被部署:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

流已创建。图片来源于作者。

2. 创建 AWS Lambda 函数以模拟事件流

现在我们希望将一些事件发送到我们的 Kinesis 数据流。为此,我们可以创建一个无服务器应用程序,例如 AWS Lambda。我们将使用boto3库(AWS 的 Python SDK)来构建一个数据连接器与 AWS Kinesis 进行数据源连接。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

本地运行应用以模拟事件流。图片来源于作者。

我们的应用程序文件夹结构可以如下所示:

.
├── app.py
├── config
│   └── staging.yaml
├── env.json
└── requirements.txt

我们的app.py必须能够向 Kinesis 数据流发送事件:

# Make sure boto3 is installed locally, i.e. pip install boto3
import json
import random
import boto3

kinesis_client = boto3.client('kinesis', region_name='eu-west-1')
# Constants:
STREAM_NAME = "your-data-stream-staging"

def lambda_handler(event, context):
    processed = 0
    print(STREAM_NAME)
    try:
        print('Trying to send events to Kinesis...')
        for i in range(0, 5):
            data = get_data()
            print(i, " : ", data)
            kinesis_client.put_record(
                StreamName=STREAM_NAME,

                Data=json.dumps(data),
                PartitionKey="partitionkey")
            processed += 1
    except Exception as e:
        print(e)
    message = 'Successfully processed {} events.'.format(processed)
    return {
        'statusCode': 200,
        'body': { 'lambdaResult': message }
    }

我们希望添加一个帮助函数来生成一些随机事件数据。例如:

# Helpers:
def get_data():
    return {
        'event_time': datetime.now().isoformat(),
        'event_name': random.choice(['JOIN', 'LEAVE', 'OPEN_CHAT', 'SUBSCRIBE', 'SEND_MESSAGE']),
        'user': round(random.random() * 100)}

我们可以使用python-lambda-local库本地运行和测试 AWS Lambda,方法如下:

pip install python-lambda-local
cd stack
python-lambda-local -e events_connector/env.json -f lambda_handler events_connector/app.py event.json --timeout 10000
# -e is for environment variables if you choose to use them.
#  event.json - sample JSON event to invoke our Lambda with.

env.json 只是一个事件负载,用于本地运行 Lambda。

config/staging.yaml 可以包含我们应用程序未来可能需要的任何环境特定设置。例如:

# staging.yaml
Kinesis:
  DataStreamNsme: your-data-stream-staging

如果你需要使用requirements.txt,它可以如下所示:

requests==2.28.1
pyyaml==6.0
boto3==boto3-1.26.90
python-lambda-local==0.1.13

在你的命令行中运行这个:

 cd stack
pip install -r events_connector/requirements.txt

这种方法很有用,因为我们可能希望将无服务器应用程序部署到云中并进行调度。我们可以使用 CloudFormation 模板来实现这一点。我之前在这里写过:

## Infrastructure as Code for Beginners

使用这些模板像专业人士一样部署数据管道

levelup.gitconnected.com

当我们使用 CloudFormation 模板时,应用程序可以通过类似的 shell 脚本进行部署:

PROFILE=your-aws-profile
STACK_NAME=YourStackNameLive
LAMBDA_BUCKET=your-lambdas-bucket.aws # Make sure it exists

date

TIME=`date +"%Y%m%d%H%M%S"`

base=${PWD##*/}
zp=$base".zip"
echo $zp

rm -f $zp

pip install --target ./package -r requirements.txt

cd package
zip -r ../${base}.zip .

cd $OLDPWD

zip -r $zp ./events_connector -x __pycache__ 

aws --profile $PROFILE s3 cp ./${base}.zip s3://${LAMBDA_BUCKET}/events_connector/${base}${TIME}.zip

aws --profile $PROFILE \
cloudformation deploy \
--template-file stack.yaml \
--stack-name $STACK_NAME \
--capabilities CAPABILITY_IAM \
--parameter-overrides \
"StackPackageS3Key"="events_connector/${base}${TIME}.zip" \
"Environment"="staging" \
"Testing"="false"

这是一个灵活的设置,允许我们创建强大的 CI/CD 管道。我记得我在下面的帖子中创建了一个。

Continuous Integration and Deployment for Data Platforms

数据工程师和 ML Ops 的 CI/CD

[towardsdatascience.com

创建 Redshift Serverless 资源

现在我们需要为我们的流数据管道创建 Redshift Serverless 集群。我们可以手动或使用 CloudFormation 模板配置 Redshift Workgroup、创建 Namespace 和其他所需资源。

Redshift Serverless 仅仅是一个数据仓库解决方案。它可以执行任何规模的分析工作负载,无需数据仓库基础设施管理。Redshift 运行迅速,并能在几秒钟内从巨量数据中生成洞察。它会自动扩展,为即使是最苛刻的应用程序提供快速性能。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

例子视图显示了我们应用程序的事件。图像来源于作者。

在我们的案例中,我们可以使用 CloudFormation 模板定义来部署 Redshift 资源。

AWSTemplateFormatVersion: 2010-09-09
Parameters:
  DatabaseName:
    Description: The name of the first database in the Amazon Redshift Serverless environment.
    Type: String
    Default: dev
    MaxLength: 127
    AllowedPattern: '[a-zA-Z][a-zA-Z_0-9+.@-]*'
  AdminUsername:
    Description: The administrator's user name for Redshift Serverless Namespace being created.
    Type: String
    Default: admin
    AllowedPattern: '[a-zA-Z][a-zA-Z_0-9+.@-]*'
  AdminUserPassword:
    Description: The password associated with admin user.
    Type: String
    NoEcho: 'true'
    Default: Admin123
    MinLength: 8
    MaxLength: 64
    # AllowedPattern: '^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[^\x00-\x20\x22\x27\x2f\x40\x5c\x7f-\uffff]+'
  NamespaceName:
    Description: A unique identifier that defines the Namespace.
    Default: rswg
    Type: String
    MinLength: 3
    MaxLength: 64
    AllowedPattern: '^[a-z0-9-]+$'
  WorkgroupName:
    Description: A unique identifier that defines the Workspace.
    Default: redshiftworkgroup
    Type: String
    MinLength: 3
    MaxLength: 64
    AllowedPattern: '^[a-z0-9-]*$'
  BaseRPU:
    Description: Base RPU for Redshift Serverless Workgroup.
    Type: Number
    MinValue: 8
    MaxValue: 512
    Default: 8
    AllowedValues: [8,16,32,40,48,56,64,72,80,88,96,104,112,120,128,136,144,152,160,168,176,184,192,200,208,216,224,232,240,248,256,264,272,280,288,296,304,312,320,328,336,344,352,360,368,376,384,392,400,408,416,424,432,440,448,456,464,472,480,488,496,504,512]
  PubliclyAccessible:
    Description: Redshift Serverless instance to be publicly accessible.
    Type: String
    Default: true
    AllowedValues:
      - true
      - false

  SubnetId:
    Description: You must have at least three subnets, and they must span across three Availability Zones
    Type: List<AWS::EC2::Subnet::Id>
  SecurityGroupIds:
    Description: The list of SecurityGroupIds in your Virtual Private Cloud (VPC).
    Type: List<AWS::EC2::SecurityGroup::Id>
  LogExportsList:
    Description: Provide comma seperate values from list "userlog","connectionlog","useractivitylog".  E.g userlog,connectionlog,useractivitylog.  If left blank, LogExport is turned off.
    Type: CommaDelimitedList 
    Default: userlog,connectionlog,useractivitylog
  EnhancedVpcRouting:
    Description: The value that specifies whether to enable enhanced virtual private cloud (VPC) routing, which forces Amazon Redshift Serverless to route traffic through your VPC.
    Type: String
    AllowedValues:
      - true
      - false
    Default: false    
Metadata:
  'AWS::CloudFormation::Interface':
    ParameterGroups:
      - Label:
          default: Namespace parameters
        Parameters:
          - NamespaceName
          - DatabaseName
          - AdminUsername
          - AdminUserPassword
          - IAMRole
          - LogExportsList          
      - Label:
          default: Workgroup parameters
        Parameters:
            - WorkgroupName
            - BaseRPU
            - PubliclyAccessible
            - SubnetId
            - SecurityGroupIds
            - EnhancedVpcRouting            
    ParameterLabels:
      DatabaseName:
        default: "Database Name"
      AdminUsername:
        default: "Admin User Name"
      AdminUserPassword:
        default: "Admin User Password"
      NamespaceName:
        default: "Namespace"
      WorkgroupName:
        default: "Workgroup"
      BaseRPU:
        default: "Base RPU"
      PubliclyAccessible:
        default: "Publicly accessible"
      SubnetId:
        default: "Subnet Ids (Select 3 Subnet Ids spanning 3 AZs)"
      SecurityGroupIds:
        default: "Security Group Id"
      IAMRole:
        default: "Associate IAM Role"
      EnhancedVpcRouting:
        default: "Enhanced VPC Routing"  
      LogExportsList:
        default: "Log Export List"
Resources:
  RedshiftAccessRole:
    Type: AWS::IAM::Role
    Properties:
      ManagedPolicyArns: 
          - arn:aws:iam::aws:policy/service-role/AWSGlueServiceRole
          - arn:aws:iam::aws:policy/AmazonRedshiftAllCommandsFullAccess

      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          -
            Effect: Allow
            Principal:
              Service:
                - redshift.amazonaws.com
            Action:
              - sts:AssumeRole
  RedshiftRolePolicy:
    Type: AWS::IAM::Policy
    Properties:
      PolicyName: RedshiftRolePolicy
      PolicyDocument:
        Version: 2012-10-17
        Statement:
          -
            Effect: Allow
            Action: s3:ListAllMyBuckets
            Resource: arn:aws:s3:::*
          -
            Effect: Allow
            Action:
              - 's3:Get*'
              - 's3:List*'
            Resource: '*'
          -
            Effect: Allow
            Action: cloudwatch:*
            Resource: "*"
          -
            Effect: Allow
            Action: kinesis:*
            Resource: "*"
      Roles:
        - !Ref RedshiftAccessRole
  RedshiftServerlessNamespace:
    DependsOn: RedshiftAccessRole
    Type: 'AWS::RedshiftServerless::Namespace'
    Properties:
      AdminUsername:
        Ref: AdminUsername
      AdminUserPassword:
        Ref: AdminUserPassword
      DbName:
        Ref: DatabaseName
      NamespaceName:
        Ref: NamespaceName
      IamRoles:
        - !GetAtt [ RedshiftAccessRole, Arn ]
      LogExports:
        Ref: LogExportsList        
  RedshiftServerlessWorkgroup:
    Type: 'AWS::RedshiftServerless::Workgroup'
    Properties:
      WorkgroupName:
        Ref: WorkgroupName
      NamespaceName:
        Ref: NamespaceName
      BaseCapacity:
        Ref: BaseRPU
      PubliclyAccessible:
        Ref: PubliclyAccessible
      SubnetIds:
        Ref: SubnetId
      SecurityGroupIds:
        Ref: SecurityGroupIds
      EnhancedVpcRouting:
        Ref: EnhancedVpcRouting        
    DependsOn:
      - RedshiftServerlessNamespace
Outputs:
  ServerlessNamespace:
    Description: Name of the namespace
    Value: !Ref NamespaceName
  ServerlessWorkgroup:
    Description: Name of the workgroup
    Value: !Ref WorkgroupName

所以如果我们在命令行中运行这段代码,它将部署这个堆栈:

STACK=YourRedshiftServerless
SUBNETID=subnet-1,subnet-2,subnet-3
SECURITYGROUPIDS=sg-your-security-group
aws \
cloudformation deploy \
--template-file redshift-serverless.yaml \
--stack-name $STACK \
--capabilities CAPABILITY_IAM \
--parameter-overrides \
"SubnetId"=$SUBNETID \
"SecurityGroupIds"=$SECURITYGROUPIDS

通常,我们希望在私有子网中部署数据库。然而,在开发的早期阶段,你可能希望从开发机器直接访问 Redshift。

这不推荐用于生产环境,但在这种开发情况下,你可以先将 Redshift 放入我们的 default VPC 子网。

现在,当所有所需的管道资源成功配置后,我们可以连接我们的 Kinesis 流和 Redshift 数据仓库。

然后我们可以使用 SQL 语句在 Redshift 中创建 kinesis_data 模式:

CREATE EXTERNAL SCHEMA kinesis_data
FROM KINESIS
IAM_ROLE 'arn:aws:iam::123456789:role/rs3-RedshiftAccessRole-1TU31HQNXM0EK';
;
CREATE MATERIALIZED VIEW "your-stream-view" AUTO REFRESH YES AS
    SELECT approximate_arrival_timestamp,
           partition_key,
           shard_id,
           sequence_number,
           refresh_time,
           JSON_PARSE(kinesis_data) as payload
      FROM kinesis_data."your-data-stream-staging";
;

这段 SQL 的第一部分将设置 AWS Kinesis 作为数据源。第二部分将创建一个包含我们应用程序事件数据的视图。

确保创建一个具有 AmazonRedshiftAllCommandsFullAccess AWS 管理策略的 AWS Redshift 角色。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Action": "s3:ListAllMyBuckets",
            "Resource": "arn:aws:s3:::*",
            "Effect": "Allow"
        },
        {
            "Action": [
                "s3:Get*",
                "s3:List*"
            ],
            "Resource": "*",
            "Effect": "Allow"
        },
        {
            "Action": "cloudwatch:*",
            "Resource": "*",
            "Effect": "Allow"
        },
        {
            "Action": "kinesis:*",
            "Resource": "*",
            "Effect": "Allow"
        }
    ]
}

就这样。一切准备好运行应用程序以模拟事件数据流。这些事件会立即出现在我们刚刚创建的 Redshift 视图中:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

应用程序在本地运行。图像来源于作者。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

示例视图显示了来自我们应用程序的事件。图片由作者提供。

结论

我们创建了一个简单而可靠的流数据管道,从使用 AWS Lambda 创建的无服务器应用程序到 AWS Redshift 数据仓库,在那里数据实时转化和摄取。它能够轻松捕获、处理和存储任何规模的数据流。对于任何机器学习(ML)管道都非常适用,其中模型用于检查数据并预测推理端点,因为数据流向其目标。

我们使用基础设施即代码来部署数据管道资源。这是部署不同数据环境中资源的首选方法。

推荐阅读

/continuous-integration-and-deployment-for-data-platforms-817bf1b6bed1?source=post_page-----04e09d7e85b2--------------------------------) [## 数据平台的持续集成和部署

数据工程师和 ML 运维的 CI/CD

[Towards Data Science /data-platform-architecture-types-f255ac6e0b7?source=post_page-----04e09d7e85b2--------------------------------) [## 数据平台架构类型

它能多大程度上满足你的业务需求?选择的困境。

[Towards Data Science [`levelup.gitconnected.com/infrastructure-as-code-for-beginners-a4e36c805316?source=post_page-----04e09d7e85b2--------------------------------) [## 初学者的基础设施即代码

使用这些模板像专业人士一样部署数据管道

LevelUp`

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值