ShihanRan's Blog Life is like a Markov Chain.

PyEcharts

2019-06-09
Shihan Ran

大规模分布式系统课上的Final Project需要作Word Vec的可视化,预期做成类似TensorFlow Embedding Projector的效果。前端使用Flask,可视化工具在看了d3.js、echarts和pyecharts之后还是选定了pyecharts。虽然以前也有用过,但这次用又有新的感(踩)悟(坑),特此纪念。

开端

由于已经使用PCA降维到三维,所以我这边需要处理的为:

  1. 基于三维数组画出全部词向量的散点图
  2. 根据鼠标移动,当指向某个点的时候,显示该点对应的单词及三维的数值
  3. 对于查询的单词相近的向量,透明度应设置为1,其余的向量,透明度应相应降低

那就先从三维散点图入手,首先当然是查看pyecharts文档:3D散点图

依葫芦画瓢,pip install pyecharts装了包之后先把代码拉下来跑一跑:

import random

from example.commons import Faker
from pyecharts import options as opts
from pyecharts.charts import Scatter3D


def scatter3d_base() -> Scatter3D:
    data = [
        [random.randint(0, 100), random.randint(0, 100), random.randint(0, 100)]
        for _ in range(80)
    ]
    c = (
        Scatter3D()
        .add("", data)
        .set_global_opts(
            title_opts=opts.TitleOpts("Scatter3D-基本示例"),
            visualmap_opts=opts.VisualMapOpts(range_color=Faker.visual_color),
        )
    )
    return c

只定义函数当然不行,肯定要调用一下scatter3d_base(),发现运行没输出,溜回去简单看了看”5分钟上手”。发现还需要加一句.render()

scatterPlot = scatter3d_base()
scatterPlot.render()

就跟matplotlib需要加一句plt.show()一样。

然后就能在py文件的同层文件夹下找到生成好的html文件。

此时生成的图片为:

修改透明度

接下来便是对每个点的透明度修改。通过在文档中搜索关键词”透明度”,我找到了设置的关键:ItemStyleOpts:图元样式配置项

但这里只是讲了类的定义,我并不知道具体的调用方式。于是我进一步搜索了ItemStyleOpts,在Calendar:日历图这个例子里找到了用法介绍:

def add(
    # 系列名称,用于 tooltip 的显示,legend 的图例筛选。
    series_name: str,

    # 系列数据,格式为 [(date1, value1), (date2, value2), ...]
    yaxis_data: Sequence,

    # 是否选中图例
    is_selected: bool = True,

    # 标签配置项,参考 `series_options.LabelOpts`
    label_opts: Union[opts.LabelOpts, dict] = opts.LabelOpts(),

    # 日历坐标系组件配置项,参考 `CalendarOpts`
    calendar_opts: Union[opts.CalendarOpts, dict, None] = None,

    # 提示框组件配置项,参考 `series_options.TooltipOpts`
    tooltip_opts: Union[opts.TooltipOpts, dict, None] = None,

    # 图元样式配置项,参考 `series_options.ItemStyleOpts`
    itemstyle_opts: Union[opts.ItemStyleOpts, dict, None] = None,
)

可以看到,是在add函数中设置itemstyle_opts函数,即:

Scatter3D()
.add("", data, itemstyle_opts=opts.ItemStyleOpts(opacity=0.2))

如此便可起到设置透明度的作用。

但我们的目的是为了区分”与查询单词相似的单词”和”不相似的单词”,由于itemstyle_opts是直接对所有点的透明度进行更改,因此我能想到的就是:对于”相似”与“不相似”两组数据,使用两次add函数,每次分别使用一个透明度。

def scatter3d_base() -> Scatter3D:
    RelatedNum = 50
    UnRelatedNum = 1000

    RelatedData = [
            [random.randint(0, 100), random.randint(0, 100), random.randint(0, 100)]
            for _ in range(RelatedNum)
    ]
    UnRelatedData = [
            [random.randint(0, 100), random.randint(0, 100), random.randint(0, 100)]
            for _ in range(UnRelatedNum)
    ]

    c = (
        Scatter3D()
        .add("Related", RelatedData, itemstyle_opts=opts.ItemStyleOpts(opacity=1))
        .add("UnRelated", UnRelatedData, itemstyle_opts=opts.ItemStyleOpts(opacity=0.2))
        .set_global_opts(
            title_opts=opts.TitleOpts("Scatter3D-基本示例"),
            visualmap_opts=opts.VisualMapOpts(range_color=Faker.visual_color),
        )
    )
    return c

此时生成的图如下:

至此,透明度的问题解决啦!

标签显示数据名

万万没想到最后一个问题竟然变成了最难的,难点就在于:它只能设置每一类data的类名,而不能设置每个点的数据名。让我们来看看这个add函数:

def add(
        self,
        series_name: str,
        data: Sequence,
        shading: Optional[str] = None,
        itemstyle_opts: Union[opts.ItemStyleOpts, dict, None] = None,
        label_opts: Union[opts.LabelOpts, dict] = opts.LabelOpts(is_show=False),
        xaxis3d_opts: Union[opts.Axis3DOpts, dict] = opts.Axis3DOpts(type_="category"),
        yaxis3d_opts: Union[opts.Axis3DOpts, dict] = opts.Axis3DOpts(type_="category"),
        zaxis3d_opts: Union[opts.Axis3DOpts, dict] = opts.Axis3DOpts(type_="value"),
        grid3d_opts: Union[opts.Grid3DOpts, dict] = opts.Grid3DOpts(),
    ):

我在这里只选取了它的一小部分,可以看到传入的是”series_name”,这个类名就是图例Legend上显示的东西。也就是说,我们的所有单词只能分为两类:”Related”和”UnRelated”,但每一个词是什么单词我们却没办法显示出来。

我各种Google、查文档都找不到,觉得这非常不科学,感觉明明是可以传个数组就实现的事情,却找不到实现方法。甚至在issue区找到一个人2017年就问过和我类似的问题,而作者明确表示”暂时、以后也不会实现该功能”。

又找了半天,在看二维散点图的使用找灵感的时候找到一个二维散点图里实现这个作用的方法:利用extra_name

def custom_formatter(params):
    return params.value[3]

data = [
    [28604, 77, 17096],
    [31163, 77.4, 27662],
    [1516, 68, 11546],
]

x_lst = [v[0] for v in data]
y_lst = [v[1] for v in data]
extra_data = [v[2] for v in data]
extra_name = ["point A", "point B", "point C"]

sc = Scatter()
sc.add(
    "scatter",
    x_lst,
    y_lst,
    extra_data=extra_data,
    extra_name=extra_name,
    is_visualmap=True,
    visual_dimension=2,
    visual_orient="horizontal",
    visual_type="size",
    visual_range=[17000, 28000],
    visual_text_color="#000",
    tooltip_formatter=custom_formatter,
)
sc.render()

实现效果如下:

一查才发现这个功能也是2018年8月才被作者实现(说好的以后也不会实现呢!一年后就打脸了)估计3D的需求更小,不知道要等多久才会被实现。

无奈之下,只能想到一个笨办法:每次add一个点,每个点的类名即为点的数据名。

这样做的一个显著劣势:html加载速度大大降低了,但没办法,舍不得速度套不到PJ。

另一个需要注意的是:要去掉图例,不然每个单词都是一个图例,图的顶端会被挤爆吧。

于是新的实现如下:

def scatter3d_base(RelatedNames, UnRelatedNames, RelatedData, UnRelatedData) -> Scatter3D:
    c = (
        Scatter3D(init_opts=opts.InitOpts(width="1300px", height="700px"))
        .set_global_opts(
            title_opts=opts.TitleOpts("Scatter3D-Demo"),
            legend_opts=opts.LegendOpts(is_show=False),
            visualmap_opts=opts.VisualMapOpts(range_color=Faker.visual_color),
        )
    )

    for i in range(len(RelatedNames)):
        c.add(
            RelatedNames[i], 
            [RelatedData[i]],
            itemstyle_opts=opts.ItemStyleOpts(opacity=1),
    for j in range(len(UnRelatedNames)):
        c.add(
            UnRelatedNames[j], 
            [UnRelatedData[j]],
            itemstyle_opts=opts.ItemStyleOpts(opacity=0.2))
    return c


if __name__ == "__main__":
    random.seed(5583)

    RelatedNum = 50
    UnRelatedNum = 1000

    RelatedNames = ["Related"]*RelatedNum
    UnRelatedNames = ["UnRelated"]*UnRelatedNum

    RelatedData = [
            [random.randint(0, 100), random.randint(0, 100), random.randint(0, 100)]
            for _ in range(RelatedNum)
    ]
    UnRelatedData = [
            [random.randint(0, 100), random.randint(0, 100), random.randint(0, 100)]
            for _ in range(UnRelatedNum)
    ]

    scatterPlot = scatter3d_base(RelatedNames, UnRelatedNames, RelatedData, UnRelatedData)
    scatterPlot.render()

效果如下:

此时新的风暴又出现了!

为什么当我的鼠标移动到对应的点上时,显示类名和点的三维数值的提示框没了呢???

一开始我以为是因为我这个提示框ToolTip的设置原因,在查询了文档之后,我发现它的默认设置挺对的啊!

Scatter3D(init_opts=opts.InitOpts(width="1300px", height="700px"))
        .set_global_opts(
            tooltip_opts=opts.TooltipOpts(is_show=True, trigger="item", trigger_on="mousemove|click"),
        )

那就只能是我自己的问题了。排查了半天发现,当我每次只add一个点的时候,提示框就不会出现了。难道作者觉得只add一个点我就知道它在哪里了吗???我更愿意相信肯定是程序员忘记加len(data)==1的corner case了。

那怎么办,只有我来擦屁股了:每次add两个点吧,方便起见,第二次就随便加一个点好了。

for i in range(len(RelatedNames)):
        c.add(
            RelatedNames[i], 
            [RelatedData[i], RelatedData[-1]],
            itemstyle_opts=opts.ItemStyleOpts(opacity=1),
            )
    for j in range(len(UnRelatedNames)):
        c.add(
            UnRelatedNames[j], 
            [UnRelatedData[j], UnRelatedData[-1] ],
            itemstyle_opts=opts.ItemStyleOpts(opacity=0.2))
    return c

效果如下:

但还是有点丑哈!又查了一下文档,原来还有个叫标签的东西:

c.add(
      RelatedNames[i], 
      [RelatedData[i], RelatedData[-1]],
      itemstyle_opts=opts.ItemStyleOpts(opacity=1),
      label_opts=opts.LabelOpts(
      is_show=True,
      formatter="{a}")  # {b} ({c})
      )

加以设置之后,可以在每个点上方显示出一个标签,标签内容由自己定义的format生成。对于我们的散点图而言,formatter的各个参数意味着:{a}(系列名称),{b}(数据名称),{c}(数值数组), {d}(无)。

于是最终效果如下:

虽然看似还是全部是一样的标签”Related”,但要知道我传入的是一个数组RelatedNames,所以如果要变成每个单词也是很好实现的。

尾声

整个实现过程到这里就结束啦。不得不说Pyecharts还是有很多不足的地方,本来我还打算直接在每个点旁边标text的,但也没有找到标text的实现方法。Anyway,虽然用了一个很笨的方法,降低了效率,但是还是勉强解决了问题。

最后再贴一下完整的代码吧。

import random

from example.commons import Faker
from pyecharts import options as opts
from pyecharts.charts import Scatter3D


def scatter3d_base(RelatedNames, UnRelatedNames, RelatedData, UnRelatedData) -> Scatter3D:
    c = (
        Scatter3D(init_opts=opts.InitOpts(width="1300px", height="700px"))
        .set_global_opts(
            title_opts=opts.TitleOpts("Scatter3D-Demo"),
            legend_opts=opts.LegendOpts(is_show=False),
            tooltip_opts=opts.TooltipOpts(is_show=True, trigger="item", trigger_on="mousemove|click"),
            visualmap_opts=opts.VisualMapOpts(range_color=Faker.visual_color),
        )
    )

    for i in range(len(RelatedNames)):
        c.add(
            RelatedNames[i], 
            [RelatedData[i], RelatedData[-1]],
            itemstyle_opts=opts.ItemStyleOpts(opacity=1),
            label_opts=opts.LabelOpts(
                is_show=True,
                formatter="{a}")  # {b} ({c})
            )
    for j in range(len(UnRelatedNames)):
        c.add(
            UnRelatedNames[j], 
            [UnRelatedData[j], UnRelatedData[-1] ],
            itemstyle_opts=opts.ItemStyleOpts(opacity=0.2))
    return c


if __name__ == "__main__":
    random.seed(5583)

    RelatedNum = 50
    UnRelatedNum = 1000

    RelatedNames = ["Related"]*RelatedNum
    UnRelatedNames = ["UnRelated"]*UnRelatedNum

    RelatedData = [
            [random.randint(0, 100), random.randint(0, 100), random.randint(0, 100)]
            for _ in range(RelatedNum)
    ]
    UnRelatedData = [
            [random.randint(0, 100), random.randint(0, 100), random.randint(0, 100)]
            for _ in range(UnRelatedNum)
    ]

    scatterPlot = scatter3d_base(RelatedNames, UnRelatedNames, RelatedData, UnRelatedData)
    scatterPlot.render()

Similar Posts

Comments