Adiós, 2024
Adiós, 2024. ❤️
Adiós, 2024. ❤️
Raycast 很早就内置了 AI 功能,而 Alfred 直到 2024 年 3 月发布的 5.5 版本才增加 Text View 并推出自己的 ChatGPT 插件。而且,顾名思义,它仅支持 ChatGPT,对话场景单一,配置固定,提示词调整也很不方便。但是现实情况是,我们往往会用到多个 API 的多个模型,并且即便使用同一个模型去完成不同任务时,往往也会搭配不同的模型参数。
以自身举例,我需要常用大语言模型的场景有4个,分别是翻译、改写、解释、和随便问问,此外还有一些临时任务比如帮忙写注释,写 Git 提交信息,分析词汇和短语,问题分析及思考等等。这些场景任务目的不同,对应的提示词和模型参数也不应该一样。另外,模型很多,提供模型的API平台也很多,我希望对比着用,找出效果、速度、价格搭配上的最佳组合。因此,我期望的这个 Alfred AI Workflow 既要兼容不同场景,还要支持添改提示词、参数、及API信息等。
基于以上个人需求,我对官方 ChatGPT 插件进行了魔改,重命名为 La 🌶️。具体改动如下:
如上图所示,大改动有三处:一、最底下的 Text View,此处调用的 JXA 脚本完全重写了,当然主要是 AI 的功劳,我只是 AI 的 Copilot,提需求,做测试;二、Text View 正上方的的小模块,不再依赖 Alfred 环境变量,完全变成 JSON 配置解析器;三、再往上的这些小功能,比如翻译、Git Commit、Eli5、改写等,原则上可以随意添加,配置好过程把最终输入指向第二条的小模块即可。
一、通过关键词(默认是 ask)触发。特别适合零散的小提问;二、通过快捷键拉起。特别适合一些提示词固定又常用的任务;三、Universal Actions,比如翻译,一般是选中一段文字,按下 Universal Actions 快捷键,选中「翻译」,如果有需要则再指定目标语言,然后等待结果。
当然,你也完全可以为所有任务都设置不同关键词、快捷键、甚至 Universal Actions,但没必要,徒增记忆负担,但需定制最好。
Unversal Actions 是真好用,不知道 Raycast 现在有类似功能了没。
Magic = Wand + Gesture + Spell
🔥 = 🪄 + 🖖 + 📖
一个应用场景就是一个魔法(Magic),一个魔法由三部分组成,即:魔杖(Wand)、手势(Gesture)、和咒语(Spell),它们依次分别对应:API 平台(Provider)、模型及参数(Model & Parameters)、系统提示词(System Prompts)。用户消息则取决于使用场景,可以是选中的文字,也可以是键盘输入,是获取的一部分,不用放在配置里。
如上图,魔法配方是一个 JSON 文件,可以存放在任意目录,比如 ~/.config/alfred/la.json
。在这个配置里,我们为不同的任务调制不同的配方,配方可以随意发挥,最终,一次调用就是一次完整的施法过程。
我们先去对角巷采购魔杖。如下图所示,我们需要为每个商店起个名字,然后填上对应的 API 地址和 API Key。Azure 等云厂商往往需要特别对待,我们可以先把 resource
及 deployment
等特殊字段标记出来,放到手势配方里去统一替换,这样可以保持此处的整洁和固定。
接下来我们去霍格沃茨城堡五楼,在图书馆里按需抄录一些常用咒语。示例如下图,我常备的有翻译、改写、问询、解答等。部分咒语在使用时需要指向目标,我们可以先用 context_placeholder
做个标记,在施法时在明确替换。
比如翻译场景,我常用的有「中英互翻」和「中西互翻」,虽然目的都是翻译,但至少涉及三种语言,且并不总是要翻译成中文,源语言和目标语言是不固定的。所以我们在此处留好标记,在 Workflow 内设计一个停顿,默认翻译成中文,有其它指向,在停顿处写下目标语言,在下一个步骤用这个目标语言替换掉上面的标记即可。
接下来是时候躲到万应室专心调制手势配方了。内容并不复杂,给每个手势起个名字,指定一个模型,然后配置模型超参数,但超参数大部分时候配置一个 temperature
即可。比如「翻译魔法」,需要的温度往往非常低,我们可以将 temperature
设置为 0。而「随意问答」时,为了确保它不会过于死板,我们可以保持这个值在 1 附近。
对于 Azure OpenAI 之流,我们在这里把 resource
和 deployment
等标记补完,有多个资源多个部署,设计多个手势配方就好。
最后我们再把这些部件装配起来。比如翻译,我们使用架设在 Groq 平台上的 Llama 模型,效果好,速度快,还免费。再比如随意问,我们用 xAI 新放出的 grok,有 25 刀的额度,正好体验一下它有什么独到之处。
如上所述,这个配置是随意扩展的,因此你可以根据自身需求发挥想象,挑选你中意的魔杖,设计你独有的手势,吟唱你编造的咒语,并把它们组合成你的魔法。
{
"kill_harry_potter": {
"wand": "acacia",
"gesture": "↑↓←←→→ba",
"spell": "avada_kedavra"
}
}
在 Workflow 界面里,你需要创建一个子工作流,它可以通过热键、关键词、Universal Action 等多种方式拉起,结尾指向 chat_magic
即可,它会接管施法之后的工作。
中间特别要注意,别忘了配置 intent
项,这是 Workflow 程序和我们魔法配方之间的桥梁。
以上面提到的翻译为例,我现在不需要考虑如何实现「从选中到翻译」的一整个 Alfred Workflow,只需要关注最前面的 UI 互动场景,即接受怎样的输入,要对输入做些什么处理,最后把整合完毕的输出信息指向 chat_magic
主模块即可。
所以我只需要创建一个小 Workflow 结点,最前面的 Input 按使用习惯设置两种触发方式:快捷键和 Universal Actions。往下,针对是否「开启新会话」做个分岔。如果是不想干的翻译,就走新会话,互不干扰,如果是一个话题或者一篇长文的陆续翻译,就走现有会话的累加,把这几次翻译放在一起。通过为 Universal Actions 增加修饰键就可以实现它。
再往下,因为翻译成中文的情况比较常用,在目标语言这里再做个分岔,默认中文,其他情况自行补充。为了避免翻译成中文时每次还弹出目标语言框,我又设置了不同的起始快捷键,实现一键直达的效果。
心仪 @yihong618 的 Running Page 久已,最近终于用上了❤️。优哉游哉跑步好几年,但数据维护基本没做,NRC 退出中国之后,更没导出保存,找出 Endomondo,记录不多,且它的 GPX 奇葩不带时间戳没法用。盘点下来,也就剩 Apple Watch 健康数据还能导出用一用。下图是结果,明细在这里。
Running Page 项目支持很多设备和数据平台。我是 Apple Watch 用户,结合我的实际情况,我的选择是 GPX + Strava 方案。理由简单,简单试用对比之后,这个方案能比较顺畅地把历史存量和增量更新糊到一处,供 Running Page 调用。
下载开通 Strava 账号之后,即可在设置界面连接手表设备和手机的健康数据。开启自动上传功能,还能获得自动同步能力,无需特意开启 Strava App。设置完成之后,即可勾选导入最近 30 天的数据,也非常方便。
对于手表内的历史记录,则需要导出、处理、再上传。进入 iPhone 健康 App,点击右上角头像,下拉到底即可点击 Export All Health Data。这里面可能包含多种数据,我们只取其中的 workout-routes。
但是光通过这些轨迹路线还分不清是散步、跑步、骑车,需要另行过滤。比如可以通过 gpx.py 先对距离过短、距离过长、速度过快进行快速的路线分类,然后借助 Avenue GPX Viewer 等工具补充目测。清理前👇
分类完成之后,把结果扔到 Running Page 的 GPX_OUT 目录,然后跟着 Strava 章节拿到几个鉴权信息,再然后跟着 GPX_to_Strava 章节批量上传即可。
借用 R4DS 一书中对数据探索工作的描述,主要包括三个关键部件:数据转换、可视化图表、数学建模。一直以来,在 EDA 场景中,我自己使用 R & Python 的强度是五五开,甚至略微更偏向 R 一些。因为 R 宇宙有 Tidyverse、data.table 这些工具集,都支持管道,且语法简明、逻辑优雅,借助 RStudio 和 RMarkdown,数据分析效率很高。而 Python + Jupyter + Pandas 这边,往往操作稍显罗嗦,组织主题更加麻烦一些。
进入 2023 年之后,Python 的使用比重就大的多了,主要原因有两个:一是因为 Polars;二是因为 Quarto。Polars 日臻成熟和流行,它兼顾了语义的优雅和性能的高效,新引入的抽象概念和 API 带来的「心智负担」也不算高,无论是从 Pandas 还是 Tidyverse 过渡而来,都非常容易。对于喜欢 R 管道的朋友,Polars 提供的链式 API,更是帮我们在 Python 环境里找到了家。再说 Quarto,RStudio/Posit 可能 All in Quarto,期望通过 Quarto 来包含 R/Python 的一切,来对抗 Jupyter。然时至今日,如果用户不进入 RStudio App,去手动链接各种 dart-sass、deno、esbuild,在苹果 M 系列芯片上,.qmd
文件还是跑不起来,勉强跑起来了,不知不觉又切回了更为熟悉轻量的 Rmd。加之今天又看到 Yihui Down,相当难过。Anyway,Good luck and have fun.
我几乎已经完全用 Polars 替换掉了 Pandas 和 Tidyverse。唯有两点遗憾,一是 Polars 没有内置的图表接口,二是还不能无缝关联模型库。不过,这两点都可以通过调用比如 .to_pandas()
,将 DataFrame 转换成相应的兼容数据形式之后,再进行处理。后者,应该还需要不少的时间,而至于前一个遗憾,在最近几天发布的 0.20.3
版中解决了。现在 Polars 也内置了类似 Pandas df.plot
方式的绘图接口 🔥,必须赶紧体验一下,并且跟 Python 生态里我常用的绘图库做个对比,做个取舍。
以上篇我在 YouTube 上看了些啥的数据集为例,做两件事,一是分别使用各个可视化框架,分别制作每月每个时段的热力图(heatmap)和每年的内容分类排名图(bump chart);二是将两附图合在一起,探索一下各框架的多图布局功能。
import polars as pl
df = pl.read_parquet("./history_aio.parquet")
pl.__version__
'0.20.4'
heatmap_dat = (
# 挑出 2023 年
df.filter(pl.col("year") == 2023)
# 按月、小时段,做 count
.group_by("month", "hour")
.agg(
pl.count().alias("n"),
)
# 透视拉宽、补列,完整 24 小时
.pivot(index="month", columns="hour", values="n")
.with_columns(
pl.lit(None).alias("4"), pl.lit(None).alias("5"), pl.lit(None).alias("6")
)
.select(["month"] + [str(c) for c in range(1, 24)])
# 融化还原成长表
.melt(id_vars="month", variable_name="hour", value_name="n")
# 变换类型并排序
.with_columns(pl.col("hour").cast(pl.Int8))
.sort("month", "hour")
)
heatmap_dat.head()
month | hour | n |
---|---|---|
i8 | i8 | u32 |
1 | 1 | 17 |
1 | 2 | 1 |
1 | 3 | null |
1 | 4 | null |
1 | 5 | null |
bump_dat = (
# 挑出有效类别行
df.filter(pl.col("cat_name").is_not_null())
# 按年、类,count 视频唯一量
.group_by("year", "cat_name").agg(pl.n_unique("title").alias("n_video"))
# 按量大小,排名
.with_columns(
pl.col("n_video").rank("ordinal", descending=True).over("year").alias("ranking")
)
# 按年排序
.sort("year")
)
bump_dat.head()
year | cat_name | n_video | ranking |
---|---|---|---|
i32 | str | u32 | u32 |
2016 | "People & Blogs… | 44 | 4 |
2016 | "Sports" | 14 | 9 |
2016 | "Howto & Style" | 41 | 5 |
2016 | "Autos & Vehicl… | 3 | 13 |
2016 | "Film & Animati… | 25 | 7 |
Polars 并没有自行实现绘图逻辑,它的 .plot
是通过的 hvPlot 来代理的(而 hvPlot 默认的后端又是 bokeh,所以如果在 Notebook 环境中图表未正确显示,可以尝试使用以下方法正确显示图表)。
# from bokeh.io import output_notebook
# output_notebook()
fig1 = heatmap_dat.plot.heatmap(
x="hour", y="month", C="n", cmap="reds", title="Heatmap"
).options(toolbar=None, default_tools=[])
fig1
热力图是 hvPlot 预置的模板图表,所以一行代码,简单映射一下 xy 轴,再指定一下颜色,即可实现相当不错的效果。对于 bokeh 后端,如果不想看见它的工具栏,不喜欢它默认启用的缩放效果,我们也只需要在 .plot
之后跟 .opts
或者 .options
把它们关掉即可。
排名变化图 Bump Chart 并非 hvPlot 预置模板,需要自行实现。我们可以将其分解为散点图和条线图的结合体,分作制作这两者,然后将其合二为一。
fig2 = (
bump_dat.plot.line(x="year", y="ranking", by="cat_name")
* bump_dat.plot.scatter(x="year", y="ranking", by="cat_name")
).opts(
title="Bump Chart",
toolbar=None,
default_tools=[],
invert_yaxis=True,
legend_opts=dict(border_line_width=0, label_text_font_size="6pt", label_height=2),
padding=0.05,
height=480,
)
fig2
微调的图表参数,可以大胆假设,先填进去,脚本报错之后,框架根据你的填写,会贴心的爆出它的合理推测或支持的参数集,如此两三个来回,再结合文档,实现想要的图表效果,门槛也不算太高。上图比较打眼的部分是右边的图例(legend)栏,它的排序没有能跟离它最近的 2023 年排名保持一致,观感不好。我们可以单独对它进行重排,或者拿掉它之后单独对 2023 的排名做 label 标注。不过这个过程在这里似乎都不同容易实现,且先留空。
AttributeError: unexpected attribute 'border_width' to Legend, similar attributes are border_line_width, border_line_dash or label_width
图内叠加用 *
,而多图堆叠也非常方便,用 +
即可。
fig3 = (fig1.opts(height=480) + fig2).opts(toolbar=None)
fig3
交互式图表库,我最早用的是 Altair。Altair 的语法结构不算复杂,使用时也比较符合直觉, Chart().mark_x().encode()...properties()
一路串下去,Chart
吃数据,mark_x
吃形状,encode
吃配置,properties
做一些找补。特别有意思的是,在串联环节当中,Altair 还引入了便捷的数据变换机制,方便用户操作数据。并且文档和 API 组织的相当好,使用时没有 Plotly、Bokeh 那么松散的感觉,遇到问题时,按图索骥也比较通畅。
import altair as alt
alt.__version__
'5.2.0'
fig4 = (
# 吃数据
alt.Chart(heatmap_dat.fill_null(0))
# 画方块
.mark_rect()
# 微调配置
.encode(
# x 及其数据类型
x="hour:O",
# y 及其数据类型、排序方式
y=alt.Y("month:O", sort="descending"),
# color 填充规则
color=alt.condition(
"datum.n != 0", alt.Color("n:Q").scale(scheme="reds"), alt.value("white")
),
# 悬停信息
tooltip=[
alt.Tooltip("month"),
alt.Tooltip("hour"),
alt.Tooltip("n:Q", title="# of videos"),
],
)
# 补充定义
.properties(title="Heatmap", width=500, height=300)
)
fig5 = (
alt.Chart(bump_dat)
# 画线、标点
.mark_line(point=True)
.encode(
x="year:O",
y=alt.Y("ranking:Q", sort="descending"),
# 填充且干预排序
color=alt.Color(
"cat_name",
sort=[
"Science & Technology",
"People & Blogs",
"Film & Animation",
"Education",
"Gaming",
"Entertainment",
"Howto & Style",
"Music",
"Travel & Events",
"Sports",
"News & Politics",
"Pets & Animals",
"Autos & Vehicles",
"Comedy",
"Nonprofits & Activism",
],
),
tooltip=[
alt.Tooltip("year:O"),
alt.Tooltip("cat_name"),
alt.Tooltip("ranking:Q"),
],
)
.properties(
title="Bump Chart",
width=500,
height=300,
)
)
# 超方便的多图堆叠
# (fig1 & fig2)
fig6 = (fig4 | fig5).configure_axis(grid=False).properties(title="Bye, hell subplots.")
fig6
如上可见,图+图的堆叠及操控尤其方便,直出画面的效果简洁大方。缺点就是数据量一大(5000)就报警。
MaxRowsError: The number of rows in your dataset is greater than the maximum allowed (5000).
Try enabling the VegaFusion data transformer which raises this limit by pre-evaluating data
transformations in Python.
>> import altair as alt
>> alt.data_transformers.enable("vegafusion")
Or, see https://altair-viz.github.io/user_guide/large_datasets.html for additional information
on how to plot large datasets.
Plotly 为满足「灵活自定义」以及「开箱即用」的两种需求,提供了两种不同的途径。一种底层 API,强调精确控制,称作 graphic_objects;一种上层 API,强调简单易用,称作 express。上一篇文章的全部图表,均出自 Plotly 框架。一般是先用 express 尝试,express 表达不出来的图表,再使用 graphic_objects 描绘。
import plotly
plotly.__version__
'5.18.0'
import plotly.graph_objects as go
from plotly import express as px
from plotly.subplots import make_subplots
fig7 = (
# 方块热力图在 plotly express 中表述为 imshow
px.imshow(
# 为了迎合 imshow 的表达方式,我们需要先揉捏一下数据
heatmap_dat.pivot(index="month", columns="hour", values="n")
.to_pandas()
.set_index("month"),
# 数据呈现方式
labels=dict(x="hour", y="month", color="n_videos"),
color_continuous_scale="reds",
# 图表配置
template="plotly_white",
title="Heatmap",
).update_layout(
xaxis1=dict(showgrid=False),
yaxis1=dict(showgrid=False),
)
)
fig7
在 Plotly 里边实现 Bump Chart 也需要我们自行设计实现方式,这时候就需要从 express 切换到 graphic_objects。这一切换过程往往会给我们造成一些困扰,主要来源于 API 设计上的不完全兼容,比如同样是绘制散点图,这两套 API 散点图方法提供的参数可能就不一致,从而造成理解偏差。
def plotly_bump(df):
fig = go.Figure()
# Bump Chart 可以
# 为每一个类别
for name, data in df.group_by(["cat_name"]):
name = name[0]
trace = go.Scatter(
x=data["year"],
y=data["ranking"],
mode="lines+markers",
name=name,
line=dict(width=2),
marker=dict(size=10),
text=name,
hoverinfo="text",
legendrank=data["ranking"][-1],
)
fig.add_trace(trace)
fig.update_layout(
title="Bump Chart",
xaxis=dict(title="Year"),
yaxis=dict(title="Rank"),
legend=dict(title="Category"),
yaxis_autorange="reversed",
template="plotly_white",
height=500,
width=800,
xaxis1=dict(showgrid=False),
yaxis1=dict(showgrid=False),
)
return fig
fig8 = plotly_bump(bump_dat)
fig8
可见,Plotly 设计的 figure、trace、layout, 及其各项属性都是自成一派的,它有自己鲜明的特色,默认的交互效果也非常好,只不过 ggplot2 这一脉的用户,需要一个习惯的过程。快速分析时我很喜欢用 Plotly 做可视化,但是遇到自定义场景时又很头痛,往往需要耗费很多时间去搜索、查阅。
def plotly_subplots():
fig = make_subplots(rows=1, cols=2, subplot_titles=["Heatmap", "Bump Chart"])
# Heatmap
heatmap_trace = go.Heatmap(
x=heatmap_dat["hour"],
y=heatmap_dat["month"],
z=heatmap_dat["n"],
hoverongaps=False,
opacity=0.7,
colorscale="reds",
colorbar=dict(x=0.45, y=0.5, len=1.1),
)
fig.add_trace(
heatmap_trace,
row=1,
col=1,
)
# Bump Chart
for name, data in bump_dat.group_by(["cat_name"]):
name = name[0]
trace = go.Scatter(
x=data["year"],
y=data["ranking"],
mode="lines+markers",
name=name,
line=dict(width=2),
marker=dict(size=10),
text=name,
hoverinfo="text",
legendrank=data["ranking"][-1],
legendgroup="2",
)
fig.add_trace(trace, row=1, col=2)
# Update layout
fig.update_layout(
title_text="Heatmap and Bump Chart Side by Side",
template="plotly_white",
xaxis1=dict(showgrid=False),
yaxis1=dict(showgrid=False),
yaxis2_autorange="reversed",
height=400,
width=1300,
)
return fig
fig9 = plotly_subplots()
fig9
花了不少时间让上面的两幅图表排在一起,结论暂时只有一个:不要折腾 Plotly 的 subplots,会变得不幸。
import bokeh
bokeh.__version__
'3.3.3'
不像绝大多数的图表库,Bokeh 已经不再内置模板 API,即便最常见的散点图、折线图,使用 Bokeh 来实现,看起来也没有那么直观,给人一种从底层一砖一瓦开始的基建感。
from bokeh.layouts import row
from bokeh.models import ColumnDataSource, Legend, LegendItem
from bokeh.plotting import figure, show, save
from bokeh.transform import factor_cmap, linear_cmap
from bokeh.io import output_notebook
output_notebook()
# 需要 Pandas DataFrame
fig10_dat = heatmap_dat.to_pandas()
# 控制热力图细节
fig10 = figure(
title="Heatmap",
# 小时
x_range=[str(x) for x in range(0, 24)],
# 月份,多一份留白,让每个小格子等宽等长
y_range=[str(x) for x in range(1, 14)],
x_axis_location="above",
tools="",
toolbar_location=None,
tooltips=[("hour", "@hour"), ("month", "@month"), ("n", "@n")],
width=600,
height=300,
)
# 去掉干扰
fig10.grid.grid_line_color = None
fig10.axis.axis_line_color = None
fig10.axis.major_tick_line_color = None
fig10.outline_line_color = None
# 映射数据
fig10.rect(
x="hour",
y="month",
width=1,
height=1,
source=fig10_dat,
fill_color=linear_cmap(
"n", "Reds256", high=fig10_dat.n.min(), low=fig10_dat.n.max(), nan_color="white"
),
line_color=None,
)
# 展示图表
show(fig10)
# 初始化图表配置
fig11 = figure(
title="Slope Graph",
x_range=(2015.9, 2023.1),
y_range=(17.5, 0.5),
x_axis_location="above",
tools="",
toolbar_location=None,
width=600,
height=300,
tooltips=[("category", "@cat_name"), ("ranking", "@ranking")],
)
# 预先设定 17 个分类的颜色映射关系
cmap6 = factor_cmap(
"cat_name",
"Category20_17",
sorted(bump_dat["cat_name"].unique()),
)
# 预先设定好图例排序
fig11_legend_indexes = [
"Science & Technology",
"People & Blogs",
"Film & Animation",
"Education",
"Gaming",
"Entertainment",
"Howto & Style",
"Music",
"Travel & Events",
"Sports",
"News & Politics",
"Pets & Animals",
"Autos & Vehicles",
"Comedy",
"Nonprofits & Activism",
"Movies",
"Trailers",
]
fig11_legend_items = []
# 分别绘制折线图和散点图,并把图例信息传出来
for name, data in bump_dat.group_by(["cat_name"]):
src = data.to_pandas()
name = name[0]
line = fig11.line(
x="year",
y="ranking",
source=src,
line_width=2,
line_color=cmap6.transform.palette[cmap6.transform.factors.index(name)],
)
scatter = fig11.scatter(
x="year",
y="ranking",
source=src,
size=8,
color=cmap6.transform.palette[cmap6.transform.factors.index(name)],
)
fig11_legend_items.append(LegendItem(label=name, renderers=[line, scatter]))
# 排除干扰
fig11.grid.grid_line_color = None
fig11.axis.axis_line_color = None
fig11.axis.major_tick_line_color = None
fig11.outline_line_color = None
fig11.xaxis.minor_tick_out = 0
fig11.xaxis.major_tick_in = 0
fig11.yaxis.minor_tick_out = 0
fig11.yaxis.major_tick_in = 0
# 微调图例
fig11_legend_items_sorted = sorted(
fig11_legend_items, key=lambda x: fig11_legend_indexes.index(x.label.value)
)
fig11.add_layout(Legend(items=fig11_legend_items_sorted, click_policy="mute"), "right")
# 排除干扰
fig11.legend.label_text_font_size = "6pt"
fig11.legend.glyph_height = 1
fig11.legend.spacing = 5
fig11.legend.label_height = 5
fig11.legend.location = (0, 0)
fig11.legend.background_fill_alpha = 0.6
fig11.legend.border_line_alpha = 0
# 展示图表
show(fig11)
至于多图堆叠,bokeh 提供了清晰的方案,比如我们这里的一行两列,直接调用 row 方法即可。
fig12 = row(fig10, fig11)
show(fig12)
今年应该会深入使用 Polars 及其背后的 hvPlot,以及 hvPlot 背后的 HoloViz、bokeh 生态,继续关注 Altair。另外,将尝试减少 Plotly 的使用,好用是好用,微调也确实痛苦。
自 2016 年 4 月中开始,至 2023 年底,年视频观看量从 1000+ 升至 4000,观看的频道也从 400+ 增至 1400。除了中间的 2019 年不记得什么原因之外,总体趋势上,毋庸置疑,我在 YouTube 上逛的越来越宽了。当然,这可能跟上瘾和 YouTube 的推荐有效相关,类似每天总得看看 Twitter,同样我也越来越习惯性点开 YouTube,看看订阅的博主有没更新,看看首页推荐有没有感兴趣的视频。此外,还经常越过 Google,直接进 YouTube 搜索一些概念和教程。总之,确实越用越勤了。最开始,可能还偏重于学习,部分视频反复观看,越往后这个现象似乎就越少了。
从这两张图的角度也能验证上面的结论,无论是从视频播放数量还是天数来看,偶然碰到然后不再见的频道,和经常光顾的频道,都逐年递增,逐渐上瘾。
YouTube 的视频也并不永生,也有生老病死。这些历史播放记录中,相当一部分的视频已经被删除或被设置为私有,看不到了。2022 年的尤其多,现在也无法回溯源头,不知道是哪位或哪几位大哥放弃了。
通过 YouTube API 抓取了这些视频的元数据,其中的 71% 是有标注声音语言代码的。语言编码标记千奇百怪,粗略统计了一下,有两个发现:一是未知语言(unset)的视频数量逐年变少,似乎越来越规范了;二是我看的中文视频越来越多了 orz。我有一个不负责任的观察,即,一般来说,YouTube 上的中文博主,消遣型的比较多。这么看来,我的播放记录显示我日渐消遣 orz。
不知道播放记录里面的时间戳是点击时间还是看完时间,从我的记录来看,工作日和周末有些差异。上两图挑的 2023 年数据,这一年除了 12 月,其余时间还是在正经上班,有参考意义。看得出来,工作日的 YouTube 之约,是在下班之后,吃晚饭开始到睡觉前的时段;周末,则比较放飞自我,除了跑步、做饭的时段,整个白天都很随意。
12 月大概是 11 号之后没上班,基本就没有所谓工作日和周末的区别了。
上图的内容分类,是先从 YouTube API 中扒取的 categoryId,然后根据网上找的映射关系匹配而得。排名依据,相当简单,就是视频播放量,多次播放算一次。有几个发现:一是科技类一直是历史观看榜的第一;二是它这个分类也挺随意的,不知是博主自己标的,还是系统分配的,比如标的教育类视频,其实是剧情解说;三,不过有些地方也符合直觉,比如教程类的视频确实看的少了,音乐和旅行类排行也确实是我的主动选择。
频道排行的规则稍微复杂一些,综合了视频量、观看月份量、观看次数、视频时长这几个因素,做了个草台评分,然后给出排名。尽量剔除博主更新频次低、我短期大量看某个频道等因素导致的偏颇。上图是各年按此规则得出的 Top20 频道。根据这个结果,这个评分规则还不理想,不过已经可以印证上边一些看法,尤其不得不承认几件事:一是英文>中文内容的变化;二是功用性内容的消费萎缩;三是消遣方向的多元。
如此错乱、跳跃,有我注意力转向的原因,也有博主更新热情消退的原因,还有铁拳力量的杀伤。最显眼的,回形针,公司莫名其妙被干没了;脱口秀,行业都基本被干趴了。
2023 年 Top20 频道,分析起来,有这些原因:我喜欢听侦探故事,所以榜首有「十三兰德」,中间有「X调查」;喜欢看游戏,好奇一些经典设定,所以榜单上有「达奇上校」(战锤,下厨时戴耳机听着)、「Leya蕾雅」;因为一直要跟墙斗法,所以「不良林」期期不落;内容制作精良、趣味性强,所以持续关注「影视飓风」、「LKs」、「极客湾」;还有直接追更的「不明白播客」、「滇西小哥」、「徐云」等等个人博主。
2023,注意力被消遣型的内容,粘住了。2024,少看热闹多创造。
今年,库克的刀法更加随心所欲了,新款 MacBook Pro,硬是按照尺寸、类型、CPU、GPU、SSD 给你切出一个家族来。于是,14 还是 16;Pro 还是 Max;32 还是 64,512 还是 1T,个个都是选择题。
为了省事,先是点了一个 16 寸高配,加到 64G 内存,但由于64G 内存款等待时间实在太长,足有两期花呗,加上又看了专业人士的配置,感觉 32G 应该够我用了,于是退单,最终选择 M1 Max 24 核 GPU + 32G 内存 + 1T SSD 款,收货预期压缩到两周。
截至目前,使用了 4 个月,聊聊感受。
一句话来说,对于一个从 2017 款 15 寸 MBP 过来的用户,以上配置完全够用,体验提升很大。
刘海所处的一整条屏幕区域,都是 Safe Area,其他窗口进不去,只有从外接显示器往内建屏幕拖入窗口时,能暂时驻留这片区域,不被自动弹开。其实没啥用。跟我使用习惯类似的用户,完全享受不到苹果所说的「额外赠送显示面积」,因为我们永远隐藏菜单栏,隐藏 Dock,这一片只能放菜单栏的区域,约等于无。反而还要想办法找黑色壁纸,把刘海藏起来。
通过以下方式,使用苹果的 Accelerate Framework,榨取新硬件带来的性能提升。R 自带了编译好的 vecLib 链接,自行手动指向一下即可。
cd /Library/Frameworks/R.framework/Resources/lib
ln -sf libRblas.vecLib.dylib libRblas.dylib
而对于 Python numpy 则有:
方案一:pip 源码安装,指定编译参数,详情可参考这个 gist。
conda install cython pybind11
pip install --no-binary :all: --no-use-pep517 numpy
然后记得编辑 ~/.condarc,开启
pip_interop_enabled: true
最后,Homebrew 安装的 mambaforge 每次更新自身,会删除所有安装的包,我目前暂时的做法是,在 ~/.condarc
内再指定一下用户目录,如下 pkgs_dirs
channels: [conda-forge]
pip_interop_enabled: true
repodata_threads: 4
pkgs_dirs:
- ~/.local/python
方案二(个人推荐):指定 conda 安装参数 libblas=*=*accelerate
,比如我现在用 micromamba:
micromamba install numpy "libblas=*=*accelerate"
需要注意的是,为了防止运行 conda update --all
升级导致 accelerate 失效,得固定住这个设定,让 conda 记住它。以 micromamba 为例,编辑 ~/micromamba/envs/env_name/conda-meta/pinned
,加入一行 libblas=*=*accelerate
。
昨天晚上看 WWDC 开场演讲,满心以为要发布点能干重活的 Mac,结果一粒沙子都没看见,导致今天上班都有点恍惚。提到 Mac,R 社区 rfordatascience/tidytuesday 2020 年第 52 周的数据 The Big Mac index 正好也是讲 Mac 的,关公战秦琼,拉出来看一看,玩一玩。
library(tidyverse)
big_mac <- read_csv("https://github.com/TheEconomist/big-mac-data/releases/download/2021-01/big-mac-full-index.csv") |>
janitor::clean_names()
# A tibble: 1,386 x 19
date iso_a3 currency_code name local_price dollar_ex
<date> <chr> <chr> <chr> <dbl> <dbl>
1 2000-04-01 ARG ARS Argentina 2.5 1
2 2000-04-01 AUS AUD Australia 2.59 1.68
3 2000-04-01 BRA BRL Brazil 2.95 1.79
4 2000-04-01 CAN CAD Canada 2.85 1.47
5 2000-04-01 CHE CHF Switzerla… 5.9 1.7
6 2000-04-01 CHL CLP Chile 1260 514
7 2000-04-01 CHN CNY China 9.9 8.28
8 2000-04-01 CZE CZK Czech Rep… 54.4 39.1
9 2000-04-01 DNK DKK Denmark 24.8 8.04
10 2000-04-01 EUZ EUR Euro area 2.56 1.08
# … with 1,376 more rows, and 13 more variables:
# dollar_price <dbl>, usd_raw <dbl>, eur_raw <dbl>,
# gbp_raw <dbl>, jpy_raw <dbl>, cny_raw <dbl>, gdp_dollar <dbl>,
# adj_price <dbl>, usd_adjusted <dbl>, eur_adjusted <dbl>,
# gbp_adjusted <dbl>, jpy_adjusted <dbl>, cny_adjusted <dbl>
1,386 x 19 的小数据集,19 列看起来似乎很多,看列名,其实除去日期、国家代码、货币代码、国名、汇率之后,剩下的都是 Big Mac 指数在 USD、EUR、CNY、JPY 等常见货币之间的换算关系,可以理解为一回事。
那么,何为 Big Mac Index?首先,这里的 Mac 不是💻电脑,而是🍔巨无霸汉堡;其次,要理解这个巨无霸指数是什么意思,得先了解一个概念——PPP(purchasing power parity),它是这个指数成立的前提。那么,PPP 又是什么呢?它译作「购买力平价」,大意是这样的:世界各国都有自己的货币,自成体系,不好直接比较说一块人民币换一块美刀是高了还是低了。为了方便比较,它先做了一个假设,假设一些全球流通的商品,价值在哪里都是一样的,那么,只要比较这些商品在各地的价格,再结合当时的实际汇率,我们就能顺理成章的推断当地货币是被高估了还是被低估了。
那选什么商品好呢,最起码,它要既方便简单操作,且又全球流通。提出这个指数的《经济学人》说,当然是大汉堡了,并且自苹果发布 Mac(Macintosh)的 1984 年之后的两年起,《经济学人》开始坚持每年出版一次汉堡指数,帮助大家据此理论,简单评估汇率的差异。
那么,道理我都懂,汉堡为什么那么大,到底什么是巨无霸指数呐?再举个实例,假如同样一个麦当劳大汉堡,在美国卖 6 块钱一个,在中国卖 24 块钱一个,那么,两地汉堡指数是 6:24,即 1:4。那么,现在美元与人民币的汇率是多少呢,是 1:6.4,根据 PPP 理论,与实际汇率相比,通过计算 (1/6.4)/(1/4)-1 = -38%,于是我们说:跟实际汇率相比,人民币相对于美元,被低估了 38%。啥意思?人民币应该据此升值到四块兑一块美金吗?哦,$699 的 RTX 3080 只需 ¥3,355 🎉🎉🎉(大方点算你 20% 增值税)。
话虽如此,但是在实际生活当中,决定一件商品价格的因素可能有很多,比如开店的房租、人工、税收、消防等成本,再比如居民的收入水平等等,可能为了让这个指数看起来不那么粗暴,经济学人又指标数据分作了两个部分,一半是上述逻辑所讲的直接换算,一半则是后边的几列列名带 adj/adjusted
的数值,它们是融合了人均 GDP 之后的调整结果。
怎么个调整法呢,总体预期是:穷国便宜,富国更贵。上图也可以印证着一预期,具体计算详情可以参考原 GitHub 仓库的 ipynb,引文的最后一个链接可直达。我们可以根据其中一个代码片段来推测,摘录如下:
big_mac_gdp_data[, adj_price := lm(dollar_price ~ GDP_dollar) %>% predict, by = date]
这是一段融合了 data.table + tidyverse + base R 三种风格的 R 代码,DT[i, j, by]
这个外框是 data.table,lm(dollar_price ~ GDP_dollar) %>% predict
这里是 tidyverse + base R。也就是说,以 date
为分组,获得 y = ax + b 的线性关系。这里的 y 是当个 date
窗口内的汉堡原价 dollar_price
,x 为人均 GDP GDP_dollar
,货币单位,顾名思义,都是换算过的美金。由此,一组 date 决定一条线,线定下来了,游离于线外的老点在新线上的新位置,自然也就出来了。
数据不一样了,结论自然也不一样了,新的调整结果下,我们上边的人民币被低估的结论,甚至都反过来了。不如直接看历年来的当地价格变化吧,2000 年以来,难以想象,日本、台湾两地还曾降过价,内地和香港,价格都翻了一倍多,只是一时没有通胀数据,不好结论。
big_mac |>
filter(date >= "2000-01-01",
name %in% c("China", "Hong Kong", "Taiwan", "Japan", "Korean")) |>
ggplot(aes(x = date, y = local_price, color = name)) +
geom_line(show.legend = FALSE) +
facet_wrap(~name, scales = "free_y") +
expand_limits(y = 0)
Source:
自己编译 R 有很多好处,比如可以按需引入 OpenBLAS,用以加速一些线性代数和矩阵运算;但是,要想获得跟官方二进制包一样的开箱即用的所有能力(capabilities()
),恐怕就需要耗费大量额外的精力:1、2,这个编译过程中,有很多编译环境、编译参数、依赖组件的问题需要自行解决,还不止于此,由于是自行编译的 R,那么库/包的安装也需要走源码编译,于是乎,包也有依赖问题要自行解决,尤其是遇到地理数据时,依赖很深,安装过程可能令人沮丧。
> capabilities()
jpeg png tiff tcltk X11
TRUE TRUE TRUE TRUE FALSE
aqua http/ftp sockets libxml fifo
TRUE TRUE TRUE TRUE TRUE
cledit iconv NLS Rprof profmem
TRUE TRUE TRUE TRUE TRUE
cairo ICU long.double libcurl
TRUE TRUE TRUE TRUE
R 语言相关的数据仓库 tidytuesday 第 20 周的数据是美国的宽带使用情况,具体一点,是用上了 25Mbps 及以上带宽的人口比例。分作两份数据,broadband.csv
是包含 2017 和 2019 两年且精确到县一级的数据,broadband_zip.csv
是包含 2020 且精确到邮编级别的数据。
这种情况,当然是做个地图最吸引眼球,我们来尝试一下。大体上,应该是分作两块:
假如我们只对比 2019 和 2020 两年,对疫情前后带宽 >= 25Mbps 覆盖变化有个基本画面。那么,对于 2020 年的数据,因为它是细分到邮编街区级的,不好直接对比,我们得再找找合适的数据源,根据 zipcode 匹配出来这一级的人口数,反算实际覆盖人口,最后再聚合到县一级,重新算县一级的覆盖率,方便后续的对比绘图。不过,对于这个问题,数据提供方比较贴心的给了提示,告诉我们可以试试看 {zipcodeR} package。
再看第二块,地图数据。几个要求,覆盖新近的行政区划变更,带 fips 等 id 方便建立准确的映射关系,最好附带了合适的投影模式(比如 albers),且处理好了阿拉斯加、夏威夷的摆放。带着这些需求关键字,一番搜索,网上提到比较多的,一是 ggplot2 自带的 map_data
,它的使用很简单,问题是没有 county_id,只有 county_name,而名字又常变动还有不同写法的问题,不好结合其他数据来做映射; 一是 albersusa,看着也不错,可以仓库已经是 Archived 状态;一是 usmap,试了一下,相当推荐;还有通过这条 tweet 发现的 urbnmapr,也不错,很好定制。以下简单复述一下后两个方案。
先看数据,先是 2019 年的,只需稍加处理,比如把 county_id 通过 0 补齐 5 位数字,方便后续匹配地图数据,比如 county_id == fips 之类的。
# library(tidyverse)
# library(janitor)
# library(zipcodeR)
# library(scales)
# library(usmap)
# library(sf)
# library(urbnmapr)
bu19 <- read_csv('https://raw.githubusercontent.com/rfordatascience/tidytuesday/master/data/2021/2021-05-11/broadband.csv', na = c("-", "", NA)) |>
clean_names() |>
rename(state = st) |>
mutate(county_id = sprintf("%05d", county_id))
处理之后的结果,我们这里只关注 broadband_usage,它是 2019 的统计结果。
state county_id county_name broadband_availabil… broadband_usage
<chr> <chr> <chr> <dbl> <dbl>
1 AL 01001 Autauga Cou… 0.81 0.28
2 AL 01003 Baldwin Cou… 0.88 0.3
3 AL 01005 Barbour Cou… 0.59 0.18
4 AL 01007 Bibb County 0.29 0.07
5 AL 01009 Blount Coun… 0.69 0.09
6 AL 01011 Bullock Cou… 0.06 0.05
2020 年的数据,稍微复杂一点。
bu20 <- read_csv('https://raw.githubusercontent.com/rfordatascience/tidytuesday/master/data/2021/2021-05-11/broadband_zip.csv') |>
clean_names() |>
rename(state = st) |>
mutate(postal_code = sprintf("%05d", postal_code),
county_id = sprintf("%05d", county_id)) |>
# 分割
left_join(zip_code_db |> select(zipcode, population),
by = c(postal_code = "zipcode")) |>
mutate(q = population * broadband_usage) |>
group_by(county_id) |>
summarise(usage = sum(q) / sum(population))
left_join
之前的数据长这样,注意它是到 postal code 级别的细分数据。
# A tibble: 6 x 8
state county_name county_id postal_code broadband_usage
<chr> <chr> <chr> <chr> <dbl>
1 SC Abbeville 45001 29639 0.948
2 SC Abbeville 45001 29620 0.398
3 SC Abbeville 45001 29659 0.206
4 SC Abbeville 45001 29638 0.369
5 SC Abbeville 45001 29628 0.221
6 LA Acadia Parish 22001 70516 0.032
# … with 3 more variables: error_range_mae <dbl>,
# error_range_95_percent <dbl>, msd <dbl>
引入 zipcodeR 所带的 zip_code_db 数据库,我们可以很方便的获得 zipcode 这一级的人口统计数据,聚合到 county 之后,只保留我们需要的,如下:
# A tibble: 6 x 2
county_id usage
<chr> <dbl>
1 01001 0.377
2 01003 0.439
3 01005 0.292
4 01007 0.120
5 01009 0.197
6 01011 0.162
最后通过地图绘制库,分别绘制出来即可,这里两年两张图,usmap、urbnmapr 一人负责一张,简单对比一下。
首先,对于 usmap + 2019,只需要指定绘制精度级别、数据及列与 usmap 的映射关系即可,其他设定照着 ggplot2 就行了。为了清晰起见,我在这里将人口比例以 50% 为切线,做了一个二分,颜色突出的,是低于 50% 的县。
plot_usmap("counties", data = bu19 |>
rename(fips = county_id) |>
mutate(tier = cut(broadband_usage,
breaks = c(0, 0.5, 1.0),
include.lowest = TRUE,
right = TRUE)),
values = "tier",
size = 0.1) +
scale_fill_manual(values = c("#5ab4ac", "#f5f5f5"),
na.value = "#d8b365")
然后是 urbnmapr + 2020,过程类似:先指定精度,给你 sf 的选项,需要阿拉斯加等区域,打开它就行(这要在我朝,搞不好就是政治问题 😂️);关联数据,同样二分;后边就是接驳 ggplot2 接口,配置都一样。
get_urbn_map("counties", sf = TRUE) |>
left_join(bu20, by = c("county_fips" = "county_id")) |>
mutate(tier = cut(usage,
breaks = c(0, 0.5, 1.0),
include.lowest = TRUE,
right = TRUE)) |>
ggplot() +
geom_sf(aes(fill = tier), size = 0.1) +
coord_sf(datum = NA) +
scale_fill_manual(values = c("#5ab4ac", "#f5f5f5"),
na.value = "#d8b365") +
theme_void() +
theme(legend.position = "bottom")
二者,没啥大差别。小结起来:一,可能由于行政变更,2019 个别县映射不到新地图数据上;二,上述配置之下,urbnmapr 的地图初始粒度更细(海岸线、岛屿等);没具体统计,光看图,星星点点,较之 2019 年,一年下来,过 50% 人口的 25 Mbps 宽带覆盖率,是有明显改观的。
最后,要做颜色搭配的话,可以用这个网站,直观、易用:https://colorbrewer2.org/
Google Analytics 在大概 10 个年头之后,终于从 Universal Analytics 大改至现在的 Google Analytics 4,它的前身是 App + Web,顾名思义,应该是希望通过一套统一的事件数据模型来实现跨平台的数据采集,解放用户在 Firebase for mobile 和 GA for web,在 Pageview 和 Events 之间反复横跳的错乱神经。核心思想,暂时可以简单归结为一句:一切皆事件。事件不再是 Category、Action、Label、Value 四件套,而是一个自由扩展的 Key-Value 对象。作为一个离开很久的回归用户,感觉变化很大,部署更灵活,门槛也更高了。
gtag('event', 'view_item', {
currency: 'USD',
items: [
{
item_id: 'SKU_12345',
item_name: 'jeggings',
coupon: 'SUMMER_FUN',
discount: 2.22,
affiliation: 'Google Store',
item_brand: 'Gucci',
item_category: 'pants',
item_variant: 'black',
price: 9.99,
currency: 'USD',
quantity: 1,
},
],
value: 9.99,
});
老 GA 的一些功能,在 GA4 上基本都移植成了事件,比如 first_visit
,session_duration
这种以前不需要采集端关注的维度或指标,页面浏览也有了专门的 page_view
事件,描述 pageview 语境的 page_title
,page_path
,page_referrer
等等,则都是作为 page_view
这一事件的参数,放在参数对象里面,形如 gtag('event', 'page_view', {...})
。当然,GA4 的 SDK 把 pageview 这样的基本事件,都给我们集成好了,不需要自己去监控页面变化,去采集当页的 title,path,和 referrer 等。除此之外,GA4 也为一些常用场景所涉及的行为事件,做了模板,便于我们选用,比如电商监测,我们可以直接根据它的模板,实现对浏览商品、浏览商品列表、选择商品、浏览推广、选择推广等等电商场景的常见动作的数据收集。我们要做的,只是拿着模板找到触发位置,去接收对应的参数值即可,比如上面的 view_item
例子。
对于这个博客来说,需求很简单,只要采集 4 个东西:
以上是个人需求和 GA4 简要的事件逻辑梳理。使用工具,无论如何看文档是少不了的,其他细节可以自行去细看、动手玩。根据文档描述,我们可以这样把 GA4 事件分作两类:自动采集的、根据场景建议的(相当于提供一个模板)。注意这里的潜台词,获得这些功能的前提是使用 GA4 提供的 SDK,比如 gtag.js。如果使用 Measurement Protocol 而且又需要 GA4 默认报表的话,就需要拿着事件模板,自己去实现这些隐含信息的采集。
因此,我们加速工程的第一关,先面临 GA4 SDK 和 Measurement Protocol 的选择问题。SDK 体积大,可能加载慢,且涵盖了很多不需要的功能,线路基本被屏蔽;好处也很明显,省心。Measurement Protocol for GA4 好处是可以完全灵活自主,根本不需要走一条不通的路线去下载一个一百几十 K 的 js 文件(gzip 也有近 50K),但问题也很突出:要自己实现看得见看不见的背景功能。比如用户 ID、Cookie 机制、Session 机制、首访判断、互动判断等等一堆有的没的。而且需要注意,MP for GA4 现阶段还是 Alpha,原则上也是不建议用于生产。
Anyway,对于个人试玩来讲,没那么讲究,但,这里还是选择用现成的 SDK,因为 MP 没太玩明白,虽然模拟了 Client ID、Session 切换、首访判断等,对比原版,还是差了意思,将来确定好了再回头走这条路。现在先用现成的,对于这个 js 文件的加速,当然就是大家想的那样:
这里插一句,这个博客是 GitHub Pages,域名、DNS 等等都托管在 Cloudflare 上,用的也都是免费服务,这些配置这里就省略不细聊,基本就是登录 Cloudflare,进入这些功能页面,然后点几下鼠标的事情。
GA 加速分两段,上边讲的是 SDK 的下发;现在进入到数据的上传。上传阶段的加速,对应的其实是各种浏览器拦截插件的屏蔽。基本思想是在 Client - GA 之间加一层中转,变成 Client - Server - GA。这里的 Server 我们用自己网站的地址,并在上边做两件事情,一是代理 GA 接收并解析上传的原 GA 请求,二是将其加工并转发到 GA 服务器。因为我们用的是静态博客,不方便直接做这两件事,所以这里的 Server 我们借助 Cloudflare 提供的 Worker 功能,有免费额度,目标也很契合。思路梳理出来是这样的:
transport_url
为自己网站域名/g/collect
为上面 route 路径出来的结果,
v: 2
tid: G-ABCDEFG
gtm: 1234567
_p: 1039218207
sr: 2560x1440
ul: en-us
cid: 0123456789.9876543210
dl: https://placeless.net/blog/macos-dictionaries
dr: https://placeless.net/
dt: 《柯林斯双解》for macOS
sid: 1621444075
sct: 6
seg: 1
_s: 1
// Request Data
en=page_view
en=FCP&epn.value=1677&ep.metric_id=v1-1621444098204-8846853795459&epn.metric_value=1677&epn.metric_delta=1677&ep.metric_rating=good&up.color_mode=Dark&up.blocker=true
注意四个细节,一是 GA4 不再支持自定义 transport_type
,也就是说不能自主指定 xhr/image/beacon 三选一,系统完全接管了,优先 beacon,对于不支持的浏览器,系统会 fallback 到 Fetch POST,再不行才是最早先的像素图片 GET 形式;二是 /g/collect
改自定义路径,是在 gtag.js 里面,大致在 var c=wj(a.s(E.Ba),"/g/collect")
这个位置,请自行体会;三是 GA4 我目前没看到对 uip
参数的支持,也就是说不能像之前的 Measurement Protocol 那样追加 uip
参数,通过自行指定的 IP 地址,去覆写请求发起的源地址,比如这里的中转服务器地址,总之,通过我们上边的转发方式,所采集的用户地域分布,应该都是 Cloudflare 的 CDN 地域分布;最后,给 Cloudflare Worker 配置 route 时,对于 Jekyll 之类的静态生成器,请考虑贪婪匹配,比如 /ga
写成 /ga*
,不然 Jekyll 可能先返回了 405 错误,因为静态服务器确实无法支持你的 beacon POST。
鄙人在下刚好也是一个极端的广告屏蔽者,看到这里的用户,可以将 https://placeless.net/ma
加入屏蔽规则,即可完全拦截上边提到的采集内容。除了上边提到的 4 条,GA4 还有一些预置逻辑,比如 10 秒算一个 Engagement 事件等。GA4 发请求现在支持 batch 模式,也就是短时间内触发了好几条事件,系统会把它们合并到一起,公共内容放在公用参数里面,Payload 里面装载事件和事件本身的描述参数,换行分割。
至于如何采集 Web Vitals 相关指标、Ad Blocker 标记、Dark Mode 适配等、以及 GA4 的请求参数有哪里多了哪些各自意义是什么,这里就不赘述了。
最后放参考代码,先是 worker 的部分,骨干部分非常简单,基本就是官方文档的复制粘贴:
addEventListener('fetch', (event) => {
// 可以追加一些请求过滤和回应的处理逻辑
return event.respondWith(handleRequest(event.request));
});
async function readRequest(request) {
const { url, headers } = request;
const body = await request.text();
const ga_url = url.replace(
'https://你的域名/你的采集route',
'https://www.google-analytics.com/g/collect'
);
const nq = {
method: 'POST',
headers: {
Host: 'www.google-analytics.com',
Origin: 'https://你的域名',
'Cache-Control': 'max-age=0',
'User-Agent': headers.get('user-agent'),
Accept: headers.get('accept'),
'Accept-Language': headers.get('accept-language'),
'Content-Type': headers.get('content-type') || 'text/plain',
Referer: headers.get('referer'),
},
body: body,
};
return new Request(nq);
}
async function handleRequest(event) {
const newReq = await readRequest(event.request);
event.waitUntil(fetch(newReq));
return new Response(null, {
status: 204,
statusText: 'No Content',
});
}
再是页面插码的部分:
<script async src="/js/YOU-GA-SDK.js"></script>
<script>
// function color_mode() {...}
window.dataLayer = window.dataLayer || [];
function gtag() { dataLayer.push(arguments); }
gtag('js', new Date());
gtag('config', 'GA4-Measurement-ID', { transport_url: 'https://YOUR-DOMAIN' });
// gtag('set', 'user_properties', { color_mode: color_mode() });
</script>
最近玩 R,出现了很多有意思的画面,今天来复盘一到错题,温故知新。初始问题是这样的:帮忙看看用户的购买间隔,为沟通机制提供数据参考。🤔️ 开动,38万行数据,第一锤子下去,敲出来的代码如下:
library(tidyverse)
library(lubridate)
data %>%
filter(created_at >= '2020-07-01',
created_at < '2021-01-01',
is.na(refund_state)) %>%
select(member_no, union_no, created_at) %>%
mutate(created_at = date(created_at)) %>%
group_by(member_no, union_no) %>%
slice_max(created_at, n = 1) %>%
ungroup() %>%
select(-union_no) %>%
distinct() %>%
group_by(member_no) %>%
arrange(created_at) %>%
mutate(
n_days = n(),
tier = case_when(
n_days == 1 ~ "1d",
n_days == 2 ~ "2d",
n_days == 3 ~ "3d",
n_days > 3 ~ "3d+"),
previous = lag(created_at),
dd = interval(previous, created_at) %/% days(1))
使用 tidyverse 语法的好处之一就是易读,这里的逻辑是这样的:
filter
筛选必要的行select
筛选必要的列mutate
更改日期时间为仅日期(间隔日,不需要理会时间点)group_by
按用户和单号进行分组filter
同一单选最大日期(发现同一单有时间不一致的情况)ungroup
取消分组select
丢掉单号列distinct
针对剩下的用户id和下单日期,去重group_by
再次按用户id分组arrange
按日期在分组内排序mutate
计算活跃(购买)天数并按天数分层lag
找出当前日期的上一个日期interval
计算两个日期之间的间隔跑跑看,猜怎么着?看起来好像没啥问题,跑起来慢的要死,两分钟没有结束迹象,这种情况,显然是哪里犯傻了,得先揪出明显的性能瓶颈再说。这时候 tidyverse 语法的另一个好处又体现出来了,它的代码组织形式,对于一行行或一块块的问题排查,非常友好。我们可以先采用二分法,以 ungroup
为界,先看上半部分。到底慢在哪里,我们可以借助 profvis 这个工具来分析,把需要检测的代码塞到 profvis({...})
内部即可。上半部分的结果如下图
直接使用 slice_max
筛选最大值是不合适的,消耗了 11 秒多,先 arrange
对这两个关键列进行排序,再 slice_head/tail
会快不少,但它显然不是大头,这十来秒不会是漫长无应答的主要原因。
另外,我们的目的,只是为了获得去重的用户id和下单日期,引入 union_no
单号似乎毫无必要,但是这一顿操作只是为了修正一单跨两个日期的情况;但话又说回来,虽然发现存在一单有多个时戳的情况,到底跨没跨日期,其实可以另行探查;而探查的结果是,没有 😂️,所以上半部分的最终的改进结果如下:
data %>%
filter(created_at >= '2020-07-01',
created_at < '2021-01-01',
is.na(refund_state)) %>%
select(member_no, created_at) %>%
mutate(created_at = date(created_at)) %>%
distinct()
# ...
上半部分,就只是一百毫秒左右的事情了。但是问题又来了,发现两部电脑的运行结果不一致,还好略有经验,猜测是时区处理的问题,编辑 ~/.Renviron
文件,加入 TZ
参数即可,我的配置如下,其他选项可参考:
TZ="UTC"
RETICULATE_PYTHON="/usr/local/bin/python3"
R_MAX_VSIZE=16Gb
http_proxy=http://127.0.0.1:1091/
https_proxy=http://127.0.0.1:1091/
再来看后半部分,它只可能慢在 mutate 内部,推测着,先把 interval 这行注释掉,运行结果如下图:
case_when
和 lag
都很慢,加起来十几秒,不过,这十几秒也让最终 boss 浮出了水面:interval
。这里其实有点奇怪,单拿出来跑, interval(df$a, df$b) %/% days(1)
是很快的,应该是嵌套到 mutate
内部之后,失去了 vectorized 特性,变成了 row-wise 的循环。改用 base R 提供的 lagged differences 算法之后,就直接光速了:
data %>%
filter(created_at >= '2020-07-01',
created_at < '2021-01-01',
is.na(refund_state)) %>%
select(member_no, created_at) %>%
mutate(created_at = date(created_at)) %>%
distinct() %>%
group_by(member_no) %>%
arrange(created_at) %>%
mutate(
n_days = n(),
tier = case_when(
n_days == 1 ~ "1d",
n_days == 2 ~ "2d",
n_days == 3 ~ "3d",
n_days > 3 ~ "3d+"
),
dd = c(0, diff(created_at)))
改动如上,原 lag
和 interval
并作一行 c(0, diff(created_at)))
,至此,从遥遥无期到十几秒跑完,算是一个里程碑,最后还剩下一个 case_when,一时想不出什么办法。
data.table 是 R 宇宙当中以性能见长的数据处理库,它的语法抽象而简洁,几乎一句话就可以概括:DT[i, j, by]
。只是一旦 chain 到一起,(至少对我来说)就有点绕毛线了,因为它的 chain 是这样的:
# data.table 原生 chain
dt[][
][
][
]
# 当然也可以使用 magrittr 的 pipe
# 但内容一多,括号里面的内容就不好分割了
dt %>%
.[] %>%
.[] %>%
.[]
tidyverse 语法虽然看似罗嗦,但是对比之下,在 base、tidy、data.table 这三大宇宙当中,一点点语法冗余,换来更好的排版和阅读便利,还是非常值得的。为了二者兼得,tidyverse 还在 dplyr 的基础上,提供了一个桥接方案 dtplyr,桥接的后端就是 data.table。它会在运行之前,先将 dplyr 语法转义为 data.table 语法,以期实现「写的舒服」「跑的还快」的双重目的。这个方案在之前的一些小测试中,我并没发现有大的性能差异,最近重建了 R 环境,较为仔细地完成了 openblas 和 libomp 的编译支持,正好一试。
使用 dtplyr 并不算麻烦,可以把 dplyr 草稿纸,注意两件事就好:一是通过 data.table 来读入数据,获得 data.table 结构;二是,操作的开头需要调用 lazy_dt
创建(或者说告知这是)一个草稿,中间 dplyr 等 tidyverse 那些逻辑正常写,结尾再调用 as_tibble
或 as.data.table
或 as.data.frame
来获得完整结果(或者说告知执行)。
dt %>% # <----
lazy_dt() %>% # <----
filter(created_at >= '2020-07-01',
created_at < '2021-01-01',
is.na(refund_state)) %>%
select(member_no, created_at) %>%
mutate(created_at = date(created_at)) %>%
distinct() %>%
group_by(member_no) %>%
arrange(created_at) %>%
mutate(
n_days = n(),
tier = case_when(
n_days == 1 ~ "1d",
n_days == 2 ~ "2d",
n_days == 3 ~ "3d",
n_days > 3 ~ "3d+"
),
dd = c(0, diff(created_at))
) %>%
as.data.table() # <----
性能差异非常明显,case_when
转义成跑在 data.table 上的 fcase
之后,快了一个数量级。两个部分加一起总共耗时约两秒钟,并且注意内存那一栏,相较于 dplyr,消耗也小了很多。
# system.time({...})
user system elapsed
2.915 0.037 2.098
把末尾的 as.data.table()
替换成 show_query()
即可获得翻译结果,如下:
unique(`_DT2`[created_at >= "2020-07-01" & created_at < "2021-01-01" &
is.na(refund_state), .(member_no, created_at)][, `:=`(created_at = date(created_at))])[order(created_at)][,
`:=`(c("n_days", "tier", "dd"), {
n_days <- .N
tier <- fcase(n_days == 1, "1d", n_days == 2, "2d", n_days ==
3, "3d", n_days > 3, "3d+")
dd <- c(0, diff(created_at))
.(n_days, tier, dd)
}), by = .(member_no)]
使用 dtplyr 和 data.table 前后的火焰图,也能看得出大不同:
dtplyr + data.table 火焰图
dplyr 火焰图
Unit: seconds
expr min lq mean median uq max neval
dp 18.792854 19.282197 19.793766 19.602105 20.323187 20.96849 5
dt 2.163451 2.220804 2.235009 2.234266 2.277006 2.27952 5
# 注释掉 case_when 和 diff 两处运算之后的对比
Unit: milliseconds
expr min lq mean median uq max neval
dp 1874.7782 1932.8641 2036.7339 2041.8302 2060.804 2273.3928 5
dt 134.3752 138.8937 155.6884 141.1073 142.066 221.9998 5
对于数十万及以上规模的数据集,dtplyr + data.table 速度提升明显。
样例结果绘图
不考虑 iCloud 污染和凿壁借光,国行 Apple Watch 开启 ECG,我主要参考了三个说法:1、2、3。同属一个派别,即备份修改还原法。在当前的官方新版系统 iOS 14.4 和 watchOS 7.3.3 正式版的背景之下,均以失败告终。
此时做了以下动作:
...heart-rhythm.plist
文件而且,这个过程反复尝试过了好几遍,ECG都没有正常工作,在手表上按 ECG App 提示请先在手机 Health App 上做配置,而一旦进入 Health App 配置又会遭遇「不支持你所在的地区」。
阅读了 1 的后续评论之后,尝试了以下动作:
引入的变量较多,无法确定其中的步骤 1、4 和 6 是不是必要的,总之,Public Beta 很关键。等待配置加载完成,按下手表上的 ECG App,不再有配置和地区提示,直接进入使用说明,跳过之后,进入红心飘荡,功能正常,Yeah!。
Miguel Grinberg 曾在 2017 PyCon 上讲过一个例子:你可以想象一个象棋大师跟一堆人车轮战,大师不会一人一场的来,通常是挑战者排成一排大师,大师依次走过去,路过一人走一步棋,且不在原地等待对手反击,而是直接走向下一个对手,如此往复,直到下完。
这么做,效率显然高了一大截,我们可以做个计算:假如有 24 个对手,大师走一步棋需要 5 秒,对手走一步平均要 55 秒,一局算 30 个回合,如果是一个一个来,下完一局再进行下一局,那么合到一起就是 (55 + 5) * 30 = 1800 秒一局,总共需要 12 个小时才能下完;如果改成上面的车轮战方式,则只需要 24 * 5 * 30 = 1 小时。
这大概就是 Async IO 了。需注意的是,Async IO 跟往常说的多进程、多线程并无直接关系,它是单线程单进程的设计,只是给人一种并发的感觉。范围涵盖上,Concurrency 包含 Parallelism,然后 Threading 和 Async IO 属于前者,Multiprocessing 属于后者;另外,Async IO 是跟语言无关的编程范式,每种语言都有各自的实现,Python 是通过 asyncio
这个包来提供相关接口,发展过程中,设计改变很大,这里以 Python 3.7+ 为例,仅为个人在工具应用层面的粗浅理解,并不涉及工具底层的设计和实现。
async def g():
# Pause here and come back to g() when f() is ready
r = await f()
return r
使用 async def xxx
的形式来定义异步功能,它会引入一个叫作 Native Coroutine 的东西,接管底层工作。async with
和 async for
等也是有效写法。
使用 await
获取异步函数的结果,在函数内调用 await
会挂起当前函数(比如 g()
),并将当前函数加入到 event loop 中,等待 await 后边(比如 f()
)获得结果之后,再通知当前函数继续执行。
使用 asyncio.create_task()
来计划一个任务,使用 asyncio.gather(*tasks)
来计划多个任务(或者说是将一系列 corutines 加入到单个 future 中),也就是说输入一堆任务(通常是以星号开头的展开形式),等它们全部完成,输出这些任务的结果清单。使用 asyncio.as_completed
来监控任务的完成情况。最后,使用 asyncio.run()
来执行任务队列。
搭配 asyncio
的常用包有不少,比如网络相关的 aiohttp,比如文件相关的 aiofiles,以自己的应用场景举个例子:
import json
from sys import argv
import pandas as pd
import asyncio
import aiohttp
import aiofiles
from tqdm.asyncio import tqdm
def read(file):
pass
def getToken():
pass
async def update(token, session, id, msg):
async with session.put('url', json={"id": id, "msg": msg}) as response:
text = await response.text()
async with aiofiles.open(log_file, 'a') as f:
await f.write(f'{text}\n')
async def main():
data = read(argv[1])
print(data.tail(5))
print('Press Enter to continue...')
token = getToken()
async with aiohttp.ClientSession() as session:
tasks = [update(token, session, row['id'], row['msg'])
for _, row in data.iterrows()]
results = [await t for t in tqdm.as_completed(tasks, leave=False)]
return results
asyncio.run(main())
应用到实际案例当中,总会引入了一些语境本身的复杂性,比如这里的 aiohttp 带来的 session 管理(可以理解为浏览器窗口,在一个窗口中管理多个标签页),比如 tqdm 相关的异步任务进度监控(注意 gather 和 as_completed 的区别,以及 as_completed 之前,我们在创建 tasks 之时,还不需要 await 获得结果),剥离开来单独看,也就相对清楚了。
今年的主题是放飞自我,做更多尝试,实现更高效的输入,获得量身定做般的成就感。
先尝试了小鹤双拼的形码方案,发现并不适合自己。鹤形,在拼音的基础上,引入了类似五笔的拆字规则,以期通过双拼加形码来解决重码问题,这就要求我们需要熟记几乎所有单字和词组的音、形编码,输入过程拼音、五笔双重袭脑,相互角力,堪比一心二用,对于习惯于读音思考的我来说,起点实在有些高。玩起来很有意思,只是坚持不下去。
但是,这个过程加深了我对 Rime 输入法的理解,也启发了我实现一些定制点子的思路,再借助网上各位的分享和贡献,汇聚成以下的:鼠须管配置 2021。
主要依赖项:
Python 生态,做前期数据处理,大致路径如下:
placeless_flypy.schema.yaml
这个输入方案的基本前提:一是仅用鼠须管输入中文,ascii 或者说西文,直接 Ctrl + Space 切到 macOS 的默认 ABC;二是尽量以长句为输入单元,不打断整句的表达节奏。
# Rime schema
# encoding: utf-8
schema:
schema_id: placeless_flypy
name: 小鹤双拼
version: "0.1"
author:
- double pinyin layout by 鶴
- Rime schema by 佛振 <chen.sst@gmail.com>
- Mod by placeless
description: |
小鹤双拼自定义方案。
# 基本不用切换,因为不用扩展字符集,也不用 emoji
switches:
- name: full_shape
reset: 0
states: [ 半角, 全角 ]
- name: ascii_punct
states: [ 。,, ., ]
# 只有简化字中文,所以
# 删掉了 ascii_composer
# 删掉了 ascii_segmentor
# 删掉了 filters
# selector 提前,方便;次选及'一选
engine:
processors:
- recognizer
- key_binder
- speller
- selector
- punctuator
- navigator
- express_editor
segmentors:
- matcher
- abc_segmentor
- punct_segmentor
- fallback_segmentor
translators:
- punct_translator
- script_translator
# 增加了 :./-=_+ 作为输入码
# 便于在打字中,不中断地输入 3.14、2:00-3:00 等
# 始码限制为:仅字母和数字
speller:
alphabet: 'zyxwvutsrqponmlkjihgfedcba0987654321:,.!()/-=_+'
initials: 'zyxwvutsrqponmlkjihgfedcba0987654321'
algebra:
- erase/^xx$/
- derive/^([jqxy])u$/$1v/
- derive/^([aoe])([ioun])$/$1$1$2/
- xform/^([aoe])(ng)?$/$1$1$2/
- xform/iu$/Q/
- xform/(.)ei$/$1W/
- xform/uan$/R/
- xform/[uv]e$/T/
- xform/un$/Y/
- xform/^sh/U/
- xform/^ch/I/
- xform/^zh/V/
- xform/uo$/O/
- xform/ie$/P/
- xform/i?ong$/S/
- xform/ing$|uai$/K/
- xform/(.)ai$/$1D/
- xform/(.)en$/$1F/
- xform/(.)eng$/$1G/
- xform/[iu]ang$/L/
- xform/(.)ang$/$1H/
- xform/ian$/M/
- xform/(.)an$/$1J/
- xform/(.)ou$/$1Z/
- xform/[iu]a$/X/
- xform/iao$/N/
- xform/(.)ao$/$1C/
- xform/ui$/V/
- xform/in$/B/
- xlit/QWRTYUIOPSDFGHJKLZXCVBNM/qwrtyuiopsdfghjklzxcvbnm/
# 当前只有八股文简化字和袖珍简化字词库
# 其它词库,在 extended 中增加引用
# 繁体词库编译错误,需要修改前面的 switcher 和 filters
# 增加八股文简化字语言模型,优化长句输入
translator:
dictionary: extended
prism: placeless_flypy
contextual_suggestions: true
max_homophones: 7
grammar:
language: zh-hans-t-essay-bgw
# 无前缀输入简单的预制表情和键盘符号
# 小鹤双拼没有 bq 和 kb 的编码
punctuator:
import_preset: default
symbols:
"bq": [😂️, 😅️, 🎉, 🐂, 😱️, 👌, 😇️, 🙃️, 🤔️, 💊️, 💯️, 👍️, 🙈️, 💩️, 😈️ ]
"kb": [⌘, ⌥, ⇧, ⌃, ⎋, ⇪, , ⌫, ⌦, ↩︎, ⏎, ↑, ↓, ←, →, ↖, ↘, ⇟, ⇞]
half_shape:
"\\" : "、"
"#" : "#"
"@" : "@"
"~": "~"
"/": "/"
"'": {pair: ["「", "」"]}
"[": "["
"]": "]"
"{": "{"
"}": "}"
"*": "*"
"%": "%"
"<" : ["<", "《"]
">" : [">", "》"]
recognizer:
import_preset: default
patterns:
punct: "^(bq|kb)$"
# 💊️,只提供两个候选项
# 分号次选,空格或引号一选
# 首尾衔接,轮回翻页
menu:
page_size: 2
alternative_select_keys: "';"
page_down_cycle: true
# Tab、Shift+Tab 上下翻页
# Control+p、Control+n 上下翻页
# Control+k = Esc,清除所有输入
key_binder:
bindings:
- { when: composing, accept: Tab, send: Page_Down }
- { when: composing, accept: Shift+Tab, send: Page_Up }
- { when: composing, accept: Control+k, send: Escape }
- { when: composing, accept: Control+p, send: Page_Up }
- { when: composing, accept: Control+n, send: Page_Down }
extended.dict.yaml
八股文简化字 + 袖珍简化字,个人暂时够用。如外挂其它网络词库,遇到繁简混合,可能会编译错误,需要在上边的方案里,加入对应的 switcher 和 filters 调用 opencc 进行繁简处理。
# Rime dictionary
# encoding: utf-8
---
name: extended
version: "2020.12.30"
sort: by_weight
vocabulary: essay-zh-hans
use_preset_vocabulary: true
import_tables:
- pinyin_simp
- placeless_punct
...
pinyin_simp.dict.yaml
基于 8105 个常用标准汉字匹配、剪裁自带的袖珍简体字词库
# Rime dictionary
# encoding: utf-8
#
---
name: pinyin_simp
version: "0.2"
sort: by_weight
...
# zh_simp 精简 -> 8105
placeless_punct.dict.yaml
为了方便混和输入符号和中文,将以下个人常用符号编入了码表。通常来讲鼠须管的默认状态下,像逗号、句号等,输入即触发上屏动作的,现在就不行了,需要在词语或整句输入完成之后单独敲一下。不需要选词,单个的逗号句号会直接作为中文标点上屏。
# Rime dictionary
# encoding: utf-8
#
---
name: placeless_punct
version: "0.2"
sort: by_weight
...
# 个人土方
. . 1
, , 1
: : 1
/ / 1
- - 1
_ _ 1
= = 1
+ + 1
0 0 1
1 1 1
2 2 1
3 3 1
4 4 1
5 5 1
6 6 1
7 7 1
8 8 1
9 9 1
default.custom.yaml
patch:
schema_list:
- schema: placeless_flypy
switcher/hotkeys:
- "Control+grave"
squirrel.custom.yaml
因为只有两个候选,这里隐藏掉了候选词序号,并适当调整了元素间距,让只有两个候选词的候选框看着尽可能舒服一些。
patch:
# 通知栏显示方式以及 ascii_mode 应用,与外观无关
show_notifications_via_notification_center: true
# 以下软件默认英文模式
app_options: {}
style:
color_scheme: apathy
horizontal: true # deprecated
preset_color_schemes:
apathy:
author: "LIANG Hai | placeless"
candidate_list_layout: linear # stacked | linear
text_orientation: horizontal
inline_preedit: true
candidate_format: "%c'%@''"
comment_text_color: 0x999999
corner_radius: 5
font_face: PingFangSC
font_point: 17
label_font_point: 17
back_color: 0xFFFFFF
text_color: 0x424242
label_color: 0xFFFFFF
hilited_candidate_back_color: 0xFFF0E4
hilited_candidate_text_color: 0xEE6E00
hilited_candidate_label_color: 0xFFF0E4
name: "冷漠/Apathy"
剪裁过的字词库和语言模型文件。
- essay-zh-hans.txt
- pinyin_simp.dict.yaml
- zh-hans-t-essay-bgw.gram
带 Touch Bar 的老款 MacBook Pro,它的 Esc 是不可能习惯的了了,对于喜欢 vi-mode 的用户来说,解决这个问题,有两个办法:一是使用 ⌃ + [;二将其他按键映射成为 Esc。这里从个人习惯出发,整理一下收集到的第二种方式,覆盖了这些 vi-mode:ZSH、VIM、VS Code、iPython、以及 Jupyterlab。
在 .zshrc 当中增加如下内容即可,注意后面的 timeout 不要设置为0,不然按键起不了作用。
bindkey -v
bindkey -M viins jj vi-cmd-mode
KEYTIMEOUT=25
开启和设定 vi 模式比较简单,麻烦的是配置响应 vim 模式的光标样式。我的日常情况是这样:Alacritty 终端默认启动一个 Tmux,运行 Zsh。一直以来,Esc 和光标,总有小问题,比如 Esc 突然响应不及时、卡死,光标突然不跟随模式而改变形状了。一番折腾,目前最推荐这个写法,使用时间不长,但暂时还没发现什么问题。
我用的 Neovim,只是配置文件名跟 Vim 不一样,写法是同样的。注意第二行,它的目的,是在两个 j 之间设定一个较短的等待时间,尽量缓解 j j 是正常输入不是模式切换时的停顿感。
inoremap jj <Esc>
:autocmd InsertEnter * set timeoutlen=200
:autocmd InsertLeave * set timeoutlen=1000
偏好设置当中搜索或直接在 settings.json 中加入以下内容即可。
"vim.insertModeKeyBindings": [
{
"before": ["j", "j"],
"after": ["<esc>"]
}
]
这里还有一点问题,中文输入模式下按下这两个键,会有混乱情况出现。比如,可以拿小鹤双拼输入 jpjt(解决)试试看。
创建或修改 ~/.ipython/profile_default/startup/keybindings.py
from IPython import get_ipython
from prompt_toolkit.enums import DEFAULT_BUFFER
from prompt_toolkit.filters import HasFocus, ViInsertMode
from prompt_toolkit.key_binding.vi_state import InputMode
ip = get_ipython()
def switch_to_navigation_mode(event):
vi_state = event.cli.vi_state
vi_state.input_mode = InputMode.NAVIGATION
if getattr(ip, 'pt_app', None):
registry = ip.pt_app.key_bindings
registry.add_binding(u'j',u'j',
filter=(HasFocus(DEFAULT_BUFFER)
& ViInsertMode()))(switch_to_navigation_mode)
可以使用这个插件 jupyterlab-vimrc 来自定义需要的键位。比如,在偏好设置的 vimrc 内添加以下内容即可模拟 j j 到 Esc。
"imap":
[
["jj", "<Esc>"],
]
别人不定期会同步很多 CSV 给我,每个超过 1GB,用不了多久,磁盘见满,批量压缩就很必要:
# for f in *.csv; do 7z a -tgzip "$f.zip" "$f"; done
for f in *.csv; do gzip $i; done
7z 的命令行形式,是我在 macOS 上偏好的跟压缩文件打交道的方式。偶尔遇到意外情况,比如解压报错,或是网上下载的字幕 rar,这时候会选择 unar。这两个工具都可以通过 Homebrew 获得。
上面 -t
参数之后,跟的是具体的压缩方式,默认是 7z,其他还有 zip、gzip、bzip2 以及 tar 等。如果没有 -t
参数,7z 也会根据输入的压缩文件后缀来给定一个压缩方式。
但压缩始终是个复杂话题,由于疏忽或是命名不规范,即便都是 .zip 后缀的文件,可能内含并不一致,处理起来,也要见机行事。
想要知道一个压缩文件的信息,则可以通过 zipinfo 或者 file 命令来获得:
$ file *.zip
a.zip: gzip compressed data, was "affin...
b.zip: Zip archive data, at least v?[0x314] to extract
c.zip: 7-zip archive data, version 0.4
$ zipinfo b.zip
Archive: b.zip
Zip file size: 1659 bytes, number of entries: 1
-rw-r--r-- 6.3 unx 9898 bx defN 20-Apr-23 00:09 affinity_categories.csv
1 file, 9898 bytes uncompressed, 1479 bytes compressed: 85.1%
上面的 a.zip,b.zip,c.zip,都是同一个文件的不同压缩方式,如果要预览压缩文件里边的内容,需要我们根据压缩方式,区别对待:
对于使用 gzip 压缩的 a.zip 文件,macOS 上可使用 gunzip 或 gzcat(非zcat)再搭配 head 或者 less 命令来预览内容:
gzcat a.zip | head -5
# or
gunzip -c a.zip | less
而对于使用 Zip 压缩的 b.zip 文件,需使用 unzip:
unzip -c b.zip | less
对于 7z,预览要更麻烦一点,可安装 7zcat 来实现:
7zcat c.zip | less
如果压缩包内有多个文件,可以先通过 unzip -l x.zip
或者 tar -tvf x.zip
参数来了解文件名情况,前者仅支持 Zip 压缩,后者支持 Zip 和 7z,但二者都不支持 gzip。但如果安装了 7z(brew install p7zip),我们可以通过 7z 来获得以上全部 3 种压缩包内的文件列表。
常用的 grep 命令也同样可以用于压缩文件的搜索,zgrep 面向 gzip,zipgrep 面向 Zip,7z 则暂时还没学到应对的方法。
zgrep Love a.gz | head -3
zipgrep Love b.zip | less
更复杂的一些的数据处理工具,比如 Pandas,可以使用 read_csv
等方法直接读取压缩数据,指定 compression=
参数即可,不过 7z 需要其他库的支持。有一点特别注意:目前 Pandas 不支持 tar,所以不要使用 .tar.gz 的形式来压缩数据文件。
压缩时指定更清楚的后缀,后续处理会更方便一些,gzip 用 .gz,Zip 用 .zip,7-zip 用 .7z;总的来说,好像 gzip/Zip 更容易处理一点。