R 数据分析:如何为您的孩子找到完美的 Cocomelon 视频
如何使用 R 从头开始构建一个端到端的数据项目,探索新的流行 Cocomelon 视频
·发布于 Towards Data Science ·9 分钟阅读·2023 年 3 月 4 日
–
由 Tony Sebastian 提供的照片,来源于 Unsplash
Cocomelon — Nursery Rhymes 是全球第二大 YouTube 频道(155M+ 订阅者)。这是一个如此受欢迎和有用的频道,对于幼儿和父母来说都是不可或缺的。我喜欢和我的儿子一起观看 Cocomelon。
在观看 Cocomelon 视频一个月后,我注意到 YouTube 上重复推荐相同的视频。像 “The wheel on the bus” 和 “bath song” 这样的热门视频虽然有趣,但它们已经发布多年,孩子们看了会感到厌倦。作为父亲,我希望展示一些较新的高质量 Cocomelon 视频。作为数据专业人士,我也希望深入探索全球第二大 YouTube 频道的数据,以获得更多见解并发现有趣的数据。
YouTube 频道中的所有视频只提供用户两个选项:最近上传(按时间排序)和热门(按观看次数排序)。我可以去最近上传的标签页,逐个点击。然而,Cocomelon 频道有 800 多个视频,这会很耗时间。
好消息是,我是一名工程师,知道如何利用数据构建某些东西。因此,我开始编写代码,收集数据,进行清理、可视化,并获得更多见解。我将分享我使用 R 进行数据分析的历程:从头开始构建一个端到端的解决方案,用于探索流行的 Cocomelon 视频。
注意:虽然我在 R 中编写的示例代码和 Youtube 频道是针对 Cocomelon 的,但它们是我的偏好。你也可以使用 Python 或 Rust 的数据分析工具进行编写,我将展示如何从 Youtube 获取数据适用于其他频道。
如何使用 R 获取 Youtube 数据
数据源总是任何数据项目的起点。我已经进行了几次尝试来达到最终解决方案。
我首先在 Google 上搜索了术语:“Cocomelon 的 Youtube 观看统计”,它显示了一些关于频道的统计数据,但没有覆盖每个视频的更详细数据。这些网站广告泛滥,网络爬虫可能会很困难。
然后我查看了 Kaggle 上的公共数据集,像 CC0 数据集中的 Trending YouTube Video Statistics 可能是一个不错的选择。然而,在探索数据集后,我发现了两个问题:
-
数据集中不包含 Cocomelon
-
内容是几年前获取的,需要我想要搜索的更新视频。
我唯一的选择是直接从 Youtube 拉取最新数据。这里还有两个选项:
-
网络爬虫:我可以设置一个爬虫或在 GitHub 上找到一个项目直接使用。我的担忧是,如果爬虫过于激进,可能会封锁我的 Youtube 账户。而且爬虫对于从众多视频中拉取数据并不是很高效。
-
Youtube API: 我最终找到了这个解决方案。它高效且提供一些基本的视频统计信息:观看次数和点赞数。我们可以进一步利用这些信息来构建我们的数据分析项目。
将 Youtube 数据加载到 R 数据框
获取 Youtube API 密钥以拉取数据
Youtube API 允许你从 Youtube 拉取数据。你首先需要访问 console.cloud.google.com/apis
,然后使用 API 密钥“创建凭据”。默认密钥没有限制;你可以将 API 密钥仅限于 Youtube 使用。
Google Cloud 创建凭据 | 作者图片
使用 R 获取 Youtube 频道播放列表
一旦你有了 API 密钥,请参考 Youtube 数据 API 获取更多关于支持的潜在数据的参考。为了在查询阶段检查 API,我们可以使用 Postman 等工具或直接复制完整 URL。
例如,我们想拉取 Cocomelon 的频道信息;然而,我通过检查其 URL 没有找到其频道 id,但通过一些谷歌搜索找到了它。
https://www.youtube.com/channel/UCbCmjCuTUZos6Inko4u57UQ
现在我们可以使用频道 id 来构建 GET 方法,并将 API 密钥填入密钥字段:
https://www.googleapis.com/youtube/v3/channels?part=snippet,contentDetails,statistics&id=UCbCmjCuTUZos6Inko4u57UQ&key=
从返回的 JSON 中,最关键的信息是播放列表信息,它进一步告诉我们所有视频的情况。
"contentDetails": {
"relatedPlaylists": {
"likes": "",
"uploads": "UUbCmjCuTUZos6Inko4u57UQ"
}
}
由于新采用了分页,每页最多 50 项,调用 playlistItems
将需要时间才能达到最终列表。我们需要使用当前的令牌来检索下一页,直到找不到下一页为止。我们可以在 R 中将所有内容整合在一起。
library(shiny)
library(vroom)
library(dplyr)
library(tidyverse)
library(httr)
library(jsonlite)
library(ggplot2)
library(ggthemes)
library(stringr)
key <- "to_be_replace"
playlist_url <-
paste0(
"https://www.googleapis.com/youtube/v3/playlistItems?part=snippet,contentDetails,status&maxResults=50&playlistId=UUbCmjCuTUZos6Inko4u57UQ&key=",
key
)
api_result <- GET(playlist_url)
json_result <- content(api_result, "text", encoding = "UTF-8")
videos.json <- fromJSON(json_result)
videos.json$nextPageToken
videos.json$totalResults
pages <- list(videos.json$items)
counter <- 0
while (!is.null(videos.json$nextPageToken)) {
next_url <-
paste0(playlist_url, "&pageToken=", videos.json$nextPageToken)
api_result <- GET(next_url)
print(next_url)
message("Retrieving page ", counter)
json_result <- content(api_result, "text", encoding = "UTF-8")
videos.json <- fromJSON(json_result)
counter <- counter + 1
pages[[counter]] <- videos.json$items
}
## Combine all the dataframe into one
all_videos <- rbind_pages(pages)
## Get a list of video
videos <- all_videos$contentDetails$videoId
all_videos
应该会给我们所有视频的字段。我们在这个阶段只关心 videoId,这样我们才能获取每个视频的详细信息。
迭代视频列表并获取每个视频的数据
一旦所有视频都存储在一个向量中,我们可以复制类似于播放列表的处理过程。这次会更容易,因为我们不需要处理分页。
在这个阶段,我们会更关注最终从视频 API 调用中提取的数据。我选择了那些用于后续数据分析和可视化的。为了节省再次提取数据的时间,最好将数据持久化到 CSV 文件中,这样我们就不必多次运行 API 调用了。
videos_df = data.frame()
video_url <-
paste0(
"https://www.googleapis.com/youtube/v3/videos?part=contentDetails,id,liveStreamingDetails,localizations,player,recordingDetails,snippet,statistics,status,topicDetails&key=",
key
)
for (v in videos) {
a_video_url <- paste0(video_url, "&id=", v)
print(v)
print(a_video_url)
api_result <- GET(a_video_url)
json_result <- content(api_result, "text", encoding = "UTF-8")
videos.json <- fromJSON(json_result, flatten = TRUE)
# colnames(videos.json$items)
video_row <- videos.json$items %>%
select(
snippet.title,
snippet.publishedAt,
snippet.channelTitle,
snippet.thumbnails.default.url,
player.embedHtml,
contentDetails.duration,
statistics.viewCount,
statistics.commentCount,
statistics.likeCount,
statistics.favoriteCount,
snippet.tags
)
videos_df <- rbind(videos_df, video_row)
}
write.csv(videos_df, "~/cocomelon.csv", row.names=TRUE)
在 R 中探索 Cocomelon YouTube 视频数据
数据已为我们下一阶段探索 Cocomelon YouTube 视频做好准备。现在是进行一些清理并创建可视化以展示发现的结果的时候了。
默认的对象数据类型在后续排序中效果不佳,因此我们需要将一些对象转换为浮点数或日期类型。
videos_df <- videos_df %>% transform(
statistics.viewCount = as.numeric(statistics.viewCount),
statistics.likeCount = as.numeric(statistics.likeCount),
statistics.favoriteCount = as.numeric(statistics.favoriteCount),
snippet.publishedAt = as.Date(snippet.publishedAt)
)
最受欢迎的 5 个 Cocomelon 视频是什么?
这部分很简单。我们需要选择感兴趣的字段,然后按字段 viewCount
降序排序视频。
videos_df %>%
select(snippet.title, statistics.viewCount) %>%
arrange(desc(statistics.viewCount)) %>% head(5)
# Output:
# snippet.title statistics.viewCount
#1 Bath Song | CoComelon Nursery Rhymes & Kids Songs 6053444903
#2 Wheels on the Bus | CoComelon Nursery Rhymes & Kids Songs 4989894294
#3 Baa Baa Black Sheep | CoComelon Nursery Rhymes & Kids Songs 3532531580
#4 Yes Yes Vegetables Song | CoComelon Nursery Rhymes & Kids Songs 2906268556
#5 Yes Yes Playground Song | CoComelon Nursery Rhymes & Kids Songs 2820997030
对于你之前观看过 Cocomelon 视频的人来说,看到“Bath Song”、“Wheels on the Bus”和“Baa Baa Black Sheep”排名前三并不意外。这与 Cocomelon 在 YouTube 上的 popular
标签相匹配。此外,“Bath Song”的播放次数比第二名“Wheels on the Bus”多 20% 以上。我可以看出许多幼儿在洗澡时遇到困难,让孩子们观看这个视频可以让他们知道如何洗澡,并安慰他们让他们平静下来。
我们还创建了一个包含前 5 个视频的条形图:
ggplot(data = chart_df, mapping = aes(x = reorder(snippet.title, statistics.viewCount), y = statistics.viewCount)) +
geom_bar(stat = "identity",fill="lightgreen") +
scale_x_discrete(labels = function(x) str_wrap(x, width = 16)) +
theme_minimal()
最受欢迎的 5 个 Cocomelon 视频 | 图片作者
观看次数和点赞数之间的相关性是什么?
观看次数和点赞数之间是否存在相关性:视频是否更有可能因观看次数多而获得点赞?
我们可以进一步用数据证明这一点。首先,标准化 viewCount
和 likeCount
以便更好地进行可视化。其次,我们还计算了自视频上传以来的天数,以获取流行视频的创建时间。
chart_df <- videos_df %>%
mutate(
views = statistics.viewCount / 1000000,
likes = statistics.likeCount / 10000,
number_days_since_publish = as.numeric(Sys.Date() - snippet.publishedAt)
)
ggplot(data = chart_df, mapping = aes(x = views, y = likes)) +
geom_point() +
geom_smooth(method = lm) +
theme_minimal()
cor(chart_df$views, chart_df$likes, method = "pearson")
## 0.9867712
Cocomelon 视频观看次数和点赞数的相关性 | 图片作者
相关系数为 0.98,非常高的相关性:视频的观看次数越多,获得点赞的可能性越大。令人着迷的是,只有六个视频的观看次数超过 20 亿:家长和孩子们喜欢这六个视频,并且可能会观看很多次。
我们可以进一步绘制热门视频,并发现最热门的视频,年龄在 1500–2000 天之间,显示这些视频大约在 2018 或 2019 年制作。
按观看次数计算的发布天数 | 作者提供的图片
如何检查新的热门 Cocomelon 视频?
热门视频很容易获取。然而,4、5 年前制作的热门视频由于大量的每日视频仍然可能保持热门。
怎么样找到新的 Cocomelon 视频的观看次数?由于我们只能从 Youtube API 拉取当前状态下的观看次数,我们需要在几天之间从 API 拉取数据,暂时存储数据。
f1 <- read_csv("~/cocomelon_2023_2_28.csv")
df2 <- read_csv("~/cocomelon_2023_3_2.csv")
df1<- df1 %>% transform(
statistics.viewCount = as.numeric(statistics.viewCount)
)
df2<- df2 %>% transform(
statistics.viewCount = as.numeric(statistics.viewCount),
snippet.publishedAt = as.Date(snippet.publishedAt)
)
df1 <- df1 %>% select(snippet.title,
statistics.viewCount)
df2 <- df2 %>% select(snippet.title,
snippet.publishedAt,
statistics.viewCount)
# Join data by snippet.title
joined_df <- inner_join(df1, df2, by = 'snippet.title')
joined_df <- joined_df %>%
mutate(
view_delta = statistics.viewCount.y - statistics.viewCount.x,
number_days_since_publish = as.numeric(Sys.Date() - snippet.publishedAt)
)
# Recent Video uploaded within 200 days and top 5 of them by view delta
chart_df <- joined_df %>%
filter(number_days_since_publish<=200) %>%
select(snippet.title, view_delta) %>%
arrange(desc(view_delta)) %>% head(5)
ggplot(data = chart_df,
mapping = aes(
x = reorder(snippet.title, view_delta),
y = view_delta
)) +
geom_bar(stat = "identity", fill = "lightblue") +
scale_x_discrete(
labels = function(x)
str_wrap(x, width = 16)
) +
theme_minimal()
# Output
# snippet.title view_delta
#1 🔴 CoComelon Songs Live 24/7 - Bath Song + More Nursery Rhymes & Kids Songs 2074257
#2 Yes Yes Fruits Song | CoComelon Nursery Rhymes & Kids Songs 1709434
#3 Airplane Song | CoComelon Nursery Rhymes & Kids Songs 977383
#4 Bingo's Bath Song | CoComelon Nursery Rhymes & Kids Songs 951159
#5 Fire Truck Song - Trucks For Kids | CoComelon Nursery Rhymes & Kids Songs 703467
新的热门 Cocomelon 视频 | 作者提供的图片
顶级热门视频是 🔴 CoComelon Songs Live 24/7。这个视频展示了家长可以让孩子们自动轮播视频而无需明确切换视频。其他视频也展示了潜在的好单曲,值得推荐。
最后的想法
在 Youtube 上有很多适合孩子观看的视频。Cocomelon 有许多视频,我希望在孩子每天允许的观看时间内展示好的视频。寻找这些热门视频对数据专业人士来说是一次迷人的探索。
希望我的帖子对你有帮助。接下来,我将继续我的 R 之旅,并使用 Shiny 构建一个与用户互动的应用程序。
希望这个故事对你有帮助。本文是我工程与数据科学故事系列的一部分,目前包括以下内容:
数据工程与数据科学故事
查看列表 53 个故事!
你也可以 订阅我的新文章 或成为 推荐的 Medium 会员,享受对 Medium 上所有故事的无限访问。
如果有任何问题/评论,请随时在此故事的评论中留言或通过 Linkedin 或 Twitter 直接联系我。
R 工具包用于人力分析:讲述你的员工人数故事
原文:
towardsdatascience.com/r-toolkit-for-people-analytics-telling-your-headcount-story-d872402d4e8b
使用 R 解决的人力分析中的常见挑战
·发布于Towards Data Science ·阅读时间 11 分钟·2023 年 7 月 6 日
–
在人力分析工作中,你经常需要讲述公司员工人数的变化及公司如何演变成今天的样子。我经常看到这被展示为瀑布图,这很好,但在分享逐年变化时,特别是对不太懂技术的观众,可能会变得模糊。
为了满足这个需求,我创建了逐年的迭代图,突出显示每年的一些额外背景信息。这些图表可以添加到 PowerPoint 中逐年展示,或者可以动画化为一个 gif。让我们一起制作吧!
用区域图的 gif 讲述员工人数的变化。图片由作者提供。
挑战:讲述我们的员工人数如何逐年变化,最终达到今天的状态。
步骤:
1. 加载必要的包和数据
2. 计算每月员工人数
3. 为每一年增加相关的背景信息
4. 创建图表
5. 设置自动为每年创建图表
6. 调整主题和图表格式
1. 加载必要的包和数据
对于这个挑战,我们将需要以下包:
-
tidyverse
-
hrbrthemes(用于美化我们的图表)
要创建我们的视觉效果,我们需要一个包含唯一标识符(即员工 ID)、入职日期和离职日期的文件。我将使用模拟数据来进行这个例子(在底部我包含了生成模拟数据的代码,如果你想逐步跟随)。
# load packages
library(tidyverse)
library(hrbrthemes)
# load data
employee_data <- mock_data
# alternatively you could use something like employee_data <- read.csv("input.csv")
顺便提一下,我通常会给我最初读取的数据分配一个变量,然后创建一个新变量用于后续的操作。这并不总是必要的,但在处理大数据集时可以加快速度,这样你就不需要每次修改代码时都重新加载数据。
原始输入数据的概览。图片来源于作者。
为了确保计算正确,我们需要确保 R 知道入职日期和终止日期实际上是日期。一般来说,在 R 中处理日期可能会很麻烦,但为了这个挑战,我们需要将日期列格式化为日期,并确保没有 NA。
df <- employee_data %>%
mutate(Hire.Date = as.Date(Hire.Date, format = "%m/%d/%Y"),
Termination.Date = as.Date(Termination.Date, format = "%m/%d/%Y"))
在我的输入文件中,仍在职的员工终止日期为空,因为他们尚未离职。如果日期列中有空值,R 会变得很挑剔,所以我们要添加一行代码,给这些空值赋一个很远的未来日期。
df <- employee_data %>%
mutate(Hire.Date = as.Date(Hire.Date, format = "%m/%d/%Y"),
Termination.Date = as.Date(Termination.Date, format = "%m/%d/%Y")) %>%
mutate(Termination.Date = if_else(is.na(Termination.Date),
as.Date("2100-12-31"), Termination.Date))
这一行代码表示在终止日期列中只要有 NA/空白,就赋一个很远的未来日期。在这种情况下,选择 2100 年 12 月 31 日。希望到那时我还不在工作。
2. 计算每月人员数量
希望这一步看起来很简单,但我在弄明白这个过程时遇到了不少困难,所以请对自己有耐心。
首先,我们将创建一个包含每个月日期的序列,然后设置一个数据框作为我们每月人员数量的占位符,最后我们将使用sapply
函数计算每个月的人员数量。开始吧!
为每个月创建一个日期序列(例如,2023 年 1 月 1 日、2023 年 2 月 1 日等):
month_seq <- seq(from = min(df$hire_date),
to = max(df$hire_date),
by = "1 month")
这表示从最早的入职日期开始,到最晚的入职日期,按月份生成序列。这给我们每个月的数据留下一个值。它的样子是这样的:
显示每月序列。图片来源于作者。
现在我们要利用这个序列来创建一个起始数据框,然后我们可以在其中添加人员数量。
headcount_data <- data.frame(Date = month_seq)
好的,现在进入棘手的部分。我们将计算headcount_data
数据框中每个日期的在职员工人数。也就是说,计算 2018 年 1 月 1 日、2018 年 2 月 2 日等日期的在职员工数量。
假设我们要计算 2018 年 1 月 1 日的情况。我们需要找出入职日期早于或等于 2018 年 1 月 1 日且终止日期晚于 2018 年 1 月 1 日的员工数量。换句话说,就是已经被雇佣但尚未离职的员工数量。
然后我们只需使用sapply
对headcount_data
中的每个日期进行操作。
headcount_data <- headcount_data %>%
mutate(Active.Employees = sapply(Date, function(x) {
sum(x >= df$hire_date & (is.na(df$termination_date) | x < df$termination_date))
}))
还跟得上吗?如果你已经把所有内容都搞定了,给自己一个大大的鼓励吧!如果你遇到问题,也要为自己走到这一步而感到骄傲,并查看完整代码在这里,看看是否能发现代码中的任何不一致之处。
3. 添加相关背景
这是讲故事部分的开始。根据你对组织的了解程度,你可能需要采访一些主题专家或资深员工。基本上,你希望添加有助于解释 headcount 增减的背景信息。
我想为每一年添加背景(你也可以按月添加),所以我将向 headcount_data 添加一个年份列。
headcount_data <- headcount_data %>%
mutate(year = as.integer(year(Date))
这将为每个日期添加一个年份列:
为每个日期添加了年份列。图片来源:作者。
现在,我们要为每一年添加背景。假设对于 2020 年,我们想添加背景“COVID-19”,并希望在 2020 年的每个月都显示出来。
为此,我们将使用 case_when 来添加一个基于年份的“context”列。
headcount_data <- headcount_data %>%
mutate(context = case_when(
year == 2018 ~ "Context for 2018",
year == 2019 ~ "Context for 2019",
year == 2020 ~ "COVID-19",
TRUE ~ "No additional context"
))
上面的例子中,我们是说对于每一行年份为 2018 的数据,我们希望背景列为“2018 的背景”。你可以为每一年感兴趣的年份添加背景,然后在 TRUE 条件下,可以指定对未在上面指定的年份的背景是什么。
到这个时候,你的 headcount_data 应该看起来像这样:
添加了背景列的数据集。图片来源:作者。
现在进入有趣的部分!我们可以开始绘图了。
4. 创建图表
首先,我们将使用 ggplot 创建一个包含所有数据的基本面积图。我们将把 Date 放在 x 轴上,把 Active.Employees 放在 y 轴上,这样我们就可以看到 headcount 随时间的变化。
headcount_data %>%
ggplot(aes(x = Date, y = Active.Employees)) +
geom_area()
这将给你这个基本图表:
完整数据集的基本面积图。图片来源:作者。
现在我们开始进行一些基本的 zhushing,然后再进行一些更高级的 zhushing:
1. 添加注释
2. 添加标题和副标题
我们将添加包含最终 headcount 和年份的注释(当我们为每一年制作图表时,这会变得更相关)。让我们先将它们分配给变量,以便于每年更新:
# annotations
annotation_ending_year <- max(headcount_data$year)
annotation_ending_headcount <- max(headcount_data$Active.Employees)
# titles
labels_title <- "Our Headcount Story"
labels_subtitle <- last(headcount_data$context)
现在我们将把这些添加到我们的基本图表中:
headcount_data %>%
ggplot(aes(x = Date, y = Active.Employees)) +
geom_area() +
labs(title = labels_title,
subtitle = labels_subtitle) +
annotate("text",
x = max(headcount_data$Date),
y = max(headcount_data$Active.Employees),
label = annotation_ending_headcount,
hjust = -.25)
这将给我们一个基本的图表,并附带一些额外的背景信息:
带有标题和注释的基本图表。图片来源:作者。
既然我们创建了基本图表,我们希望自动为每一年创建一个附加图表。所以将会有一个从 2018 年开始到 2018 年底的图表,一个从 2018 年到 2019 年底的图表,一个从 2018 年到 2020 年底的图表,等等。
5. 自动为每年创建一个图表
我们将使用 for 循环来为数据集中的每一年创建一个图表。
基本上,我们将把数据集中每个独特的年份放到一个名为“years”的向量中。然后对于“years”中的每一年,我们将创建一个数据子集,然后绘制该子集的图表。这听起来可能很混乱,但查看代码可能会更清楚。
首先进行一些设置:
# create a vector for unique years
years <- unique(headcount_data$year)
# empty list for plots to go to
plots <- list()
现在进入循环!这可能看起来很复杂,但一步一步来就好:
# loop over the each year in years and create plots
for (i in 2:length(years)) {
# create subset adding one year at a time
subset_df <- headcount_data %>%
filter(year <= years[i])
# calculations for annotation
annotation_ending_year <- max(subset_df$Date)
annotation_ending_active <- subset_df %>%
filter(Date == ending_year) %>%
select(Active.Employees) %>%
as.numeric()
# create a plot (p) using the subset
p <- subset_df %>%
ggplot(aes(x = Date, y = Active.Employees)) +
geom_area() +
labs(title = labels_title,
subtitle = labels_subtitle) +
annotate("text",
x = max(subset_df$Date),
y = max(subset_df$Active.Employees),
label = ending_active,
hjust = -.25)
# save each plot
ggsave(p,
file = paste("example_plot_", years[i], ".png"),
height = 6, width = 8, units = "in")
}
你现在应该在你的工作目录中有一个名为“example_plot_year”的每年图表。我喜欢每年一个单独的图表,这样我可以把每一个放在幻灯片中,并在大家有问题时暂停。或者,你可以将图表动画化并创建一个 gif,或者使用像ScreenToGif这样的屏幕录制工具,得到这样的效果:
使用 ScreenToGif 合成的图表动画 gif。图片由作者提供。
我们做到了!!!!剩下的就是添加一些样式,以使图表更符合你的品牌,并添加一个矩形来突出显示最近的一年。
6. 调整主题和图表格式
我想做的第一件事是添加一个矩形来突出显示最近的一年。这将帮助观众知道要关注的重点,并且在每个图表中都会更新,这样我们可以在更大的背景下逐年查看。
我们将通过添加另一个“rect”注解层来完成,这看起来会是这样的:
annotate("rect", xmin = , xmax = , ymin = , ymax = )
这是另一个花了我一段时间才调整到我想要的方式,但关键点在于:
X 轴:我希望矩形从给定年份内的第一个(即底部)日期(即我们数据子集中的最大年份)开始,并在给定年份内的最后一个(即顶部)日期(即我们数据子集中的最大年份)结束。因此,对于 2019 年的图表,我们希望矩形从 2019 年 1 月 1 日开始,到 2019 年 12 月 1 日结束。
annotate("rect",
xmin = floor_date(max(subset_df$Date), "year"),
xmax = ceiling_date(max(subset_df$Date), "year")
Y 轴:我希望矩形从 y 轴开始,到该年最终人数以上的位置结束,这样更容易阅读而不会显得拥挤。再看一下 2019 年,我希望矩形从 y 轴开始,并在最终人数 240 之上(+300)的位置结束。
annotate("rect",
xmin = floor_date(max(subset_df$Date), "year"),
xmax = ceiling_date(max(subset_df$Date), "year"),
ymin = -Inf, ymax = ending_active + 300)
样式:最后,我会把框设置为灰色,并将透明度改为 0.1,使其相当透明,你可以看到下面的区域图:
annotate("rect",
xmin = floor_date(max(subset_df$Date), "year"),
xmax = ceiling_date(max(subset_df$Date), "year"),
ymin = -Inf, ymax = ending_active + 300,
alpha = .1, color = "gray", fill = "gray")
限制坐标轴:为了使过渡更平滑,我将对 x 轴和 y 轴设置限制,使每个图表的比例相同。
scale_x_date(breaks = "1 year", date_labels = "%Y",
expand = c(.1,.1),
limits = c(min(headcount_data$Date), max(headcount_data$Date)))
太棒了!我们快完成了,现在我要对主题进行一些更改,然后给自己倒一杯酒。现在是时候发挥你自己的创意了,我的最终效果是这样的:
最终产品!图片由作者提供。
这是我最终的 for 循环代码:
# loop over the each year in years and create plots
for (i in 2:length(years)) {
# create subset adding one year at a time
subset_df <- headcount_data %>%
filter(year <= years[i])
# calculations for annotation
ending_year <- max(subset_df$Date)
ending_active <- subset_df %>%
filter(Date == ending_year) %>%
select(Active.Employees) %>%
as.numeric()
# create a plot (p) using the subset
p <- subset_df %>%
ggplot(aes(x = Date, y = Active.Employees)) +
geom_area(fill = "#457b9d") +
labs(title = "Our Headcount Story",
subtitle = paste(years[i],":", last(subset_df$context)),
x = "", y = "") +
scale_x_date(breaks = "1 year", date_labels = "%Y",
expand = c(.1,.1),
limits = c(min(headcount_data$Date), max(headcount_data$Date))) +
theme_classic(base_family = "Arial") +
theme(plot.title = element_text(size = 24, face = "bold", color = "#457b9d"),
plot.subtitle = element_text(size = 18),
panel.grid.major = element_blank(),
panel.grid.minor = element_blank(),
axis.ticks.y = element_blank(),
axis.text.y = element_blank(),
axis.line.y = element_blank()) +
annotate("text", x = ending_year,
y = ending_active, label = ending_active,
vjust = -1.25, hjust = -.25, color = "#457b9d") +
annotate("rect",
xmin = floor_date(max(subset_df$Date), "year"),
xmax = ceiling_date(max(subset_df$Date), "year"),
ymin = -Inf, ymax = ending_active + 300,
alpha = .1, color = "gray", fill = "gray")
# save each plot
ggsave(p,
file = paste("example_plot_final", years[i], ".png"),
height = 6, width = 8, units = "in")
}
全部完成!
我们现在有了一个动态视图,展示了我们的员工人数如何随着时间的推移而变化,并在副标题中提供了额外的背景。一些未来迭代的想法:使用 gganimate 制作图表,为每年的员工人数添加百分比变化,如果员工人数增加或减少则改变图表颜色,添加增长趋势线的预测,可能性无穷无尽!
你尝试制作了吗?如果是的话,我很想看看你做了什么!
想要更多通用的人员分析资源吗?
我推荐给那些想要开始人员分析职业生涯的人的免费资源。
如果你想要更多这样的资源,并且访问网站上的所有优质内容,可以使用我的链接以每月$5 注册(我将获得少量佣金,但你无需额外支付费用)。
[## 使用我的推荐链接加入 Medium - Jenna Eagleson
阅读 Jenna Eagleson 的每一个故事(以及 Medium 上成千上万其他作者的故事)。你的会员费直接支持…
我的背景是工业组织心理学,我在人员分析领域找到了归属。数据可视化让我的工作生动起来。我喜欢使用 Power BI、R、Tableau 以及我遇到的其他工具进行学习和开发。我很想了解你的经历!可以通过Linkedin或Twitter与我联系。
径向树图:将树图扩展到圆形映射
原文:
towardsdatascience.com/radial-treemaps-extending-treemaps-to-circular-mappings-7b47785191da
了解径向树图并用 Python 创建自己的树图
·发表于 Towards Data Science ·阅读时间 16 分钟·2023 年 12 月 10 日
–
径向树图,作者 Nick Gerend
背景
树图概念
“树图”由 Ben Shneiderman 在 1990 年代初期于马里兰大学提出¹。简而言之,它是一种将层次数据以嵌套矩形的形式高效展示的方法。尽管这一概念很简单,但矩形的排列受美学偏好的影响,因此已经开发出各种排列算法来改善最终布局的外观。
树图原理
给定一个层次结构,树图将层次结构中的每个分支表示为一个矩形,然后用代表子分支的较小矩形进行拼接。树图中的空间是根据数据的特定属性(通常是大小或值)进行划分的,每个矩形的面积对应于该属性的大小,使得比较层次结构中的不同部分变得容易。
树图中 a、b 和 c 组的顺序 -> 每个层级的最大项:(a1)、(a1,b1)、(a1,b1,c1)
为了考虑矩形的排列,以下是一些常见的算法,它们控制树图的构造和最终外观:
-
方形树图² - 通过调整矩形的长宽比,使矩形尽可能接近正方形
-
条形树图³ - 根据数据的层次结构,以水平或垂直方式布置矩形
-
切片和切割⁴ - 交替进行水平和垂直分割,虽然直观,但可能会生成较长的矩形
树图特征
-
比例 - 每个矩形的大小与其代表的数据点成比例,使得快速识别较大和较小的项变得容易
-
线条和颜色 - 边框、边框大小和颜色以及缓冲区的巧妙使用可以划分层次级别,而容器颜色通常用来表示数据的不同维度
-
空间效率 - Treemaps 特别适合可视化大型数据集,因为它们有效利用空间,允许同时显示成千上万的项目,平铺算法确定最终布局
-
顺序 - 当收集的数据独立于已知层次结构时,分组中维度的顺序很重要,因为它决定了层次结构每一级的父子关系
总体而言,Treemap 的优势在于能够以空间高效和视觉吸引的方式展示复杂的层次数据,使其成为任何研究领域中受欢迎的可视化工具。
多态性
层次数据及其作为嵌套多边形和形状的表示已经有各种有用且视觉吸引的实现。我最喜欢的之一是 Voronoi Treemaps⁵,而且我特别喜欢与 Voronoi 形状结合的 3D 映射(如 3D Voronoi Treemap Sphere)的想法。
可能还有许多 Treemap 表现形式等待开发,下面我将介绍我自己实现的径向 Treemap。在撰写这篇文章时,我还发现了一个名为 Krona⁶ 的工具(它似乎有类似的输出风格),这是通过反向图像搜索我自己的径向 Treemap 图表时发现的。
径向 Treemap
灵感
当我创建我的第一个径向 Treemap 时,我并没有考虑实现一种 Treemap 类型,而是一个关于飞行器⁷的数据集。我的目标是将这个数据集呈现成一个喷气发动机的样子,作为一种艺术可视化作品。
“Takeoff” 作者:Nick Gerend (3/11/2021)
我最终得到的东西,最初我称之为“饼图树”图表,但后来我意识到这实际上只是 Treemapping 的一种形式,所以现在我称它为径向 Treemap!
这种类型的 Treemap 特别之处在于其“径向”布局,这种布局在圆形空间中打开了各种有用且有趣的组件。我发现将它与其他类型的径向图表结合以分解层次数据的不同方面特别有趣。
在下一部分,我将讨论构建径向 Treemap 时涉及的组件。
数学、算法与布局
元素
径向 Treemap 的数学基础利用了一些基本元素来确定多边形容器的尺寸:
-
内半径 - 勾勒出容器较短的弯曲边缘
-
外半径 - 勾勒出容器较长的弯曲边缘
-
起始角度 - 勾勒出容器在最小角度处的直边
-
结束角度 - 勾勒出容器在最大角度处的直边
这些容器本质上是沿两个半径分割的饼图或甜甜圈楔形,与传统树图的矩形容器相比显得不规则,但在相对大小比较上保持相同的直观性。
来自树图和径向树图的形状,具有类似的区域
函数
现在我们已经确定了容器的基本要素,接下来我们将进入数学部分,从层次结构第一层的外部父容器的面积开始。假设我们希望对外部容器的形状有一定的灵活性,我们可以为甜甜圈切片实现一个面积函数(func_area):
# area of the outer container:
# > r1: inner radius
# > r2: outer radius
# > start_angle: degrees between 0-360
# > end_angle: degress > start_angle
area = (pi*r2**2-pi*r1**2)*((end_angle-start_angle)/360)
这允许在甜甜圈的中间(原点和内半径之间)和起始角度与结束角度之间留有空隙。这种灵活性鼓励以独特和创造性的方式配置布局,以最大限度地利用当前的用例。可能性无限!
接下来我们需要根据容器的预期方向来确定容器的角点(func_container):
# given 3 of the 4 child container paramters (r1, r2, a1, a2),
# gathered from the parent container, and the area of the child container:
# > r1: inner radius
# > r2: outer radius
# > a1: starting angle
# > a2: ending angle
# > area: area of the child container
# split the current container between two angles at a constant radius
# find the radius:
r2 = sqrt(((area/(a2-a1))+pi*r1**2)/pi)
# split the current container at a specific angle between two radii
# find the angle:
a2 = a1 + (area)/(pi*r2**2-pi*r1**2)
为了实现一个好的默认方向方法,让我们计算两个方向选项的弧长和半径长度,以选择具有最小最大长度的容器方向(我称之为“智能”方法):
# "smart" container orientation method:
# calculate the max lengths between both
# orientation options using the following:
arc_length = (2*pi*r2)*((a2-a1)/360)
radius_length = r2-r1
max_lnegth = max(arc_length, radius_length)
# select the orientation with the smallest max_length
# to avoid skinny polygons
剩下的就是将极坐标转换为笛卡尔坐标进行绘图(func_convertion):
# assuming a starting position of 12 o'clock:
# > ad: angle in degrees
# > ar: angles in radians
# > r: radius
ar = (ad-90)*pi/180
x, y = r*cos(ar), r*sin(ar)
径向树图算法
使用已建立的函数,这里是通用算法:
-
从一组互斥的组开始,具有 1 到多个维度,以及它们的计数或值,这些将决定多边形的面积,例如:({a1,b1,c1}, 12.3), ({a1,b2,c1}, 4.5), ({a2,b1,c2}, 32.3), ({a1,b2,c2}, 2.1), ({a2,b1,c1}, 5.9), ({a3,b1,c1}, 3.5], ({a4,b2,c1}, 3.1)
-
设置配置输入:(位置:{起始角度、结束角度、内半径、外半径、旋转}),(排序:{降序、升序、手动}),(容器方向方法:{交替、向外、绕行、智能、图例}),(分组:{开启、关闭})
-
计算外部容器的面积(func_area)
-
递归计算子容器的位置,利用它们相对于外部容器面积的相对面积百分比作为容器函数(func_container)的输入,配合所选择的容器方向方法
-
用点填充弧段中的多边形边界(更多的点以获得更高的曲线分辨率),并将极坐标转换为笛卡尔坐标以进行绘图
容器方向方法
我开发的原始构建方法恰好在半径分割和同心圆分割之间交替(如上所示的“起飞”信息图),类似于早期的树图,这些树图使用了“切片和切割”算法,在水平和垂直分割之间交替。
这是我目前为径向树图创建的容器方向方法:
-
替代 - 原始的!(类似于切片和骰子的替代方法)
-
向外 - 所有分区都绘制为半径
-
周围 - 所有分区都绘制为同心圆
-
智能 - 为每个选项计算弧长和半径长度,并选择最小的作为防止细长多边形的机制
-
图例 - 层级结构的第一层总是绘制为半径,以对齐相应的图例甜甜圈
还可以选择是否首先对项目进行分组。可以切换扁平化数据(移除层次分组)以按元素最低级的自然顺序排序,为另一个层次的见解提供更多信息(特别是对图例很有用)。
使用上述示例数据的径向树图配置
布局
通过包括中心和楔形空白区域、堆叠和旋转径向树图的灵活性,布局选项是无限的!
外围布局参数:
-
总面积(作为两个或更多径向树图之间的相对度量)
-
布局约束(甜甜圈切片由两个角度和两个半径确定)
-
旋转(围绕中心的旋转)
-
与其他径向树图的相对定位(堆叠等)
径向树图周边示例
图例:
- 补充的径向树图(内部、外部或两侧)(有助于说明不同级别的元素排序)
径向树图图例
可视化扩展(内/外/线性连接):
适用的可视化扩展示例(桑基图,和弦图)
3D 径向树图
径向树图结构可以轻松地在数学上扩展到 3D,并且附带了一个额外的切片和切割平面!
可用于容器化的几何形状从球坐标系统中可以看出:
-
径向距离:r ≥ 0,
-
极角:0° ≤ θ ≤ 180°(0 rad ≤ θ ≤ π rad)
-
方位角:0° ≤ φ < 360°(0 rad ≤ φ < 2π rad)
就 3D 径向树图输入而言,这是每个表面可用空间的投影:
3D 径向树图容器边界
方便的是,3D 的通用算法与 2D 相同,调整面积过渡到体积的效果,并解决半径、极角和方位角的三种可能方向。以下是一个简单的 3D 径向树图:
由 Nick Gerend 渲染的 3D 树图,使用 Autodesk Fusion 360
接下来,我将展示一个生成 2D 径向树图可视化的 Python 实现。3D 版本在开发计划中!
Python 实现
我已经通过我的 vizmath 包在 PyPI 上提供了我径向树图算法的初步实现。以下是一个使用示例:
from vizmath import rad_treemap as rt # pip install vizmath==0.0.9
import pandas as pd
# using the example data from above:
data = [
['a1', 'b1', 'c1', 12.3],
['a1', 'b2', 'c1', 4.5],
['a2', 'b1', 'c2', 32.3],
['a1', 'b2', 'c2', 2.1],
['a2', 'b1', 'c1', 5.9],
['a3', 'b1', 'c1', 3.5],
['a4', 'b2', 'c1', 3.1]]
df = pd.DataFrame(data, columns = ['a', 'b', 'c', 'value'])
# create a rad_treemap object
# > df: DataFrame with 1 or more categorical columns of data
# and an optional 'value' column for the areas
# (otherwise groups counts are used for areas)
# > groupers: group-by columns
# > value: optional value column
# > r1, r2: inner and outer radius positions
# > a1, a2: start and end angle positions
# > rotate_deg: overall rotation around the center
# > mode: container orientation method
# > other options: 'points', 'default_sort', 'default_sort_override',
# 'default_sort_override_reversed', 'mode', 'no_groups', 'full'
rt_1 = rt(df=df, groupers=['a','b','c'], value='value', r1=0.5, r2=1,
a1=0, a2=180, rotate_deg=-90, mode='alternate')
# plot the Radial Treemap
rt_1.plot_levels(level=3, fill='w')
使用组值作为区域的 Radial Treemap 通过 vizmath 渲染,使用 Matplotlib
让我们来看看 Radial Treemap 算法的输出:
-
level - 层级:从 1 到 N 层
-
group - 代表树上的每个节点:例如,组 {a1,b1,c1} 属于组 {a1,b1},而 {a1,b1} 属于 {a1}
-
count - 组的计数:下方可以看到在第 1 层(最高层),组 {a2} 包含 2 个项目
-
value - 组的值(如果指定):可以使用提供的数字来表示大小,而不是使用组中项目的计数
-
层级排名 - 项目在其组中的排名,按其值(如果值不可用则按计数)从高到低排序:1 到 N
-
总体排名 - 项目在所有组中的总体排名,按其值(如果值不可用则按计数)从高到低排序:1 到 N
-
x, y - 布局中点的笛卡尔 2D 坐标
-
path - 描述封闭多边形的整数有序集合,与 Radial Treemap 中每个 (x, y) 点相结合,用于每个组:1 到 N(由‘points’参数指定)
# sample the Radial Treemap DataFrame
rt_1.to_df()[['level','group','count','value',
'level_rank','overall_rank','x','y','path']].head()
Radial Treemap DataFrame
最后,让我们看看一个忽略组值的基于计数的版本是什么样的。
# set 'value' to None or just leave it out since None is the default
# doing this sets the areas equal to the group counts
# in this case, each count will be one since there are no duplicates
rt_2 = rt(df=df, groupers=['a','b','c'], value=None, r1=0.5, r2=1,
a1=0, a2=180, rotate_deg=-90, mode='alternate')
# plot the Radial Treemap
rt_2.plot_levels(level=3, fill='w')
使用组计数作为区域的 Radial Treemap 通过 vizmath 渲染,使用 Matplotlib
Tableau Public 实现
在这一部分,我将展示如何在 Tableau Public (v 2023.3.0) 中实现我的 Radial Treemap 可视化,并介绍一些有趣的交互功能。
要开始,请向我们之前的示例中添加更多的组和值,并将数据输出到 csv 文件中以供 Tableau Public 使用。首先,创建一个包含 3 个类别列和一个数值列的 DataFrame:
import pandas as pd
data = [
['a1', 'b1', 'c1', 9.3],
['a1', 'b1', 'c2', 6.7],
['a1', 'b1', 'c3', 2.4],
['a1', 'b2', 'c1', 4.5],
['a1', 'b2', 'c2', 3.1],
['a2', 'b1', 'c1', 5.9],
['a2', 'b1', 'c2', 32.3],
['a2', 'b1', 'c3', 12.3],
['a2', 'b1', 'c4', 2.3],
['a2', 'b2', 'c1', 9.1],
['a2', 'b2', 'c2', 17.3],
['a2', 'b2', 'c3', 6.7],
['a2', 'b2', 'c4', 4.4],
['a2', 'b2', 'c5', 11.3],
['a3', 'b1', 'c1', 7.5],
['a3', 'b1', 'c2', 9.5],
['a3', 'b2', 'c3', 17.1],
['a4', 'b2', 'c1', 5.1],
['a4', 'b2', 'c2', 2.1],
['a4', 'b2', 'c3', 11.1],
['a4', 'b2', 'c4', 1.5]]
df = pd.DataFrame(data, columns = ['a', 'b', 'c', 'value'])
接下来,我们将使用 vizmath 创建 Radial Treemap 图表和图例,将两者合并到一个文件中,并将绘图信息输出到 csv:
from vizmath import rad_treemap as rt
import os
# Radial Treemap chart object
rt_obj = rt(df=df, groupers=['a','b','c'], value='value',
r1=0.5, r2=1, a1=0, a2=180, rotate_deg=-90 ,mode='legend')
rt_df = rt_obj.to_df()
rt_df['type'] = 'chart'
# Radial Treemap legend object
rt_legend_obj = rt(df=df, groupers=['a','b','c'], value='value',
r1=1.04, r2=1.09, a1=0, a2=180, rotate_deg=-90 ,mode='legend',
no_groups=True)
rt_legend_df = rt_legend_obj.to_df()
rt_legend_df['type'] = 'legend'
# export the drawing data
df_out = pd.concat([rt_df, rt_legend_df], axis=0)
df_out.to_csv(os.path.dirname(__file__) + '/radial_treemap.csv',
encoding='utf-8', index=False)
使用 文本文件 选项将文件导入 Tableau,导航到 Sheet 1,并创建这些参数和计算字段,我们将使用它们绘制图表和图例:
创建参数(从左侧“数据”标签下的汉堡菜单中选择“创建参数…”):
[Chart Level]: {整数, 范围, 最小值: 1, 最大值: 3, 步长: 3}
[Legend Level]: {整数, 范围, 最小值: 1, 最大值: 3, 步长: 3}
创建计算字段(从相同菜单下选择“创建计算字段…”):
[rad_treemap]: 如果 [type] = ‘chart’ 且 [Level] = [Chart Level],则 MAKEPOINT([Y],[X]) 否则为 null 结束
[rad_treemap_legend]: 如果 ([type] = ‘legend’ 且 [Level] = [Legend Level]),则 MAKEPOINT([Y],[X]) 否则为 null 结束
[rad_treemap_lines]: 如果 [type] = ‘chart’ 且 [Level] <= [Chart Level],则 MAKEPOINT([Y],[X]) 否则为 null 结束
首先将***[radial_treemap]拖动到标记下的详细信息中,以生成第一个地图层,然后右键点击地图区域,选择背景层***来调整这些选项:
-
取消选择所有背景地图层(基础、土地覆盖等)。
-
现在在地图区域右键单击,选择地图选项并取消选择所有选项。
关闭背景层并继续以下步骤:
-
将***[Group]拖到标记下的详细信息***中。
-
在标记下拉菜单中选择多边形(如果此时看起来有些奇怪也不用担心)。
-
将***[Path]拖动到标记下的路径中,右键点击现在的SUM(Path)并选择维度***。
-
将***[Value]拖动到颜色中,并重复将其转换为维度***的过程。
-
在颜色下选择“编辑颜色…”,并配置以下选项:{反向,高级:(起始:0,结束:10)}
-
点击确定,然后在颜色下将不透明度调整为 50%。
现在,径向树图的结构应该可见。让我们添加另一个层级,以使用层级的第一级项来增强颜色。首先添加一些新的计算列:
[Label]:replace(replace(replace([Group],”’”,’’),’(‘,’’),’)’,’’)
[Level 1]:split([Label],’,’,1)
[Level 2]:split([Label],’,’,2)
[Level 3]:split([Label],’,’,3)
现在让我们使用***[Level 1]***进行着色:
-
将***[radial_treemap]***拖动到地图区域,弹出窗口将显示:添加标记层 - 将该图标拖入此处以创建新的地图层。
-
重复上述步骤,但现在使用***[Level 1]作为颜色***。
-
在颜色下选择黑色边框,将不透明度设置为 50%。
让我们通过添加一些不同厚度的线条来总结图表部分,以指示层级边界的位置:
-
使用***[rad_treemap_lines]作为地图层,线条作为标记下拉菜单中的图表类型,并将颜色***设置为中等黑色,重复前面的步骤。
-
将***[Level]拖动到标记下的大小***,并转换为维度和离散。
-
在图表右侧的大小部分标记为Level,从容器右上角显示的下拉菜单中选择“编辑大小…”。
-
选择反向选项,点击确定,然后右键单击图表右下角的空值图标,选择隐藏指示器以隐藏空值标签。
现在图表部分已就位,应与下图类似:
让我们添加一个图例来补充图表:
- 使用***[rad_treemap_legend]***添加两个图表层,重复之前的所有步骤。
为了完成可视化,让我们添加一些标签层。首先添加这些参数和计算列来定位标签:
创建参数:
[Show Labels Chart]: {布尔值,别名:(True: Yes,False: No)}
[Show Labels Legend]: {布尔值,别名:(True: Yes,False: No)}
创建计算列:
[point_angle]: atan2([X],[Y])*180/pi() — 90
[group_angle]: {固定 [Type],[Group]:avg([point_angle])}
[point_radius]: [X]/cos([point_angle]*pi()/180)
[group_radius_min]: {固定 [Type],[Group]:min([point_radius])}
[group_radius_max]: {固定 [Type],[Group]:max([point_radius])}
[group_radius]: ([group_radius_max]-[group_radius_min])/2+[group_radius_min]
[chart_group_legend]: 如果 [Type] = ‘chart’ 且 [Level] = [Chart Level] 且 [Show Labels Chart] 则
MAKEPOINT(
-[group_radius]*sin(([group_angle])*pi()/180),
[group_radius]*cos(([group_angle])*pi()/180)
) 否则为空结束
[legend_group_legend]: 如果 [Type] = ‘legend’ 且 [Level] = [Legend Level] 且 [Show Labels Legend] 则
MAKEPOINT(
-[group_radius]*sin(([group_angle])*pi()/180),
[group_radius]*cos(([group_angle])*pi()/180)
) 否则为空结束
现在我们将添加最后两层以完成 Radial Treemap:
-
将 [chart_group_legend] 作为地图图层添加,并将 [Group] 添加到 Marks 下的 Detail。
-
将图表类型更改为 Circle,并将 [Label] 拖到 Marks 下的 Label。
-
将 Color 调整为 50%不透明的白色,并没有边框或光晕,将滑块拖动到 Size 的中心右侧。
-
在 Label 下,点击 […] 菜单旁的 Text,在对话框中选择文本,将大小更改为 {8,粗体},然后点击 OK。
-
返回主 Label 菜单,选择 Allow labels to overlap other marks,并将 Alignment 调整为 {center,center}。
-
目前将参数 [Show Labels Chart] 切换为 False,并重复上述步骤使用 [legend_group_legend] 向图例添加标签。
要完成 Sheet 1,通过将 [Label] 拖到 Marks 下的 Tooltip 中的 Attribute,并右键点击该 pill 选择 Attribute,将 [ATTR(Label)] 添加到 Tooltips 中。以相同方式添加 [ATTR(Items)] 和 [ATTR(Value)]。
为了帮助交互式探索 Radial Treemap 中的数据,让我们创建一个简单的表格条形图。
-
使用底部面板上的第一个加号创建新工作表,生成 Sheet 2。
-
在新工作表中,将 [Level 1]、[Level 2]、[Level 3] 和 [Label] 拖到 Rows。
-
现在将 [Count] 拖到 Rows 并更改为 Dimension 和 Discrete。
-
对 [Value] 进行相同操作,将图表类型更改为 Bar,并将 [Value] 拖到 Marks 下的 Color 和 Size。
-
对于 [Value] 使用与之前工作表相同的颜色方案,并添加 80%不透明度的黑色边框。
-
右键点击列并选择 Rename,将 [Count] 重命名为 [Items]。
最后,将两个工作表汇总到仪表盘中。在创建仪表盘并添加工作表后,在仪表盘顶部菜单中的操作下设置一个操作。点击添加操作下拉菜单,选择高亮显示。在目标高亮显示下选择选择字段并选择***[标签]和[ATTR(标签)]字段。最后在右侧的运行操作于菜单下选择悬停***选项,现在当鼠标悬停在表格或图表中的每一层级上时,整个仪表盘将高亮显示!
添加参数到仪表盘并以有序的方式进行定位后,这里是我们在 Tableau Public 上的新仪表盘:
结论
在这篇文章中,我简要介绍了树图的历史以及我称之为“径向树图”的内容,这是一种我开发的可视化工具,用于检查循环布局中的层级关系,提供了在甜甜圈切片、堆叠、图例和与其他径向图表类型的协同方面的灵活性。它可以以多种方式使用,从数据中得出新的见解,希望你发现这种可视化技术充满启发性和潜力!
如果你对其他径向图表类型感兴趣,查看我的多弦图:
背景
towardsdatascience.com
如果你发现任何有趣或专业的使用案例,请告诉我,谢谢阅读!
参考文献
本文中的所有图像均由作者创建,除非另有说明。
[1] 本·施奈德曼, “使用树图的树状可视化:二维空间填充方法” (1992),《ACM 图形学报告》
[2] 马克·布鲁尔斯,凯斯·惠辛,贾尔克·J·范·维克,“方形树图” (2000),《数据可视化 2000:欧洲图形学和 IEEE TCVG 联合会议论文集》,荷兰阿姆斯特丹,2000 年 5 月 29-30 日
[3] 本杰明·贝德森,本·施奈德曼,马丁·瓦滕贝格,“有序和量子树图:有效利用二维空间显示层级结构” (2002),《ACM 图形学报告》
[4] 本·施奈德曼,马丁·瓦滕贝格,“有序树图布局” (2001),《INFOVIS》第 73-78 页
[5] 迈克尔·巴尔泽,奥利弗·德伊森,“Voronoi 树图” (2005),IEEE 信息可视化研讨会
[6] 布莱恩·昂多夫,尼古拉斯·伯格曼,亚当·菲利皮,“在网页浏览器中的交互式宏基因组可视化”(2011 年),BMC 生物信息学
[7] 联邦航空管理局,“飞机登记数据库”(2020 年),美国运输部
RAG:如何与您的数据交流
详细指南:如何使用 ChatGPT 分析客户反馈
·
关注 发表在 Towards Data Science ·21 分钟阅读·2023 年 11 月 11 日
–
由 DALL-E 3 提供的图像
在我的以前的文章中,我们讨论了如何使用 ChatGPT 进行主题建模。我们的任务是分析不同酒店连锁的客户评论,并确定每家酒店提到的主要主题。
通过这样的主题建模,我们知道每个客户评论的主题,可以轻松按主题筛选并深入了解。然而,在现实生活中,拥有能够涵盖所有可能用例的详尽主题集是不可能的。
例如,这是我们从客户反馈中先前识别出的主题列表。
这些主题可以帮助我们获得客户反馈的高层次概述,并进行初步预筛选。但是,假设我们想了解客户对健身房或早餐饮品的看法。在这种情况下,我们将需要自己从“酒店设施”和“早餐”主题中浏览相当多的客户反馈。
幸运的是,LLMs 可以帮助我们进行这种分析,节省大量浏览客户评论的时间(尽管自己倾听客户的声音仍可能是有帮助的)。在本文中,我们将讨论这些方法。
我们将继续使用 LangChain(最流行的 LLM 应用框架之一)。你可以在我之前的文章中找到 LangChain 的基本概述。
幼稚的方法
获取与特定主题相关的评论最直接的方法就是在文本中寻找一些特定的词汇,比如“健身房”或“饮料”。在 ChatGPT 出现之前,我曾多次使用这种方法。
这种方法的问题是相当明显的:
-
你可能会得到很多不相关的关于附近健身房或酒店餐厅酒精饮料的评论。这种过滤器不够具体,不能考虑上下文,因此你会有很多假阳性。
-
另一方面,你可能也无法获得足够好的覆盖范围。人们往往对相同的事物使用略微不同的词汇(例如,饮料、茶点、饮品、果汁等)。可能还会有拼写错误。如果你的客户说不同的语言,这个任务可能会变得更加复杂。
因此,这种方法在精准度和召回率方面都有问题。它会给你对问题的粗略理解,但能力有限。
另一种潜在的解决方案是使用与主题建模相同的方法:将所有客户评论发送给 LLM,并让模型确定它们是否与我们的兴趣主题相关(早餐饮品或健身房)。我们甚至可以要求模型总结所有客户反馈并提供结论。
这种方法可能会工作得很好。然而,它也有其局限性:每次你想深入探讨一个特定话题时,你需要将所有文档发送给 LLM。即使基于我们定义的主题进行高水平过滤,传递给 LLM 的数据量也可能相当大,而且成本也会相当高。
幸运的是,还有另一种解决这个任务的方法,它被称为 RAG。
检索增强生成
我们有一组文档(客户评论),我们希望提出与这些文档内容相关的问题(例如,“客户喜欢早餐的哪些方面?”)。正如我们之前讨论的,我们不想将所有客户评论都发送给 LLM,因此我们需要一种方法来定义最相关的评论。然后,任务将变得非常简单:将用户问题和这些文档作为上下文传递给 LLM,就可以了。
这种方法称为检索增强生成或 RAG。
作者提供的方案
RAG 的流水线包括以下几个阶段:
-
加载文档从我们拥有的数据源。
-
将文档分割为更容易进一步使用的块。
-
存储: 向量存储通常用于此用例,以有效处理数据。
-
检索与问题相关的文档。
-
生成是将问题和相关文档传递给 LLM 并获得最终答案**。**
您可能已经听说 OpenAI 本周推出了助理 API,它可以为您完成所有这些步骤。但我认为值得通过整个过程来理解它的工作原理及其特殊性。
因此,让我们逐步了解所有这些阶段。
加载文档
第一步是加载我们的文档。LangChain 支持不同类型的文档,例如CSV或JSON。
您可能会想知道使用 LangChain 处理这些基本数据类型的好处是什么。毫无疑问,您可以使用标准 Python 库解析 CSV 或 JSON 文件。但我建议使用 LangChain 数据加载器 API,因为它返回包含内容和元数据的文档对象。稍后使用 LangChain 文档会更容易。
让我们看看一些更复杂的数据类型的例子。
我们经常需要分析网页内容,因此必须处理 HTML。即使您已经掌握了BeautifulSoup库,您可能会发现BSHTMLLoader也很有帮助。
与 LLM 应用相关的 HTML 的有趣之处在于,很可能您需要对其进行大量预处理。如果您使用浏览器检查工具查看任何网站,您会注意到比网站上看到的文本要多得多。它用于指定布局、格式和样式等。
作者提供的图片,LangChain 文档
在大多数实际情况下,我们不需要将所有这些数据传递给 LLM。一个站点的整个 HTML 很容易超过 200K 标记(只有用户看到的文本约为 10-20%),因此将其适应上下文大小将是一项挑战。而且,这些技术信息可能会让模型的工作变得更加困难。
因此,从 HTML 中提取文本并将其用于进一步分析是相当标准的做法。要做到这一点,你可以使用下面的命令。结果,你将得到一个文档对象,其中网页内容在page_content
参数中。
from langchain.document_loaders import BSHTMLLoader
loader = BSHTMLLoader("my_site.html")
data = loader.load()
另一个常用的数据类型是 PDF。我们可以解析 PDF,例如使用 PyPDF 库。让我们从 DALL-E 3 论文中加载文本。
from langchain.document_loaders import PyPDFLoader
loader = PyPDFLoader("https://cdn.openai.com/papers/DALL_E_3_System_Card.pdf")
doc = loader.load()
在输出中,你会得到一组文档 — 每页一个文档。在元数据中,source
和page
字段都会被填充。
因此,正如你所见,LangChain 允许你处理广泛的不同文档类型。
让我们回到我们最初的任务。在我们的数据集中,每个酒店都有一个单独的.txt 文件,其中包含顾客的评论。我们需要解析目录中的所有文件并将它们整合在一起。我们可以使用DirectoryLoader
来完成这个任务。
from langchain.document_loaders import TextLoader, DirectoryLoader
text_loader_kwargs={'autodetect_encoding': True}
loader = DirectoryLoader('./hotels/london', show_progress=True,
loader_cls=TextLoader, loader_kwargs=text_loader_kwargs)
docs = loader.load()
len(docs)
82
我们的文本不是标准的 UTF-8 编码,所以我还使用了'autodetect_encoding': True
。
结果,我们得到了文档列表 — 每个文本文件一个文档。我们知道每个文档由独立的客户评论组成。与其处理酒店所有顾客评论的大文本,我们更有效地使用较小的块来处理。因此,我们需要分割我们的文档。让我们继续下一阶段,详细讨论文档分割。
文档分割
下一步是分割文档。也许你会想为什么我们需要这样做。文档通常很长,涵盖多个主题,例如 Confluence 页面或文档。如果我们将这样的长文本传递给 LLMs,我们可能会面临以下问题:要么 LLM 被无关信息分散注意力,要么文本不适合上下文大小。
因此,为了有效地处理 LLMs,值得从我们的知识库(文档集合)中定义最相关的信息,并仅将此信息传递给模型。这就是为什么我们需要将文档分割成较小块的原因。
通常用于一般文本的最常见技术是递归字符分割。在 LangChain 中,它是由RecursiveCharacterTextSplitter
类实现的。
让我们尝试理解它是如何工作的。首先,你需要定义一个优先级列表用于分割器(默认为["\n\n", "\n", " ", ""]
)。然后,分割器会逐个字符地遍历这个列表,并尝试将文档分割成足够小的块。这意味着该方法试图保持语义上紧密相关的部分在一起(段落、句子、单词),直到我们需要分割它们以达到期望的块大小。
让我们使用Python 之禅看看它是如何工作的。这段文字有 824 个字符,139 个单词和 21 个段落。
如果你执行
import this
,你可以看到 Python 之禅。
zen = '''
Beautiful is better than ugly.
Explicit is better than implicit.
Simple is better than complex.
Complex is better than complicated.
Flat is better than nested.
Sparse is better than dense.
Readability counts.
Special cases aren't special enough to break the rules.
Although practicality beats purity.
Errors should never pass silently.
Unless explicitly silenced.
In the face of ambiguity, refuse the temptation to guess.
There should be one -- and preferably only one --obvious way to do it.
Although that way may not be obvious at first unless you're Dutch.
Now is better than never.
Although never is often better than *right* now.
If the implementation is hard to explain, it's a bad idea.
If the implementation is easy to explain, it may be a good idea.
Namespaces are one honking great idea -- let's do more of those!
'''
print('Number of characters: %d' % len(zen))
print('Number of words: %d' % len(zen.replace('\n', ' ').split(' ')))
print('Number of paragraphs: %d' % len(zen.split('\n')))
# Number of characters: 825
# Number of words: 140
# Number of paragraphs: 21
让我们使用RecursiveCharacterTextSplitter
,并从相对较大的块大小开始,设为 300。
from langchain.text_splitter import RecursiveCharacterTextSplitter
text_splitter = RecursiveCharacterTextSplitter(
chunk_size = 300,
chunk_overlap = 0,
length_function = len,
is_separator_regex = False,
)
text_splitter.split_text(zen)
我们将得到三个块:264、293 和 263 个字符。我们可以看到所有的句子都保持在一起。
以下所有图像均由作者制作。
你可能会注意到一个chunk_overlap
参数,它允许你进行重叠分割。这很重要,因为我们将把一些块和问题一起传递给 LLM,而拥有足够的上下文来仅根据每个块中提供的信息做出决策是至关重要的。
作者方案
让我们尝试添加chunk_overlap
。
text_splitter = RecursiveCharacterTextSplitter(
chunk_size = 300,
chunk_overlap = 100,
length_function = len,
is_separator_regex = False,
)
text_splitter.split_text(zen)
现在,我们有四个分割块,字符数分别为 264、232、297 和 263,我们可以看到我们的块有重叠。
让我们把块的大小稍微调小一点。
text_splitter = RecursiveCharacterTextSplitter(
chunk_size = 50,
chunk_overlap = 10,
length_function = len,
is_separator_regex = False,
)
text_splitter.split_text(zen)
现在,我们甚至不得不分割一些较长的句子。这就是递归分割的工作原理:由于按段落("\n"
)分割后,块仍然不够小,因此分割器继续按" "
分割。
你可以进一步自定义分割。例如,你可以指定length_function = lambda x: len(x.split("\n"))
来使用段落的数量作为块的长度,而不是字符的数量。按标记分割也很常见,因为 LLM 的上下文大小基于标记的数量。
另一种潜在的自定义方式是使用其他separators
,而不是用","
而是用" "
来分隔。让我们尝试用几句话来使用它。
text_splitter = RecursiveCharacterTextSplitter(
chunk_size = 50,
chunk_overlap = 0,
length_function = len,
is_separator_regex = False,
separators=["\n\n", "\n", ", ", " ", ""]
)
text_splitter.split_text('''\
If the implementation is hard to explain, it's a bad idea.
If the implementation is easy to explain, it may be a good idea.''')
它有效,但逗号的位置不对。
为了解决这个问题,我们可以使用带回顾的正则表达式作为分隔符。
text_splitter = RecursiveCharacterTextSplitter(
chunk_size = 50,
chunk_overlap = 0,
length_function = len,
is_separator_regex = True,
separators=["\n\n", "\n", "(?<=\, )", " ", ""]
)
text_splitter.split_text('''\
If the implementation is hard to explain, it's a bad idea.
If the implementation is easy to explain, it may be a good idea.''')
现在已修复。
此外,LangChain 提供了处理代码的工具,可以根据编程语言特定的分隔符来分割文本。
然而,在我们的情况下,情况更简单。我们知道每个文件中有用"\n"
分隔的独立评论,我们只需按此分隔即可。不幸的是,LangChain 不支持这种基本用例,因此我们需要进行一些黑客操作以使其按我们想要的方式工作。
from langchain.text_splitter import CharacterTextSplitter
text_splitter = CharacterTextSplitter(
separator = "\n",
chunk_size = 1,
chunk_overlap = 0,
length_function = lambda x: 1, # hack - usually len is used
is_separator_regex = False
)
split_docs = text_splitter.split_documents(docs)
len(split_docs)
12890
你可以在我之前关于 LangChain 的文章中找到更多关于我们为什么需要这个 hack 的详细信息。
文档的重要部分是元数据,因为它可以提供有关该块来源的更多上下文。在我们的例子中,LangChain 自动填充了元数据的source
参数,因此我们知道每条评论涉及哪个酒店。
还有其他方法(例如用于HTML或Markdown的方法),它们在拆分文档时添加标题到元数据。如果您正在处理这些数据类型,这些方法可能非常有帮助。
向量存储
现在我们有评论文本,下一步是学习如何有效地存储它们,以便我们可以获得相关的文档来回答我们的问题。
我们可以将评论存储为字符串,但这对我们解决这个任务没有帮助——我们无法过滤与问题相关的客户评论。
更加功能强大的解决方案是存储文档的嵌入。
嵌入是高维向量。嵌入捕捉单词和短语之间的语义含义和关系,因此语义上接近的文本之间的距离较小。
我们将使用OpenAI 嵌入,因为它们非常流行。OpenAI 建议使用text-embedding-ada-002
模型,因为它具有更好的性能、更广泛的上下文和更低的价格。像往常一样,它有其风险和限制:潜在的社会偏见和对最近事件的有限了解。
让我们尝试在玩具示例上使用嵌入来看看它的工作原理。
from langchain.embeddings.openai import OpenAIEmbeddings
embedding = OpenAIEmbeddings()
text1 = 'Our room (standard one) was very clean and large.'
text2 = 'Weather in London was wonderful.'
text3 = 'The room I had was actually larger than those found in other hotels in the area, and was very well appointed.'
emb1 = embedding.embed_query(text1)
emb2 = embedding.embed_query(text2)
emb3 = embedding.embed_query(text3)
print('''
Distance 1 -> 2: %.2f
Distance 1 -> 3: %.2f
Distance 2-> 3: %.2f
''' % (np.dot(emb1, emb2), np.dot(emb1, emb3), np.dot(emb2, emb3)))
我们可以使用
*np.dot*
作为余弦相似度,因为 OpenAI 嵌入已经被归一化。
我们可以看到第一和第三个向量彼此接近,而第二个向量不同。第一和第三个句子有类似的语义含义(它们都是关于房间大小),而第二个句子不接近,讨论天气。因此,嵌入之间的距离实际上反映了文本之间的语义相似性。
现在,我们知道如何将评论转换为数值向量。下一个问题是如何存储这些数据,以便轻松访问。
让我们考虑一下我们的用例。我们的流程将是:
-
获取一个问题,
-
计算其嵌入,
-
找到与此问题相关的最相关的文档块(与此嵌入距离最小的文档块),
-
最后,将找到的块作为上下文与初始问题一起传递给 LLM。
数据存储的常规任务是找到 K 个最近的向量(K 个最相关的文档)。因此,我们需要计算我们问题的嵌入与我们拥有的所有向量之间的距离(在我们的情况下,余弦相似度)。
通用数据库(如 Snowflake 或 Postgres)在这样的任务中表现不佳。但是有些数据库被优化,特别适合这种用例——向量数据库。
我们将使用一个开源嵌入数据库,Chroma。Chroma 是一个轻量级的内存数据库,非常适合原型设计。你可以在这里找到更多的向量存储选项。
首先,我们需要使用 pip 安装 Chroma。
pip install chromadb
我们将使用persist_directory
来将数据本地存储并从磁盘重新加载。
from langchain.vectorstores import Chroma
persist_directory = 'vector_store'
vectordb = Chroma.from_documents(
documents=split_docs,
embedding=embedding,
persist_directory=persist_directory
)
为了在下次需要时能够从磁盘加载数据,请执行以下命令。
embedding = OpenAIEmbeddings()
vectordb = Chroma(
persist_directory=persist_directory,
embedding_function=embedding
)
数据库初始化可能需要几分钟时间,因为 Chroma 需要加载所有文档并使用 OpenAI API 获取它们的嵌入。
我们可以看到所有文档已经加载完毕。
print(vectordb._collection.count())
12890
现在,我们可以使用相似性搜索来查找关于员工礼貌的顶级客户评论。
query_docs = vectordb.similarity_search('politeness of staff', k=3)
文档看起来与问题非常相关。
我们已经以可访问的方式存储了客户评论,现在是时候更详细地讨论检索了。
检索
我们已经使用了vectordb.similarity_search
来检索与问题最相关的块。在大多数情况下,这种方法将对你有效,但可能会有一些细节:
-
多样性缺乏 — 模型可能会返回极其相似的文本(甚至重复),这不会给 LLM 带来多少新信息。
-
未考虑元数据 —
similarity_search
不会考虑我们拥有的元数据。例如,如果我查询问题“Travelodge Farringdon 的早餐”的前五条评论,结果中只有三条评论的来源等于uk_england_london_travelodge_london_farringdon
。 -
上下文大小限制 — 和往常一样,我们有有限的 LLM 上下文大小,需要将文档适配到其中。
让我们讨论一下可以帮助我们解决这些问题的技术。
解决多样性问题 — MMR(最大边际相关性)
相似性搜索返回与你的问题最接近的响应。但为了向模型提供完整的信息,你可能不想只关注最相似的文本。例如,对于问题“Travelodge Farringdon 的早餐”,前五条客户评论可能都关于咖啡。如果我们仅查看这些评论,就会错过其他提到鸡蛋或员工行为的评论,从而对客户反馈有一定的局限性。
我们可以使用 MMR(最大边际相关性)方法来增加客户评论的多样性。它的工作原理非常简单:
-
首先,我们使用
similarity_search
获取fetch_k
与问题最相似的文档。 -
然后,我们选择了
k
中最具多样性的那些。
作者方案
如果我们想使用 MMR,我们应该使用max_marginal_relevance_search
而不是similarity_search
,并指定fetch_k
数量。值得保持fetch_k
相对较小,以便输出中不会有不相关的答案。就这些。
query_docs = vectordb.max_marginal_relevance_search('politeness of staff',
k = 3, fetch_k = 30)
让我们来看一下相同查询的示例。这次我们收到了更多样化的反馈,甚至还有带有负面情绪的评论。
解决特异性问题 — LLM 辅助检索
另一个问题是我们在检索文档时没有考虑元数据。为了解决这个问题,我们可以让 LLM 将初始问题拆分为两部分:
-
基于文档文本的语义过滤器,
-
基于我们拥有的元数据进行过滤,
这种方法被称为“自查询”。
首先,让我们添加一个手动过滤器,指定与 Travelodge Farringdon 酒店相关的source
参数的文件名。
query_docs = vectordb.similarity_search('breakfast in Travelodge Farrigdon',
k=5,
filter = {'source': 'hotels/london/uk_england_london_travelodge_london_farringdon'}
)
现在,让我们尝试使用 LLM 自动生成这样的过滤器。我们需要详细描述所有元数据参数,然后使用SelfQueryRetriever
。
from langchain.llms import OpenAI
from langchain.retrievers.self_query.base import SelfQueryRetriever
from langchain.chains.query_constructor.base import AttributeInfo
metadata_field_info = [
AttributeInfo(
name="source",
description="All sources starts with 'hotels/london/uk_england_london_' \
then goes hotel chain, constant 'london_' and location.",
type="string",
)
]
document_content_description = "Customer reviews for hotels"
llm = OpenAI(temperature=0.1) # low temperature to make model more factual
# by default 'text-davinci-003' is used
retriever = SelfQueryRetriever.from_llm(
llm,
vectordb,
document_content_description,
metadata_field_info,
verbose=True
)
question = "breakfast in Travelodge Farringdon"
docs = retriever.get_relevant_documents(question, k = 5)
我们的情况很棘手,因为元数据中的source
参数包含多个字段:国家、城市、酒店连锁和位置。在这种情况下,将如此复杂的参数拆分为更详细的子参数是值得的,以便模型可以更容易地理解如何使用元数据过滤器。
然而,通过详细的提示,它确实有效,并仅返回了与 Travelodge Farringdon 相关的文档。但我必须承认,这花了我几个迭代才达到这个结果。
让我们开启调试模式看看它的工作情况。要进入调试模式,只需执行下面的代码。
import langchain
langchain.debug = True
完整的提示非常长,所以让我们看看它的主要部分。这是提示的开头,给模型一个我们期望的概述和结果的主要标准。
然后,使用少量示例提示技术,模型提供了两个输入和期望输出的示例。这是其中一个示例。
我们并没有使用像 ChatGPT 这样的聊天模型,而是使用通用的 LLM(没有针对指令进行微调)。它只是训练来预测文本的后续标记。这就是为什么我们以问题和字符串Structured output:
结束提示,期待模型提供答案的原因。
结果,我们从模型那里得到的初始问题被拆分为两部分:语义部分(breakfast
)和元数据过滤器(source = hotels/london/uk_england_london_travelodge_london_farringdon
)
然后,我们使用了这种逻辑从我们的向量存储中检索文档,并仅获取了我们需要的文档。
解决大小限制 — 压缩
另一种可能有用的检索技术是压缩。尽管 GPT 4 Turbo 的上下文大小为 128K 标记,但它仍然有限。因此,我们可能需要预处理文档并仅提取相关部分。
主要优势有:
-
您将能够将更多文档和信息整合到最终提示中,因为它们将被压缩。
-
您将会得到更好、更集中的结果,因为在预处理期间将清除非相关的上下文。
这些好处是有代价的 — 您将需要更多的 LLM 调用来进行压缩,这意味着更慢的速度和更高的价格。
您可以在文档中找到有关此技术的更多信息。
作者提出的方案
实际上,我们甚至可以结合技术并在这里使用 MMR。我们使用ContextualCompressionRetriever
来获取结果。此外,我们指定了我们只想要三个文档作为返回结果。
from langchain.retrievers import ContextualCompressionRetriever
from langchain.retrievers.document_compressors import LLMChainExtractor
llm = OpenAI(temperature=0)
compressor = LLMChainExtractor.from_llm(llm)
compression_retriever = ContextualCompressionRetriever(
base_compressor=compressor,
base_retriever=vectordb.as_retriever(search_type = "mmr",
search_kwargs={"k": 3})
)
question = "breakfast in Travelodge Farringdon"
compressed_docs = compression_retriever.get_relevant_documents(question)
像往常一样,了解其内部运作方式是最有趣的部分。如果我们看实际调用,可以看到有三次调用 LLM 来从文本中提取仅相关信息的情况。这里有一个例子。
在输出中,我们只得到了与早餐相关的部分句子,所以压缩有所帮助。
有许多更有利的检索方法,例如经典自然语言处理技术:支持向量机(SVM)或者TF-IDF。不同的检索器可能在不同情况下有所帮助,因此我建议您为您的任务比较不同版本,并选择最适合您使用情况的版本。
生成
最后,我们来到了最后阶段:我们将所有内容合并并生成最终答案。
这里是它们将如何运作的一个方案:
-
我们收到了用户的一个问题,
-
我们从向量存储中使用嵌入检索了此问题的相关文档,
-
我们将初始问题与从嵌入中检索到的相关文档一起传递给 LLM,并获得最终答案。
作者提出的方案
在 LangChain 中,我们可以使用RetrievalQA
链快速实现这一流程。
from langchain.chains import RetrievalQA
from langchain.chat_models import ChatOpenAI
llm = ChatOpenAI(model_name='gpt-4', temperature=0.1)
qa_chain = RetrievalQA.from_chain_type(
llm,
retriever=vectordb.as_retriever(search_kwargs={"k": 3})
)
result = qa_chain({"query": "what customers like about staff in the hotel?"})
让我们看一下对 ChatGPT 的调用。正如您所见,我们将检索到的文档与用户查询一起传递。
这里是模型的输出。
我们可以调整模型的行为,定制提示。例如,我们可以要求模型更加简洁。
from langchain.prompts import PromptTemplate
template = """
Use the following pieces of context to answer the question at the end.
If you don't know the answer, just say that you don't know, don't try
to make up an answer.
Keep the answer as concise as possible. Use 1 sentence to sum all points up.
______________
{context}
Question: {question}
Helpful Answer:"""
QA_CHAIN_PROMPT = PromptTemplate.from_template(template)
qa_chain = RetrievalQA.from_chain_type(
llm,
retriever=vectordb.as_retriever(),
return_source_documents=True,
chain_type_kwargs={"prompt": QA_CHAIN_PROMPT}
)
result = qa_chain({"query": "what customers like about staff in the hotel?"})
这次我们得到了一个更短的答案。此外,由于我们指定了return_source_documents=True
,我们得到了一组返回的文档。这对于调试可能有帮助。
正如我们所见,所有检索到的文档默认都合并在一个提示中。这种方法既优秀又简单,因为只需要一个调用来执行语言模型。唯一的限制是您的文档必须符合上下文大小。如果不符合,您需要应用更复杂的技术。
让我们看看不同的链类型,它们可以让我们处理任意数量的文档。第一个是 MapReduce。
这种方法类似于经典的MapReduce:我们根据每个检索到的文档生成答案(map 阶段),然后将这些答案合并成最终答案(reduce 阶段)。
作者的方案
所有这些方法的局限性在于成本和速度。你需要为每个检索到的文档进行一次调用,而不是一次调用 LLM。
关于代码,我们只需要指定chain_type="map_reduce"
以改变行为。
qa_chain_mr = RetrievalQA.from_chain_type(
llm,
retriever=vectordb.as_retriever(),
chain_type="map_reduce"
)
result = qa_chain_mr({"query": "what customers like about staff in the hotel?"})
结果,我们得到了以下输出。
让我们在调试模式下看看它是如何工作的。由于这是 MapReduce,我们首先将每个文档发送到 LLM,并根据这个块得到答案。以下是其中一个块的提示示例。
然后,我们将所有结果结合起来,并要求 LLM 给出最终答案。
就这样。
MapReduce 方法还有另一个特定的缺点。模型分别看到每个文档,而不是将它们全部放在同一上下文中,这可能导致更差的结果。
我们可以通过 Refine 链类型克服这个缺点。然后,我们将按顺序查看文档,并允许模型在每次迭代时细化答案。
作者的方案
再次,我们只需要更改chain_type
以测试另一种方法。
qa_chain_refine = RetrievalQA.from_chain_type(
llm,
retriever=vectordb.as_retriever(),
chain_type="refine"
)
result = qa_chain_refine({"query": "what customers like about staff in the hotel?"})
使用 Refine 链,我们得到了一个更详细和完整的答案。
让我们使用调试模式看看它是如何工作的。对于第一个块,我们从头开始。
然后,我们传递当前的答案和一个新的块,并给模型一个机会来细化其答案。
然后,我们对每个剩余的检索文档重复细化提示,最终得到结果。
今天我想告诉你的就这些。让我们快速回顾一下。
总结
在本文中,我们详细介绍了检索增强生成的整个过程:
-
我们已经查看了不同的数据加载器。
-
我们讨论了数据拆分的可能方法及其潜在的细微差别。
-
我们了解了什么是嵌入,并建立了一个向量存储库以有效地访问数据。
-
我们找到了检索问题的不同解决方案,并学习了如何增加多样性、克服上下文大小限制以及使用元数据。
-
最后,我们使用了
RetrievalQA
链来生成基于我们数据的答案,并比较了不同的链类型。
这些知识应该足够你开始使用自己的数据构建类似的东西。
非常感谢您阅读本文。希望本文对您有所启发。如果您有任何后续问题或评论,请在评论部分留言。
数据集
*Ganesan, Kavita 和 Zhai, ChengXiang. (2011). OpinRank 评论数据集.
UCI 机器学习库 (CC BY 4.0).* https://doi.org/10.24432/C5QW4W
参考资料
本文基于以下课程信息:
-
“LLM 应用开发的 LangChain”由 DeepLearning.AI 和 LangChain 提供,
-
“LangChain:与您的数据聊天”由 DeepLearning.AI 和 LangChain 提供。
RAG 与微调——哪种是提升你的 LLM 应用的最佳工具?
为你的用例选择正确方法的权威指南
·发表于 Towards Data Science ·19 分钟阅读·2023 年 8 月 24 日
–
作者提供的图片
前言
随着对大型语言模型(LLMs)兴趣的浪潮涌动,许多开发者和组织忙于构建利用其强大功能的应用。然而,当现成的预训练 LLMs 未能如预期般表现时,如何提升 LLM 应用性能的问题就会浮现。最终,我们会问自己:我们应该使用检索增强生成(RAG)还是模型微调来改善结果?
在深入探讨之前,让我们揭开这两种方法的神秘面纱:
RAG:这种方法将检索(或搜索)的力量集成到 LLM 文本生成中。它结合了一个检索系统,该系统从大规模语料库中获取相关文档片段,以及一个 LLM,该 LLM 使用这些片段中的信息生成答案。实质上,RAG 帮助模型“查找”外部信息以改进其响应。
作者提供的图片
微调:这是将预训练 LLM 进一步在一个较小的特定数据集上进行训练的过程,以便将其适应于特定任务或提升其性能。通过微调,我们根据我们的数据调整模型的权重,使其更加符合我们应用的独特需求。
作者提供的图片
RAG 和微调都是提升基于 LLM 的应用性能的强大工具,但它们针对优化过程的不同方面,这在选择其中一个时至关重要。
之前,我经常建议组织在深入微调之前先尝试 RAG。这是基于我对这两种方法虽然实现了类似结果但在复杂性、成本和质量上有所不同的看法。我甚至曾用类似的图表来说明这一点:
作者提供的图片
在这个图表中,各种因素如复杂性、成本和质量沿着单一维度表示。要点是什么?RAG 更简单且成本更低,但其质量可能无法匹配。我的建议通常是:从 RAG 开始,评估其性能,如果发现不足,则转向微调。
然而,我的观点已经有所变化。我认为将 RAG 和微调视为实现相同结果的两种技术,这种看法过于简化,因为一种技术更便宜、复杂度更低而已。它们本质上是不同的——而不是共线,而是正交——且满足 LLM 应用的不同需求。
为了更清楚地说明这一点,可以考虑一个简单的现实世界类比:当被问到“我应该用刀子还是勺子来吃饭?”时,最合逻辑的反问是:“你在吃什么?”我问过朋友和家人,每个人本能地回答了这个反问,这表明他们并不认为刀子和勺子是可以互换的,或者其中一种是另一种的低级变体。
这是什么内容?
在这篇博客中,我们将深入探讨区分 RAG 和微调的各种维度,这些维度在我看来对于确定特定任务的最佳技术至关重要。此外,我们将查看一些最受欢迎的 LLM 应用场景,并使用第一部分确定的维度来识别哪种技术最适合哪些用例。在博客的最后部分,我们将识别在构建 LLM 应用时应考虑的其他方面。每一个方面可能都值得单独撰写博客,因此在本篇博客中我们只能简要提及。
你为什么要关心这个?
选择适合调整大型语言模型的技术对 NLP 应用的成功有重大影响。选择错误的方法可能导致:
-
在你的具体任务上,模型表现不佳,导致输出不准确。
-
如果技术未针对你的使用案例进行优化,模型训练和推理的计算成本会增加。
-
如果需要以后转向不同的技术,则会增加额外的开发和迭代时间。
-
部署应用程序并让其面向用户的延迟。
-
如果选择了过于复杂的适配方法,则可能缺乏模型解释性。
-
由于大小或计算约束,难以将模型部署到生产环境中。
RAG 和微调之间的细微差别涵盖了模型架构、数据需求、计算复杂性等多个方面。忽视这些细节可能会破坏你的项目时间表和预算。
本博客旨在通过清晰地阐述何时使用每种技术来防止浪费努力。通过这些见解,你可以从第一天起就以正确的适应方法迅速开展工作。详细的比较将使你能够做出最佳的技术选择,以实现你的商业和 AI 目标。本指南将帮助你选择合适的工具,为你的项目成功奠定基础。
那么,让我们深入探讨吧!
提升性能的关键考虑因素
在选择 RAG 还是微调之前,我们应该评估 LLM 项目的需求,并在一些维度上提出几个问题。
我们的用例是否需要访问外部数据源?
在选择对 LLM 进行微调还是使用 RAG 时,一个关键的考虑因素是应用程序是否需要访问外部数据源。如果答案是肯定的,RAG 可能是更好的选择。
RAG 系统的定义是通过在生成回应之前从知识来源检索相关信息,以增强 LLM 的能力。这使得这种技术非常适合需要查询数据库、文档或其他结构化/非结构化数据存储的应用程序。检索器和生成器组件可以被优化以利用这些外部来源。
相比之下,虽然可以对 LLM 进行微调以学习一些外部知识,但这需要来自目标领域的大量标注数据集。随着基础数据的变化,这些数据集必须不断更新,使得它不适用于频繁变化的数据源。微调过程也没有明确建模在查询外部知识时涉及的检索和推理步骤。
总结来说,如果我们的应用程序需要利用外部数据源,使用 RAG 系统可能会比单纯依靠微调“内置”所需知识更有效和可扩展。
我们是否需要修改模型的行为、写作风格或领域特定知识?
另一个非常重要的方面是我们需要模型在多大程度上调整其行为、写作风格或为领域特定应用量身定制其回应。
微调在将 LLM 的行为适应于特定的细微差别、语气或术语方面表现出色。如果我们希望模型听起来更像医学专业人员、用诗意的风格写作,或使用特定行业的术语,在领域特定数据上进行微调可以实现这些定制。这种影响模型行为的能力对于需要与特定风格或领域专长一致的应用程序至关重要。
RAG 虽然在整合外部知识方面强大,但主要关注信息检索,并不会根据检索到的信息自我调整语言风格或领域特定性。它将从外部数据源中提取相关内容,但可能不会展现出微调模型所能提供的定制化细微差别或领域专业知识。
因此,如果我们的应用程序需要特定的写作风格或深度对齐于特定领域的术语和惯例,微调提供了实现这种对齐的更直接途径。它提供了深入的定制,使得内容真正与特定受众或专业领域产生共鸣,确保生成的内容感觉真实且信息丰富。
快速回顾
这两个方面是决定使用哪种方法来提升 LLM 应用性能时最重要的方面。有趣的是,我认为它们是正交的,可以独立使用(也可以组合使用)。
图片由作者提供
但在深入使用案例之前,我们还应该考虑几个关键方面来选择方法:
抑制幻觉有多重要?
LLM 的一个缺点是它们有产生幻觉的倾向——编造没有现实依据的事实或细节。这在准确性和真实性至关重要的应用中可能会非常有问题。
微调可以在一定程度上帮助减少幻觉,通过将模型基于特定领域的训练数据。然而,当面对不熟悉的输入时,模型仍可能会编造响应。需要对新数据进行再训练以持续最小化虚假伪造。
相比之下,RAG 系统本质上较不容易产生幻觉,因为它们将每个响应基于检索到的证据。检索器在生成器构造答案之前会从外部知识源中识别相关事实。这一步骤充当了一个事实检查机制,减少了模型虚构的能力。生成器被限制在基于检索到的上下文合成响应。
因此,在需要抑制虚假信息和想象性伪造至关重要的应用中,RAG 系统提供了内建机制来最小化幻觉。生成响应之前的证据检索使 RAG 在确保输出真实准确方面具有优势。
有多少标记好的训练数据可用?
在决定 RAG 和微调之间时,一个关键因素是我们手头上是否有大量的领域或任务特定的标记训练数据。
对 LLM 进行微调以适应特定任务或领域严重依赖于可用标注数据的质量和数量。丰富的数据集可以帮助模型深入理解特定领域的细微差别、复杂性和独特模式,从而生成更准确和上下文相关的回应。然而,如果我们使用的是有限的数据集,微调的改善可能会很小。在某些情况下,稀少的数据集甚至可能导致过拟合,模型在训练数据上表现良好,但在未见或现实世界输入中表现不佳。
相反,RAG 系统独立于训练数据,因为它们利用外部知识源来检索相关信息。即使我们没有广泛的标注数据集,RAG 系统仍然可以通过访问和整合外部数据源的见解来有效地工作。检索和生成的结合确保了系统保持知情,即使在领域特定的训练数据稀缺时。
实质上,如果我们拥有大量标注数据,能够捕捉领域的复杂性,微调可以提供更加定制化和精细化的模型行为。但在数据有限的情况下,RAG 系统提供了一个稳健的替代方案,通过其检索能力确保应用保持数据驱动和上下文相关。
数据的静态/动态特性如何?
在选择 RAG 和微调时,另一个需要考虑的基本方面是我们数据的动态特性。数据更新的频率如何?对于模型保持最新状态的重要性如何?
对特定数据集进行 LLM 微调意味着模型的知识成为训练时数据的静态快照。如果数据经常更新、变化或扩展,这会迅速使模型过时。为了保持 LLM 在这种动态环境中的时效性,我们必须频繁地重新训练模型,这个过程既耗时又资源密集。此外,每次迭代都需要仔细监控,以确保更新后的模型在不同场景中仍表现良好,并且没有出现新的偏见或理解上的漏洞。
相对而言,RAG 系统在动态数据环境中天生具有优势。它们的检索机制不断查询外部源,确保用于生成回应的信息是最新的。随着外部知识库或数据库的更新,RAG 系统无缝地整合这些变化,保持其相关性而无需频繁的模型重新训练。
总结来说,如果我们在应对快速发展的数据环境,RAG 提供了一种与传统微调难以匹敌的灵活性。通过始终连接到最新的数据,RAG 确保生成的回应与当前的信息状态保持一致,使其成为动态数据场景的理想选择。
我们的 LLM 应用需要多透明/可解释?
最后一个要考虑的方面是我们需要对模型决策过程的洞察程度。
微调 LLM 虽然极具威力,但像一个黑箱一样运行,使得其响应背后的推理更为不透明。随着模型从数据集中内化信息,识别每个响应背后的确切来源或推理变得困难。这可能会使开发者或用户难以信任模型的输出,特别是在需要理解答案背后“为什么”的关键应用中。
RAG 系统提供了一种在纯粹微调模型中通常找不到的透明度。鉴于 RAG 的两步性质——检索和生成——用户可以窥见其过程。检索组件允许检查哪些外部文档或数据点被选择为相关。这提供了可以评估的具体证据或参考,以理解响应的基础。追溯模型答案到特定数据源的能力在需要高度责任感或需要验证生成内容准确性的应用中非常宝贵。
本质上,如果透明性和解释模型响应基础的能力是优先事项,RAG 提供了明确的优势。通过将响应生成分解为不同阶段并允许洞察其数据检索,RAG 促进了对其输出的更大信任和理解。
总结
在考虑这些维度时,选择 RAG 和微调变得更为直观。如果我们倾向于访问外部知识并重视透明性,RAG 是我们的首选。另一方面,如果我们处理的是稳定的标注数据,并且目标是更紧密地调整模型以满足特定需求,微调则是更好的选择。
作者提供的图片
在接下来的部分,我们将看到如何根据这些标准评估流行的 LLM 使用案例。
使用案例
让我们看看一些流行的使用案例,以及上述框架如何用于选择正确的方法:
总结(在专业领域和/或特定风格中)
1. 是否需要外部知识? 对于以之前的总结风格进行总结的任务,主要数据来源将是之前的总结本身。如果这些总结包含在静态数据集中,则无需持续的外部数据检索。然而,如果存在一个动态的总结数据库,经常更新且目标是不断地将风格与最新条目对齐,RAG 可能会在这里发挥作用。
2. 是否需要模型适应? 这个用例的核心在于适应专业领域和/或特定写作风格。微调特别擅长捕捉风格细微差别、语调变化和特定领域的词汇,使其成为这一维度的最佳选择。
3. 是否至关重要的是最小化虚构? 在大多数大型语言模型应用中,包括总结,虚构都是一个问题。然而,在这个用例中,被总结的文本通常作为上下文提供。这使得虚构问题相较于其他用例的关注度较低。源文本限制了模型,减少了虚构的可能性。因此,尽管事实准确性总是值得追求,但在总结中压制虚构的优先级较低,因为有上下文作为基础。
4. 是否有训练数据? 如果有大量标记或结构化的先前总结,模型可以从中学习,那么微调将是一个非常有吸引力的选择。另一方面,如果数据集有限,而我们依赖外部数据库来进行风格对齐,RAG 可能会发挥作用,尽管它的主要强项不是风格适应。
5. 数据的动态性如何? 如果先前总结的数据库是静态的或更新不频繁,那么微调模型的知识可能会在较长时间内保持相关。然而,如果总结更新频繁,并且需要模型不断地与最新的风格变化保持一致,RAG 可能由于其动态数据检索能力而具有优势。
6. 是否需要透明性/可解释性? 这里的主要目标是风格上的一致性,因此某种特定总结风格背后的“原因”可能不像其他用例那样关键。不过,如果需要追溯并了解哪些先前的总结影响了特定输出,RAG 提供了更多的透明度。尽管如此,这对该用例来说可能是次要问题。
推荐: 对于这个用例,微调 似乎是更合适的选择。主要目标是风格一致性,这是微调擅长的一个维度。如果有足够多的先前总结用于训练,微调一个大型语言模型将允许对所需风格进行深度适应,捕捉领域的细微差别和复杂性。然而,如果总结数据库极其动态,并且追溯影响具有价值,可以探索混合方法或倾向于 RAG。
关于组织知识的问答系统(即外部数据)
1. 是否需要外部知识? 依赖于组织知识库的问答系统本质上需要访问外部数据,在这种情况下,即组织的内部数据库和文档存储。系统的有效性取决于其从这些来源中提取和检索相关信息的能力。鉴于此,RAG 在这一维度上更为合适,因为它设计用来通过从知识来源中检索相关数据来增强 LLM 的能力。
2. 是否需要模型调整? 根据组织和其领域的不同,模型可能需要与特定术语、语调或惯例保持一致。虽然 RAG 主要关注信息检索,但微调可以帮助 LLM 调整其回应,以适应公司的内部用语或其领域的细微差别。因此,在这一维度上,根据具体需求,微调可能会发挥作用。
3. 是否至关重要以减少幻觉? 幻觉在这种用例中是一个主要关注点,因为 LLM 的知识截止点。如果模型无法根据其训练数据回答问题,它几乎肯定会(部分或完全)编造一个合理但不正确的答案。
4. 是否有可用的训练数据? 如果组织有结构化和标记化的历史问答数据集,这可以增强微调方法。然而,并非所有内部数据库都已标记或结构化用于训练目的。在数据未被整齐标记或主要关注于检索准确相关答案的情况下,RAG 能够访问外部数据源而无需大量标记数据集,使其成为一个令人信服的选择。
5. 数据的动态性如何? 组织内部的数据库和文档存储可以是高度动态的,经常更新、更改或添加。如果这种动态性是组织知识库的特征,RAG 则具有明显的优势。它不断查询外部来源,确保其回答基于最新的数据。微调则需要定期重新训练以跟上这些变化,这可能不切实际。
6. 是否需要透明度/可解释性? 对于内部应用,特别是在金融、医疗或法律等行业,理解答案背后的推理或来源可能至关重要。由于 RAG 提供了检索和生成的两步过程,它本质上提供了更清晰的洞察力,显示了哪些文档或数据点影响了特定的答案。这种可追溯性对内部利益相关者来说非常宝贵,他们可能需要验证或进一步调查某些答案的来源。
建议: 对于这种使用场景,RAG 系统似乎是更合适的选择。鉴于需要动态访问组织不断发展的内部数据库以及可能需要回答过程中的透明度,RAG 提供的功能与这些需求相契合。然而,如果对模型的语言风格或领域特定细节有重大关注,则可以考虑结合微调的元素。
客户支持自动化(即提供即时响应的自动聊天机器人或帮助台解决方案)
1. 是否需要外部知识? 客户支持通常需要访问外部数据,特别是处理产品详细信息、账户特定信息或故障排除数据库时。虽然许多查询可以通过一般知识来解决,但有些可能需要从公司数据库或产品 FAQ 中提取数据。在这里,RAG 从外部来源检索相关信息的能力将非常有益。然而,值得注意的是,许多客户支持互动也基于预定义的脚本或知识,这些可以通过微调的模型有效解决。
2. 是否需要模型适配? 客户互动需要特定的语调、礼貌和清晰度,并且可能还需要公司特有的术语。微调特别有助于确保语言模型适应公司的声音、品牌和特定术语,从而确保一致且符合品牌的客户体验。
3. 是否必须尽量减少幻觉? 对于客户支持聊天机器人来说,避免虚假信息对于维持用户信任至关重要。仅靠微调会使模型在面对不熟悉的查询时容易出现幻觉。相比之下,RAG 系统通过基于检索到的证据来抑制虚构。这种对获取事实的依赖使 RAG 聊天机器人能够减少有害的虚假信息,并在准确性至关重要的情况下为用户提供可靠的信息。
4. 是否有训练数据可用? 如果公司有客户互动的历史记录,这些数据对于微调非常宝贵。以前客户查询及其解决方案的丰富数据集可以用来训练模型,以便未来处理类似的互动。如果这样的数据有限,RAG 可以通过从外部来源(如产品文档)检索答案来提供备用方案。
5. 数据的动态性如何? 客户支持可能需要处理有关新产品、更新的政策或变化的服务条款的查询。在产品阵容、软件版本或公司政策频繁更新的情况下,RAG 动态从最新文档或数据库中提取信息的能力具有优势。另一方面,对于更静态的知识领域,微调可能就足够了。
6. 是否需要透明度/可解释性? 虽然在某些领域透明度很重要,但在客户支持中,主要关注的是准确、快速和礼貌的响应。然而,对于内部监控、质量保证或处理客户争议,了解答案来源的可追溯性可能是有益的。在这种情况下,RAG 的检索机制提供了额外的透明度。
推荐: 对于客户支持自动化,混合方法可能是最佳选择。调整可以确保聊天机器人符合公司的品牌、语气和一般知识,处理大部分典型的客户查询。RAG 可以作为补充系统,处理更动态或特定的查询,确保聊天机器人可以从最新的公司文档或数据库中提取信息,从而减少虚假信息的生成。通过整合这两种方法,公司可以提供全面、及时和品牌一致的客户支持体验。
作者图片
需要考虑的额外方面
如上所述,决定 RAG 和调整(或两者结合)时还有其他因素需要考虑。我们无法深入探讨这些因素,因为它们都具有多面性,并没有像上述某些方面那样明确的答案(例如,如果没有训练数据,调整根本不可能)。但这并不意味着我们应忽视它们:
可扩展性
随着组织的成长及需求的演变,所用方法的可扩展性如何?由于 RAG 系统具有模块化特性,可能提供更直接的可扩展性,尤其是当知识库增长时。另一方面,频繁调整模型以适应扩展的数据集可能计算量巨大。
延迟和实时要求
如果应用程序需要实时或近实时的响应,请考虑每种方法引入的延迟。RAG 系统涉及在生成响应前检索数据,相比于基于内在知识生成响应的调整后的 LLM,可能会引入更多的延迟。
维护和支持
从长远角度考虑。哪个系统更符合组织提供一致维护和支持的能力?RAG 可能需要维护数据库和检索机制,而调整则需要持续的重新训练,特别是数据或需求发生变化时。
鲁棒性和可靠性
每种方法对不同类型输入的鲁棒性如何?尽管 RAG 系统可以从外部知识源中提取信息,可能处理各种问题,而经过良好调整的模型在某些领域可能提供更多的一致性。
伦理和隐私问题
从外部数据库存储和检索数据可能引发隐私问题,特别是当数据敏感时。另一方面,尽管微调模型不查询实时数据库,但它仍可能基于其训练数据产生输出,这可能有其自身的伦理影响。
与现有系统的集成
组织可能已经有某些基础设施到位。RAG 或微调与现有系统(无论是数据库、云基础设施还是用户界面)的兼容性可以影响选择。
用户体验
考虑最终用户及其需求。如果他们需要详细的、基于参考的答案,RAG 可能更为合适。如果他们重视速度和领域特定的专业知识,微调模型可能更适合。
成本
微调可能会变得非常昂贵,特别是对于非常大的模型。然而,在过去几个月中,由于像QLoRA这样的参数高效技术,成本已大幅下降。设置 RAG 可能需要大额的初始投资——包括集成、数据库访问,甚至可能还有许可费用——但还需要考虑对外部知识库的定期维护。
复杂性
微调可能会迅速变得复杂。虽然许多提供商现在提供一键微调,只需提供训练数据,但跟踪模型版本并确保新模型在各方面仍表现良好是具有挑战性的。另一方面,RAG 也可能迅速变得复杂。涉及多个组件的设置,确保数据库保持最新,并确保各个部分——如检索和生成——恰到好处地配合在一起。
结论
正如我们所探讨的,选择 RAG 还是微调需要对 LLM 应用的独特需求和优先级进行细致的评估。没有一种放之四海而皆准的解决方案;成功在于将优化方法与任务的具体要求对齐。通过评估关键标准——对外部数据的需求、模型行为的调整、训练数据的可用性、数据动态、结果透明度等——组织可以做出明智的决策,确定最佳前进路径。在某些情况下,利用 RAG 和微调的混合方法可能是最优的。
关键在于避免假设某种方法在所有情况下都是优越的。像任何工具一样,它们的适用性取决于具体的任务。方法和目标的不匹配可能会阻碍进展,而正确的方法则会加速进展。在组织评估提升 LLM 应用的选项时,必须抵制过度简化的倾向,不应将 RAG 和微调视为可以互换的工具,而是要选择能够使模型充分发挥其能力并与用例需求对齐的工具。这些方法所解锁的可能性令人惊叹,但仅有可能性是不够的——执行才是关键。工具已经在这里——现在让我们开始使用它们。
海科·霍茨
👋 关注我在Medium和LinkedIn上的动态,阅读更多关于生成式 AI、机器学习和自然语言处理的内容。
👥 如果你在伦敦,可以加入我们的NLP London Meetups。
📔 我对 AI 新闻的想法见😇 Naughty Neural。
作者提供的图片
使用 ggplot2 提高对气候变化的意识
原文:
towardsdatascience.com/raise-awareness-about-climate-change-with-ggplot2-f31f0cae3c70
学会有效绘制历史天气数据
·发表于 Towards Data Science ·8 分钟阅读·2023 年 4 月 17 日
–
图片由 Ganapathy Kumar 在 Unsplash 提供
全球变暖不是预测,而是正在发生的现实。
詹姆斯·汉森
有确凿证据表明地球上的温度正在上升。随着气候变化威胁到人类的生存,了解、研究和提高对这一关键问题的认识比以往任何时候都更为重要。
无论你是学生、政府工作人员、非政府组织成员还是私人公司员工,向同事展示你对相关全球问题的关注是非常重要的。
在本教程中,你将学习如何找到可靠的历史温度数据并使用 ggplot2 将其可视化。在你完成这篇文章后,你将:
-
知道在哪里找到精心整理的历史天气数据集;
-
感到舒适地使用 ggplot2 绘制历史天气数据;
-
能够自定义你的 ggplot2 图表以讲述你的故事。
第一步:查找并加载数据
本教程的数据可在 国家环境信息中心 (NCEI)*** 上获取。NCEI 是美国环境数据的权威机构,提供有关气候、生态系统和水资源的高质量数据。全球年度总结(GSOY)数据集提供按城市和站点划分的历史天气数据。在本教程中,我们将使用来自加州伯克利的数据。如果你愿意,你可以选择你喜欢的城市。如果要使用与本教程相同的数据集,请搜索伯克利并选择包含自 1893 年以来的数据的文件。
文件将通过 read_csv
加载。唯一的参数是文件路径。数据框加载后,我们仅选择 DATE
和 TAVG
变量。DATE
包含观察到温度的年份,TAVG
是以摄氏度表示的年均温度。要了解更多可用变量的信息,请参考 数据集说明书。
library(readr)
library(dplyr)
df <- read_csv('USC00040693.csv') %>%
select("DATE", "TAVG")
summary(df)
R 的 summary()
函数告诉我们数据的范围从 1893 年到 2019 年,在此期间观察到的最小年均温度为 12.9 ºC(地点:加州伯克利)。最大年均温度为 15.93 ºC。它还显示有 33 个温度数据缺失。
第 2 步:使用 na_interpolation()
填补缺失值
由于我们正在处理时间序列,我们将使用线性插值填补缺失值。这种方法假设在缺失期间数据线性变化。实际上,当您使用折线图绘制时间序列时,观察间隔也会用连接两个点的直线填补。
要进行线性插值,我们将使用 imputeTS 包。安装并加载库后,您可以使用 na_interpolation()
填补缺失值。传递两个参数:第一个是您希望处理的数据框列,第二个是您希望用来执行插补的方法。
library(imputeTS)
df$TAVG <- na_interpolation(df$TAVG, option ="linear")
第 3 步:编码我们图表的第一个版本
ggplot2 可视化由多个层组成。如下面的图所示,每一层包含一个 geom 对象,即您在图表中看到的一个元素(例如线条和点)。
图片由作者创建
首先,您需要将数据集传递给 ggplot()
函数。其次,您将变量映射到美学属性——geom 对象的视觉属性。例如,美学属性包括 y 轴位置、x 轴位置、颜色或大小。下面,我们还设置了黑白 ggplot2 主题。如果不添加其他 geom 对象,图表将只有两个轴。
library(ggplot2)
theme_set(theme_bw())
axes <- ggplot(data = df, aes(x = DATE, y = TAVG))
axes
图片由作者创建
现在,您可以添加第二层,用点表示时间上的温度。注意,您可以使用“+”符号将此层添加到前一步骤中制作的图中。
axes +
geom_point()
图片由作者创建
最后,您可以添加第三层,其中包含线条。重要的是要指出,一些作者声称这些线条并不代表观察到的数据,应谨慎使用。有关详细讨论,请查看 《数据可视化基础》第十三章*,作者 Claus O. Wilke。
图片由作者创建
第 4 步:自定义您的图表
在这一部分,你将学习如何定制你的图表,使其既清晰又美观。
首先,为了使温度的增加更加明显,我们将点的颜色美学也映射到TAVG
。由于它是一个数值变量,ggplot2 将使用渐变色来表示连续的值。你可以使用scale_color_gradient()
函数选择代表低温和高温的颜色。
此外,你可以分别使用xlab()
和ylab()
设置 x 和 y 轴的标签。标题可以通过ggtitle()
添加。我们还将增加点的大小,并添加透明度以使重叠的数据可见。
爱德华·塔夫特,数据可视化领域的专家,建议最大限度地利用墨水来显示非冗余数据。作者声称,这会使你的图表更清晰,避免分散读者的注意力。
我们正在使用的 ggplot2 主题theme_bw()
已经符合 Tufte 的建议,但我们仍然可以去除图表的面板网格。为了实现这一点,使用theme()
函数并传递两个参数:panel.grid.minor = element_blank()
和panel.grid.major = element_blank()
。
图片由作者创建
第 5 步:为你的可视化创建一个主题
你现在将学习如何创建你自己的 ggplot2 主题。作为示例,我们将创建theme_tds()
。
首先,我们将加载 Google 字体“Source Serif Pro”。它是 Medium 文章中使用的字体。你可以通过showtext
包轻松加载它。如果你没有这个包,请安装它。安装包后,加载它并使用font_add_google()
函数来加载“Source Serif Pro”。我们还告诉 R 使用showtext
来渲染文本,通过showtext_auto()
。
library(showtext)
font_add_google("Source Serif Pro")
showtext_auto()
注意,一些作者建议在图表中只使用无衬线字体。请查看这篇文章,以了解关于这一问题的讨论。
现在我们将使用theme()
来定制图表。下图展示了一些你可以使用的参数。有关完整的列表,请查看这个ggplot2 参考。
图片由作者创建
你可以通过调用包含你自定义规范的 ggplot2 theme()
函数来创建一个新的主题。注意,我们从黑白主题(theme_bw
)开始,然后去除网格,改变背景、面板和文本颜色。为了便于将来的修改,创建了两个参数供用户指定所需的文本、面板和背景颜色。
theme_tds <- function(text_panel_color, background_color) {
theme_bw()+
theme(text=element_text(size=10,
family="Source Serif Pro",
color = text_panel_color),
# Eliminates grids
panel.grid.minor = element_blank(),
panel.grid.major = element_blank(),
# Changes panel, plot and legend background
panel.background = element_rect(fill = background_color),
plot.background = element_rect(fill = background_color),
legend.background = element_rect(fill= background_color),
# Changes legend texts color
legend.title = element_text(color = text_panel_color),
# Changes plot border color and size
panel.border = element_rect(size = 1, color = text_panel_color),
# Changes color of axis texts
axis.text.x = element_text(color = text_panel_color),
axis.text.y = element_text(color = text_panel_color),
axis.title.x = element_text(color= text_panel_color),
axis.title.y = element_text(color= text_panel_color),
# Changes axis ticks color
axis.ticks.y = element_line(color = text_panel_color),
axis.ticks.x = element_line(color = text_panel_color),
)
}
现在你可以简单地将theme_tds()
添加到你的图表中,并指定你喜欢的颜色。以下是一个示例:
ggplot(data = df, aes(x = DATE, y = TAVG, color = TAVG))+
geom_point(size = 4, alpha = 0.7)+
scale_color_gradient(name = "ºC", low = "#4F88EC", high = "#ec4f88")+
ggtitle("Historical air temperature trend in Berkeley, CA")+
xlab("Year")+
ylab("Annual Mean Temperature [ºC]")+
theme_tds(text_panel_color = "white",
background_color = "#252525")
图片由作者创建
另一个例子,背景为白色,字体颜色为 Towards Data Science:
ggplot(data = df, aes(x = DATE, y = TAVG, color = TAVG))+
geom_point(size = 4, alpha = 0.7)+
scale_color_gradient(name = "ºC", low = "#4F88EC", high = "#ec4f88")+
ggtitle("Historical air temperature trend in Berkeley, CA")+
xlab("Year")+
ylab("Annual Mean Temperature [ºC]")+
theme_tds(text_panel_color = "#365A77",
background_color = "white")
图片由作者创建
最后,你可以使用 LOESS(局部回归散点平滑)平滑器来展示温度趋势,正如 Claus O. Wilke 在《数据可视化基础》第十四章中推荐的那样。你可以通过添加一个包含元素 geom_smooth()
的 ggplot2 图层来实现。
ggplot(data = df, aes(x = DATE, y = TAVG, color = TAVG))+
geom_point(size = 4, alpha = 0.7)+
geom_smooth(color = "#365A77", se = FALSE)+
scale_color_gradient(name = "ºC", low = "#4F88EC", high = "#ec4f88")+
ggtitle("Historical air temperature trend in Berkeley, CA")+
xlab("Year")+
ylab("Annual Mean Temperature [ºC]")+
theme_tds(text_panel_color = "#365A77",
background_color = "white")
图片由作者创建
结论
ggplot2 是一个强大的 R 库,允许你创建和自定义引人入胜的可视化。在本文中,你学会了如何使用它创建一个图表,以提高对全球变暖的认识,并使用了来自NCEI 网站的可靠数据。
如果你对研究和可视化气候数据的更多方法感兴趣,可以查看这篇文章,其中我通过回归分析关联了碳排放和空气温度:R 编程在气候数据分析和可视化中的应用
我希望这篇文章能为你提供新的数据可视化视角,使你的图表更具效果和吸引力。
*数据集使用条款
根据国家海洋和大气管理局(NOAA)的网站,“政府网页上的信息属于公共领域,在美国不受版权保护,除非另有特别说明(版权可能在其他地方持有)。”