Reading view

There are new articles available, click to refresh the page.

Running Page

心仪 @yihong618Running Page 久已,最近终于用上了❤️。优哉游哉跑步好几年,但数据维护基本没做,NRC 退出中国之后,更没导出保存,找出 Endomondo,记录不多,且它的 GPX 奇葩不带时间戳没法用。盘点下来,也就剩 Apple Watch 健康数据还能导出用一用。下图是结果,明细在这里

running page

我的使用方案

Running Page 项目支持很多设备和数据平台。我是 Apple Watch 用户,结合我的实际情况,我的选择是 GPX + Strava 方案。理由简单,简单试用对比之后,这个方案能比较顺畅地把历史存量和增量更新糊到一处,供 Running Page 调用。

Apple Watch 数据

下载开通 Strava 账号之后,即可在设置界面连接手表设备和手机的健康数据。开启自动上传功能,还能获得自动同步能力,无需特意开启 Strava App。设置完成之后,即可勾选导入最近 30 天的数据,也非常方便。

对于手表内的历史记录,则需要导出、处理、再上传。进入 iPhone 健康 App,点击右上角头像,下拉到底即可点击 Export All Health Data。这里面可能包含多种数据,我们只取其中的 workout-routes。

但是光通过这些轨迹路线还分不清是散步、跑步、骑车,需要另行过滤。比如可以通过 gpx.py 先对距离过短、距离过长、速度过快进行快速的路线分类,然后借助 Avenue GPX Viewer 等工具补充目测。清理前👇

moving page

分类完成之后,把结果扔到 Running Page 的 GPX_OUT 目录,然后跟着 Strava 章节拿到几个鉴权信息,再然后跟着 GPX_to_Strava 章节批量上传即可。

Python 数据可视化工具集 2024

借用 R4DS 一书中对数据探索工作的描述,主要包括三个关键部件:数据转换可视化图表数学建模。一直以来,在 EDA 场景中,我自己使用 R & Python 的强度是五五开,甚至略微更偏向 R 一些。因为 R 宇宙有 Tidyversedata.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()
shape: (5, 3)
monthhourn
i8i8u32
1117
121
13null
14null
15null
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()
shape: (5, 4)
yearcat_namen_videoranking
i32stru32u32
2016"People & Blogs…444
2016"Sports"149
2016"Howto & Style"415
2016"Autos & Vehicl…313
2016"Film & Animati…257

Polars & hvPlot

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。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

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,会变得不幸。

Bokeh

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()
Loading BokehJS ...
# 需要 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 的使用,好用是好用,微调也确实痛苦。

我在 YouTube 都看了些啥

watching history

自 2016 年 4 月中开始,至 2023 年底,年视频观看量从 1000+ 升至 4000,观看的频道也从 400+ 增至 1400。除了中间的 2019 年不记得什么原因之外,总体趋势上,毋庸置疑,我在 YouTube 上逛的越来越宽了。当然,这可能跟上瘾和 YouTube 的推荐有效相关,类似每天总得看看 Twitter,同样我也越来越习惯性点开 YouTube,看看订阅的博主有没更新,看看首页推荐有没有感兴趣的视频。此外,还经常越过 Google,直接进 YouTube 搜索一些概念和教程。总之,确实越用越勤了。最开始,可能还偏重于学习,部分视频反复观看,越往后这个现象似乎就越少了。

videos-watched distribution

days-watched distribution

从这两张图的角度也能验证上面的结论,无论是从视频播放数量还是天数来看,偶然碰到然后不再见的频道,和经常光顾的频道,都逐年递增,逐渐上瘾。

dead videos

YouTube 的视频也并不永生,也有生老病死。这些历史播放记录中,相当一部分的视频已经被删除或被设置为私有,看不到了。2022 年的尤其多,现在也无法回溯源头,不知道是哪位或哪几位大哥放弃了。

audio language distribution

通过 YouTube API 抓取了这些视频的元数据,其中的 71% 是有标注声音语言代码的。语言编码标记千奇百怪,粗略统计了一下,有两个发现:一是未知语言(unset)的视频数量逐年变少,似乎越来越规范了;二是我看的中文视频越来越多了 orz。我有一个不负责任的观察,即,一般来说,YouTube 上的中文博主,消遣型的比较多。这么看来,我的播放记录显示我日渐消遣 orz。

workdayweekend

不知道播放记录里面的时间戳是点击时间还是看完时间,从我的记录来看,工作日和周末有些差异。上两图挑的 2023 年数据,这一年除了 12 月,其余时间还是在正经上班,有参考意义。看得出来,工作日的 YouTube 之约,是在下班之后,吃晚饭开始到睡觉前的时段;周末,则比较放飞自我,除了跑步、做饭的时段,整个白天都很随意。

month-weekday 2023

12 月大概是 11 号之后没上班,基本就没有所谓工作日和周末的区别了。

Attention Is All You Need

video categories

上图的内容分类,是先从 YouTube API 中扒取的 categoryId,然后根据网上找的映射关系匹配而得。排名依据,相当简单,就是视频播放量,多次播放算一次。有几个发现:一是科技类一直是历史观看榜的第一;二是它这个分类也挺随意的,不知是博主自己标的,还是系统分配的,比如标的教育类视频,其实是剧情解说;三,不过有些地方也符合直觉,比如教程类的视频确实看的少了,音乐和旅行类排行也确实是我的主动选择。

video channels

频道排行的规则稍微复杂一些,综合了视频量、观看月份量、观看次数、视频时长这几个因素,做了个草台评分,然后给出排名。尽量剔除博主更新频次低、我短期大量看某个频道等因素导致的偏颇。上图是各年按此规则得出的 Top20 频道。根据这个结果,这个评分规则还不理想,不过已经可以印证上边一些看法,尤其不得不承认几件事:一是英文>中文内容的变化;二是功用性内容的消费萎缩;三是消遣方向的多元。

如此错乱、跳跃,有我注意力转向的原因,也有博主更新热情消退的原因,还有铁拳力量的杀伤。最显眼的,回形针,公司莫名其妙被干没了;脱口秀,行业都基本被干趴了。

2023 年 Top20 频道,分析起来,有这些原因:我喜欢听侦探故事,所以榜首有「十三兰德」,中间有「X调查」;喜欢看游戏,好奇一些经典设定,所以榜单上有「达奇上校」(战锤,下厨时戴耳机听着)、「Leya蕾雅」;因为一直要跟墙斗法,所以「不良林」期期不落;内容制作精良、趣味性强,所以持续关注「影视飓风」、「LKs」、「极客湾」;还有直接追更的「不明白播客」、「滇西小哥」、「徐云」等等个人博主。

2023,注意力被消遣型的内容,粘住了。2024,少看热闹多创造。

MacBook Pro 2021 使用体验

今年,库克的刀法更加随心所欲了,新款 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,这一片只能放菜单栏的区域,约等于无。反而还要想办法找黑色壁纸,把刘海藏起来。

优点

  • 屏幕
    • HDR 影片的效果是前(MBP)所未有的
    • 用了就回不去的功能 😱
  • 2x 整数缩放
    • 清晰度提升,略有感知
  • 120Hz 高刷
    • 略有感知
    • 用一阵之后,跟老款对比,感知超级明显 😱
  • 剪刀键盘
    • 手感有提升
    • 用一阵之后:我原来用的是什么烂东西 😱
  • 散热和温度
    • 直接:我原来究竟用的是什么烂东西 😱
  • 外接显示器时的散热和噪音:
    • 我TM原来究竟用的是什么烂东西 😱
  • 外放喇叭
    • 大幅提升,明显感知
  • Fn 功能键
    • 拜拜 Touch Bar
  • Playgrounds + SwiftUI 终流畅

缺点

  • 打字时,薛定谔的选字框,不知道它会出现在哪里
    • 系统问题,重启/更新解决
  • Control Center 内存泄漏
    • 停止 Sharing 面板下的 AirPlay Receiver 服务
  • 软件兼容,没有安装 Rosetta,完全 Arm64 环境
    • RStudio(在 Daily 渠道找 Electron Desktop 解决)
    • Charles Proxy ❎
  • 刘海刺眼
    • 选一张合适的壁纸,实现隐藏
  • 键盘背光
    • 边缘透光,没有蝶式盖的住
  • Xcode
    • 安装、升级很慢,下载10分钟,安装2小时
  • 快速 Notes 偶尔崩溃
  • 开磁盘加密,开机慢一点,登入有进度条
  • 右上角图标区域载入慢,有时只有一个输入法图标
    • 更新系统之后,没有了
  • 沉,不好拿起;沉,很沉,单手伤
  • Wi-Fi 速度只有 866Mbps,降级了 🚨

配置经验

通过以下方式,使用苹果的 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

Big Mac Index

昨天晚上看 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 等常见货币之间的换算关系,可以理解为一回事。

Macintosh 128Kbig mac

那么,何为 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 Map:疫情这两年美国宽带覆盖有什么变化

自己编译 R 有很多好处,比如可以按需引入 OpenBLAS,用以加速一些线性代数和矩阵运算;但是,要想获得跟官方二进制包一样的开箱即用的所有能力(capabilities()),恐怕就需要耗费大量额外的精力:12,这个编译过程中,有很多编译环境、编译参数、依赖组件的问题需要自行解决,还不止于此,由于是自行编译的 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 且精确到邮编级别的数据。

这种情况,当然是做个地图最吸引眼球,我们来尝试一下。大体上,应该是分作两块:

  • 清理数据,统一精确到县(county)级别
  • 找一份新近的县级地图数据,映射我们清理好的数据

假如我们只对比 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")

us broadband usage 2019

然后是 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")

us broadband usage 2020

二者,没啥大差别。小结起来:一,可能由于行政变更,2019 个别县映射不到新地图数据上;二,上述配置之下,urbnmapr 的地图初始粒度更细(海岸线、岛屿等);没具体统计,光看图,星星点点,较之 2019 年,一年下来,过 50% 人口的 25 Mbps 宽带覆盖率,是有明显改观的。

最后,要做颜色搭配的话,可以用这个网站,直观、易用:https://colorbrewer2.org/

更快的GA4:通过 Cloudflare Worker 加速

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_visitsession_duration 这种以前不需要采集端关注的维度或指标,页面浏览也有了专门的 page_view 事件,描述 pageview 语境的 page_titlepage_pathpage_referrer 等等,则都是作为 page_view 这一事件的参数,放在参数对象里面,形如 gtag('event', 'page_view', {...})。当然,GA4 的 SDK 把 pageview 这样的基本事件,都给我们集成好了,不需要自己去监控页面变化,去采集当页的 title,path,和 referrer 等。除此之外,GA4 也为一些常用场景所涉及的行为事件,做了模板,便于我们选用,比如电商监测,我们可以直接根据它的模板,实现对浏览商品、浏览商品列表、选择商品、浏览推广、选择推广等等电商场景的常见动作的数据收集。我们要做的,只是拿着模板找到触发位置,去接收对应的参数值即可,比如上面的 view_item 例子。

对于这个博客来说,需求很简单,只要采集 4 个东西:

  • 文章的 Pageview
  • 网站/页面性能参数(Web Vitals
  • Dark Mode
  • Ad Blocker

SDK 还是 MP

以上是个人需求和 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 文件的加速,当然就是大家想的那样:

  • 直接下载 GA4 部署界面提供的、属于自己 Measurement ID 对应的 js
  • 将其保存到网站的 js 目录里,将下载路径从 GA 域名改成自己的相对目录
  • Cloudflare 开缓存

这里插一句,这个博客是 GitHub Pages,域名、DNS 等等都托管在 Cloudflare 上,用的也都是免费服务,这些配置这里就省略不细聊,基本就是登录 Cloudflare,进入这些功能页面,然后点几下鼠标的事情。

上传中转

GA 加速分两段,上边讲的是 SDK 的下发;现在进入到数据的上传。上传阶段的加速,对应的其实是各种浏览器拦截插件的屏蔽。基本思想是在 Client - GA 之间加一层中转,变成 Client - Server - GA。这里的 Server 我们用自己网站的地址,并在上边做两件事情,一是代理 GA 接收并解析上传的原 GA 请求,二是将其加工并转发到 GA 服务器。因为我们用的是静态博客,不方便直接做这两件事,所以这里的 Server 我们借助 Cloudflare 提供的 Worker 功能,有免费额度,目标也很契合。思路梳理出来是这样的:

  • 在 Cloudflare 上做好域名解析
  • 创建一个 Cloudflare Worker
  • 参考官方文档,写一个简单的转发 GA 请求的 Worker
  • 为 Worker 映射一个自己网站地址的 route
  • 更改 GA 的 transport_url 为自己网站域名
  • 魔改 GA 的采集地址 /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:一个实例

最近玩 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 without sort

slice_ 和时区

直接使用 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 and lag

case_whenlag 都很慢,加起来十几秒,不过,这十几秒也让最终 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)))

改动如上,原 laginterval 并作一行 c(0, diff(created_at))),至此,从遥遥无期到十几秒跑完,算是一个里程碑,最后还剩下一个 case_when,一时想不出什么办法。

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_tibbleas.data.tableas.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() # <----

the data.table way

性能差异非常明显,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 前后的火焰图,也能看得出大不同:

data.table flame graphdtplyr + data.table 火焰图

dplyr flame graphdplyr 火焰图

小结

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 速度提升明显。

interval plot样例结果绘图

国行 Apple Watch ECG

不考虑 iCloud 污染和凿壁借光,国行 Apple Watch 开启 ECG,我主要参考了三个说法:123。同属一个派别,即备份修改还原法。在当前的官方新版系统 iOS 14.4 和 watchOS 7.3.3 正式版的背景之下,均以失败告终。

此时做了以下动作:

  1. 取消了手机手表的配对
  2. 通过 iMazing 备份了手机
  3. 导入了 V2 的 ...heart-rhythm.plist 文件
  4. 通过 Finder 恢复了修改过的备份
  5. 重新配对了手表

而且,这个过程反复尝试过了好几遍,ECG都没有正常工作,在手表上按 ECG App 提示请先在手机 Health App 上做配置,而一旦进入 Health App 配置又会遭遇「不支持你所在的地区」。

阅读了 1 的后续评论之后,尝试了以下动作:

  1. 长按,删除了手表上的 ECG App
  2. 安装描述文件,加入了苹果 Public Beta 计划
  3. 更新了 iOS 到 14.6 PB,watchOS 到 7.5 PB
  4. 禁用了位置服务和 Find My 功能
  5. 取消手机手表配对
  6. 重新配对为新手表
  7. 手表配置过程中的「位置服务」等选项,都选择稍后配置,没有立即开启

引入的变量较多,无法确定其中的步骤 1、4 和 6 是不是必要的,总之,Public Beta 很关键。等待配置加载完成,按下手表上的 ECG App,不再有配置和地区提示,直接进入使用说明,跳过之后,进入红心飘荡,功能正常,Yeah!。

浅析 Python 中的 Async IO

Async IO 是什么

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+ 为例,仅为个人在工具应用层面的粗浅理解,并不涉及工具底层的设计和实现。

Python Async IO 的语法规则

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 withasync 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 获得结果),剥离开来单独看,也就相对清楚了。

参考链接:

  1. asyncio High-level API Index
  2. Async IO in Python: A Complete Walkthrough

鼠须管配置 2021

今年的主题是放飞自我,做更多尝试,实现更高效的输入,获得量身定做般的成就感。

squirrel_screenshot_2021squirrel_screenshot_2021squirrel_screenshot_2021squirrel_screenshot_2021dark themedark theme screenshot 2

先尝试了小鹤双拼的形码方案,发现并不适合自己。鹤形,在拼音的基础上,引入了类似五笔的拆字规则,以期通过双拼加形码来解决重码问题,这就要求我们需要熟记几乎所有单字和词组的音、形编码,输入过程拼音、五笔双重袭脑,相互角力,堪比一心二用,对于习惯于读音思考的我来说,起点实在有些高。玩起来很有意思,只是坚持不下去。

但是,这个过程加深了我对 Rime 输入法的理解,也启发了我实现一些定制点子的思路,再借助网上各位的分享和贡献,汇聚成以下的:鼠须管配置 2021。

主要特点

  • 小鹤双拼
  • 8105 简体字
  • 长句模型
  • 连贯输入
  • 个性选词

基本套路

主要依赖项:

Python 生态,做前期数据处理,大致路径如下:

  • 克隆下载方案和字词库
  • 清理出规范汉字列表
  • 与简化字八股文匹配,删除界外字词
  • 与袖珍简化字匹配,删除界外字词
  • 进入东风破,装配语言模型
  • 成品参考:placeless/squirrel_config

方案详解

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 进行繁简处理。

squirrel_octagram_screenshot_1squirrel_octagram_screenshot_2

# 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

squirrel_screenshot_2021squirrel_screenshot_2021squirrel_screenshot_2021

为了方便混和输入符号和中文,将以下个人常用符号编入了码表。通常来讲鼠须管的默认状态下,像逗号、句号等,输入即触发上屏动作的,现在就不行了,需要在词语或整句输入完成之后单独敲一下。不需要选词,单个的逗号句号会直接作为中文标点上屏。

# 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

成品参考

placeless/squirrel_config

Map jj to Esc

带 Touch Bar 的老款 MacBook Pro,它的 Esc 是不可能习惯的了了,对于喜欢 vi-mode 的用户来说,解决这个问题,有两个办法:一是使用 + [;二将其他按键映射成为 Esc。这里从个人习惯出发,整理一下收集到的第二种方式,覆盖了这些 vi-mode:ZSH、VIM、VS Code、iPython、以及 Jupyterlab。

ZSH

在 .zshrc 当中增加如下内容即可,注意后面的 timeout 不要设置为0,不然按键起不了作用。

bindkey -v
bindkey -M viins jj vi-cmd-mode
KEYTIMEOUT=25

开启和设定 vi 模式比较简单,麻烦的是配置响应 vim 模式的光标样式。我的日常情况是这样:Alacritty 终端默认启动一个 Tmux,运行 Zsh。一直以来,Esc 和光标,总有小问题,比如 Esc 突然响应不及时、卡死,光标突然不跟随模式而改变形状了。一番折腾,目前最推荐这个写法,使用时间不长,但暂时还没发现什么问题。

VIM

我用的 Neovim,只是配置文件名跟 Vim 不一样,写法是同样的。注意第二行,它的目的,是在两个 j 之间设定一个较短的等待时间,尽量缓解 j j 是正常输入不是模式切换时的停顿感。

inoremap jj <Esc>
:autocmd InsertEnter * set timeoutlen=200
:autocmd InsertLeave * set timeoutlen=1000

VS Code

偏好设置当中搜索或直接在 settings.json 中加入以下内容即可。

"vim.insertModeKeyBindings": [
     {
         "before": ["j", "j"],
         "after": ["<esc>"]
     }
]

这里还有一点问题,中文输入模式下按下这两个键,会有混乱情况出现。比如,可以拿小鹤双拼输入 jpjt(解决)试试看。

iPython

创建或修改 ~/.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

可以使用这个插件 jupyterlab-vimrc 来自定义需要的键位。比如,在偏好设置的 vimrc 内添加以下内容即可模拟 j jEsc

"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 更容易处理一点。

❌