【Python OSSライブラリ製作】話題の時系列変化を可視化する"アニメ化"ワードクラウドを作成するライブラリをリリースした

## 作ったもの
イーロンマスクのツイートにおける頻出単語の時系列変化を可視化したgif画像です。
![image.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2602537/cc0b3710-904e-9d3c-b39f-ad7b93fad66a.gif)

```
pip install AnimatedWordCloudTimelapse
```

でインストールして、

```python
from AnimatedWordCloud import animate

animate(data)
```

と記述するだけで、上記のgif画像が作成できる`Python`ライブラリ[AnimatedWordCloud](https://github.com/konbraphat51/AnimatedWordCloud)を作成しました。

:::note
`GitHub`レポジトリ（スター⭐くれると泣きながら喜びます、本当に）: https://github.com/konbraphat51/AnimatedWordCloud
:::

https://github.com/konbraphat51/AnimatedWordCloud


## はじめに
**テキストマイニング** を行っていると、対象が何を話題にしているのか **時系列変化** を知りたい時、ありますよね。

まず、静的に（「時系列変化」ではなく）、とりあえず何が話題になっているのか見せたいという時は、 **ワードクラウド** が使われます。

ワードクラウドといえば、[自分の学科](https://www.si.t.u-tokyo.ac.jp/course/sdm/teachers/)に[鳥海不二夫さんという、ツイッター（旧X）で何か炎上ごとが発生するたびにYahoo記事にワードクラウドを投稿する教授](https://news.yahoo.co.jp/expert/authors/toriumifujio)がいらっしゃいますが、例えば[この記事](https://news.yahoo.co.jp/expert/articles/9a0172a8dae53d92ccb71fc8f00eb67b49cd1316)のように、話題が可視化されています。
![IMG_1063.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2602537/84eac711-9cf9-f42e-bce9-dea0e13bceaa.jpeg)
([上記の鳥海さんの記事「日本対スペイン戦で何がツイートされたか」](https://news.yahoo.co.jp/expert/articles/9a0172a8dae53d92ccb71fc8f00eb67b49cd1316)より引用)

このように、コンパクトに、素人にも見た目で話題が分かるような画像になっているのが特徴です。

ここで、まさにこの記事のように、 **時系列での** 話題変化を **アニメーション化** できれば、
* さらにコンパクトになる
    （複数の画像を使わずに、一つの動画を見れば良い）
* 何が新しく話題になり、何が残留し、何が話題にならなくなったのか確認することができる
* なんか動いているから良い仕事している感じが出る

という点で嬉しいのではないでしょうか。

なので、
- [電子情報工学科のオタクと一緒に作り(GitHubレポジトリへ飛ぶ）、](https://github.com/konbraphat51/AnimatedWordCloud/pulls?q=is%3Apr+is%3Aclosed)
- [`PyPI`に上げました（PyPIレポジトリへ飛ぶ）](https://pypi.org/project/AnimatedWordCloudTimelapse/)

この記事は
- `AnimatedWordCloud`の宣伝をし
- OSS開発作業の一例として参考になるように記述し、
- ちょっとだけアルゴリズムに触れます

## 使用例
### イーロンマスクのツイート
冒頭に紹介したものですね。
![image.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2602537/cc0b3710-904e-9d3c-b39f-ad7b93fad66a.gif)


データセットは[data.worldのAdam Helsinger氏のものをお借りしています](https://data.world/adamhelsinger/elon-musk-tweets-until-4-6-17)

[こちらのJupyter NotebookをColabに読み込ませれば使用できます](https://github.com/konbraphat51/AnimatedWordCloudExampleElon/blob/main/animatedwordcloud_colab.ipynb)

#### データを作る
- **単語ベクトル** を `単語->重み` の`dictionary`を作り
```python
#年ごとに
for cnt in range(len(dfs_by_year)):
    #ツイートごとのCountベクトルのデータフレーム
    np_year = counter.fit_transform(dfs_by_year[cnt]["text"]).toarray()
    df_year = pd.DataFrame(np_year, columns=counter.get_feature_names_out())

    #その年の総和
    sr_sum = df_year.sum(axis=0).sort_values(ascending=False)

    #10回以上のもののみ抽出
    sr_sum = sr_sum[sr_sum >= 10]

    #dictionary型へ
    dict_year = sr_sum.to_dict()

    # 年代別リストに保存
    dicts_by_year.append(dict_year)
```
- `(タイムスタンプ名, 単語ベクトルdictionary)`のタプルの **リスト** にします
```python
wordvector_timelapse = []

# 年ごとに
for cnt in range(len(dicts_by_year)):
    year = str(2012 + cnt) #タイムスタンプ名（年を表す）
    
    wordvector_timelapse.append((year, dicts_by_year[cnt])) #タプルにしてリストに追加
```

#### アニメーション化
データを作れたら、これで **アニメーションgif画像を作れます**
```python
from AnimatedWordCloud import animate, Config

config = Config(output_path="/content/") #出力先のディレクトリを指定

result = animate(wordvector_timelapse, config) #アニメーション化
```

デフォルトだと、`output.gif`としてgifが出てきます（`Config`クラスで設定できます）

ちなみに、 **gif画像をJupyter Notebook上に表示するには**
```python:gif表示.py
from IPython.display import display, Image

with open('/content/output.gif','rb') as f:
    display(Image(data=f.read(), format='png'))
```
こう書きます。

------
### ニコ動人気タグ
**日本語を用いる場合、fontの設定** が必要です。

共同開発者が書いてくれています:
[ニコ動の人気タグ遷移を一発でアニメーション化できるpythonライブラリが生まれたらしいので試してみた](https://qiita.com/SuperHotDogCat/items/d7455fee10e1c34f3452)
![nico](https://camo.qiitausercontent.com/88db992837e42d2d8ec697e0ea7c9aef2ea1bf33/68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f333331313030362f32356438333764642d663430322d613936302d313065622d3835356638376639353832322e676966)

------------
### 英誌Guardianタグ
クリックすると画質がいいMediumの記事へ飛びます。
[![ezgif-3-b757a4ba2e.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2602537/33d332af-f48f-4eaf-6d98-3d0bacd89eb5.gif)
Animate the timelapse of 2023](https://medium.com/@konbraphat/animate-the-timelapse-of-2023-51dcfb2f862b)


## 開発体制
### 作業フロー
#### 設計
責務を切り分け、一つの責務ごとにモジュールを一つ割り当てます。
頑張って[SOLIDします。](https://qiita.com/baby-degu/items/d058a62f145235a0f007)
```plantuml
@startuml flow

title Animate() modules flow

start
note right: Animate()

partition "Animator" {
    :data conversion: Convert dictionary data to TimelapseWordVector (inner) class;
    partition "AllocationCalculator" {
        partition "StaticAllocationCalculator" {
            :static allocation
            Allocate size & position of each words at the time of each of the timeframe given;
        }
        partition "AnimatedAllocationCalculator" {
            :dynamic allocation
            Allocate size & position of each words between the static allocations
            This makes the image animating;
        }
    }

    partition "ImageCreator" {
        :create image
        Create each frame's image from the allocated data;
    }

    partition "AnimationCreator" {
        :create animation
        Create animation from the images;
    }
}

end
note right: gif path

@enduml
```

作図には[`PlantUML`](https://plantuml.com/ja/)を使用しています。

#### 分担実装
**モジュールごとに** ブランチを切ってプルリクでマージしていきます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2602537/c5602ccb-c8e5-8f0f-0907-b06c68e37974.png)

ちゃんと[SOLID設計](https://qiita.com/baby-degu/items/d058a62f145235a0f007)しているので、他のモジュールが未完でも、自分のモジュールの`I/O`を全うすることだけに集中すればいいので同時並行できるというわけですね。

#### テスト
モジュールごとに単体テストコードを書き、[`GitHub Actions`](https://github.com/konbraphat51/AnimatedWordCloud/blob/dev/.github/workflows/python-tester.yml)で確認してからモジュールを実装しましたが、これは機械的なテストだとビジュアルを確認できないので、手動で確認しつつデバッグを実施しました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2602537/976aba18-c7d5-7dba-f95c-2bc25e79bbee.png)

下記`GitHub Action`を書くと、`push`するたびに
- バージョン3.8～3.12の5バージョンで、
- MacOS, Windows, Ubuntuの3OSの

`3*5=15`通りの仮想環境が立ち上げられテストされます。`public`レポジトリなら無料です。
`GitHub`さんは[懐深いの王、懐深いキング](https://qiita.com/uts1_6/items/11e348ead4bae71571b8)ですね。

```yml:自動テスト.yml
name: unit-test

on:
  push:
    branches: ["dev", "main"]
  pull_request:
    branches: ["dev", "main"]

jobs:
  build:
    strategy:
      fail-fast: false
      matrix:
        python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
        platform: ["ubuntu-latest", "macos-latest", "windows-latest"]
    runs-on: ${{ matrix.platform }}

    steps:
      - uses: actions/checkout@v3
        with:
          submodules: recursive
   
      - name: Set up Python ${{ matrix.python-version }}
        uses: actions/setup-python@v3
        with:
          python-version: ${{ matrix.python-version }}
  
      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          python -m pip install pytest
          python -m pip install -e .
    
      - name: Test with pytest
        env:
          PYTHONPATH: ${{ github.workspace }}
        run: |
          pytest
```

#### ドキュメンテーション
- [README.md](https://github.com/konbraphat51/AnimatedWordCloud/blob/dev/README.md)
- [CONTRIBUTING.md](https://github.com/konbraphat51/AnimatedWordCloud/blob/dev/CONTRIBUTING.md)

を書きました。

#### リリース
##### 作業手順
平時は`dev`ブランチで開発していますが、
- 最終成果物をバージョンアップのたびに`main`ブランチにマージし、
- `GitHub`の[`releases`](https://github.com/konbraphat51/AnimatedWordCloud/releases)を更新する度に`PyPI`アップロードを自動化する[GitHub Action](https://github.com/konbraphat51/AnimatedWordCloud/blob/dev/.github/workflows/python-publish.yml)を作成しました。

```yml:PyPI上げAction.yml
name: Upload Python Package

on:
  release:
    types: [published]

permissions:
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3
      - name: Set up Python
        uses: actions/setup-python@v3
        with:
          python-version: "3.12"
      - name: Build package
        run: |
          python -m pip install --upgrade pip
          pip install setuptools wheel
          python setup.py sdist bdist_wheel
      - name: Publish package
        run: |
          pip install twine
          python -m twine upload dist/*
        env:
          TWINE_USERNAME: __token__
          TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
```

##### 設定
- [setup.py](https://github.com/konbraphat51/AnimatedWordCloud/blob/dev/setup.py)
- [MANIFEST.in](https://github.com/konbraphat51/AnimatedWordCloud/blob/dev/MANIFEST.in)

を記述します。

### 保守性維持
#### 承認制
コード理解と保守性のため、しっかりコードレビューで承認されてからプルリクをマージします
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2602537/05617cd3-a3ce-b0ef-ba15-3ad015e1b1c2.png)

他にも、機械的に、GitHub Actionsで
- [Linter](https://github.com/konbraphat51/AnimatedWordCloud/blob/dev/.github/workflows/lint.yml)
    コーディング規約への適合を確認する
- [CodeClimate](https://codeclimate.com/github/konbraphat51/AnimatedWordCloud)
    [外面的な綺麗さ](https://qiita.com/konbraphat51/items/e82da55e50e0b7a2281f#codeclimate)を評価してくれます
- [Codacy](https://app.codacy.com/gh/konbraphat51/AnimatedWordCloud/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade)
    [内面的な綺麗さ](https://qiita.com/konbraphat51/items/e82da55e50e0b7a2281f#codacy)を評価してくれます。
- [pytester](https://github.com/konbraphat51/AnimatedWordCloud/blob/dev/.github/workflows/python-tester.yml)
    `push`するたびにテストコードが走ります。

を通じて保守性チェックをしています。
（参考: [【GitHub】レビュワーがいなくとも綺麗にコード書く気になるサービス2選と利用方法](https://qiita.com/konbraphat51/items/e82da55e50e0b7a2281f)）

#### コーディング
`Black`を使ってコーディング規約順守を自動化しています。
参考: [blackで始める妥協のないPythonコードフォーマッティング](https://qiita.com/84zume/items/ac969931b5f08fef0841)

簡単にいうと、
```
pip install black
```
でインストールし、カレントディレクトリをレポジトリ直下に移して
```
python -m black --line-length=79 .
```
とコマンドするだけで、 [自動整形してくれます](https://github.com/search?q=repo%3Akonbraphat51%2FAnimatedWordCloud+black&type=commits)

`Black`の **使用を怠っていないか** の確認は、下記`GitHub Action`で行えます。
```yml:black使用確認.yml
name: Lint

on:
  push:
    branches:
      - dev
    paths-ignore:
      - '**.md'
      - '**.yml'
      - 'docs/**'
  pull_request:
    branches:
      - dev
    paths-ignore:
      - '**.md'
      - '**.yml'
      - 'docs/**'

jobs:
  build:

    runs-on: ubuntu-20.04
    strategy:
      matrix:
        python-version: [3.8]

    steps:
    - uses: actions/checkout@v2
    - name: Set up Python ${{ matrix.python-version }}
      uses: actions/setup-python@v4
      with:
        python-version: ${{ matrix.python-version }}
    - name: Check Code Format with Black
      uses: psf/black@stable
      with:
        options: "--check --verbose --line-length=79"
        src: "./AnimatedWordCloud"
        version: "~= 22.0"
```

#### アーキテクチャ
下記の通りに。
[【Python】モジュール構成規則を考えてみる](https://qiita.com/konbraphat51/items/488d9837945c37add163)

## 中身の仕組みちょっとだけ
### 概要
```plantuml
@startuml flow

title Animate() modules flow

start
note right: Animate()

partition "Animator" {
    :data conversion: Convert dictionary data to TimelapseWordVector (inner) class;
    partition "AllocationCalculator" {
        partition "StaticAllocationCalculator" {
            :static allocation
            Allocate size & position of each words at the time of each of the timeframe given;
        }
        partition "AnimatedAllocationCalculator" {
            :dynamic allocation
            Allocate size & position of each words between the static allocations
            This makes the image animating;
        }
    }

    partition "ImageCreator" {
        :create image
        Create each frame's image from the allocated data;
    }

    partition "ImageIntegrator" {
        :create animation
        Create animation from the images;
    }
}

end
note right: gif path

@enduml
```

- 与えられた時系列データをライブラリ内部で定義しているクラス構造に変換し
- `StaticAllocationCalculator`: 時系列のタイムスタンプごとにワードクラウドの **位置決め** を行い（普通にワードクラウドを作成）
- `AnimatedAllocationCalculator`: 上記の静的な位置決めの **合間の** コマとなるデータを作成し、（これでヌルヌル動くように見える）
- `ImageCreator`: 各位置決めデータから画像を作り
- `AnimationIntegrator`: 画像を統合し一つの`gif`画像にします。

-----

### モジュール構造
#### Root
まず、大きく2つ、
- `Animator`モジュールと
- 共通処理が書かれた`Utils`モジュール

に分けて用意しています。

```plantuml
@startuml Dependency_Root
' Image can be obtained from
' https://www.plantuml.com/plantuml/uml/SyfFKj2rKt3CoKnELR1Io4ZDoSa70000

' Write all modules here
object Animator {
    * animate()
}

object Utils #LightBlue {
    * Vector
    * Consts
}

' Write all dependencies here
' X --> Y means X depends on Y
Animator --> Utils

@enduml
```
(`X -> Y`は「XがYに依存している」ことを示す）

#### Animatorモジュール
上記のフロー図通り配置しています。
`Animator.py`が各モジュールを拾いに行き、それ以外のモジュール間依存はありません。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2602537/cec3aba5-8e5f-4892-ed80-784c2b1f7bd7.png)

#### Utilsモジュール
共通処理・クラスを書いているだけです。
```plantuml
@startuml Dependency_Utils
' Image can be obtained from
' https://www.plantuml.com/plantuml/uml/SyfFKj2rKt3CoKnELR1Io4ZDoSa70000

' Write all modules here
object Data {
    * all data classes
}

object Vector {
    * Vector
    * Consts
}

object Collisition {
    * Collisition
}

object Config {
    * Config class
}

object FileManager {
    * ensure_directory_exists()
}

' Write all dependencies here
' X --> Y means X depends on Y
Vector <-- Data
Collisition --> Data

@enduml
```
------

### `StaticAllocationCalculator`
先ほど言った「まずは普通にワードクラウドを作ってみる」の部分です。
僕が書きましたが、これメンドウですね。

アルゴリズムとしては、
- まずは最頻の単語を真ん中に設置し、
- 次に頻度が高い単語から順に **磁石のように** 真ん中のクラスタへひっつけていく

ようにしています。

これはその磁石ひっつけのアニメーションです。
![image](https://private-user-images.githubusercontent.com/101827492/292932614-d12c77c2-dc23-4fd5-acd8-e82acd9bd617.gif?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3MDQ3NTExNDUsIm5iZiI6MTcwNDc1MDg0NSwicGF0aCI6Ii8xMDE4Mjc0OTIvMjkyOTMyNjE0LWQxMmM3N2MyLWRjMjMtNGZkNS1hY2Q4LWU4MmFjZDliZDYxNy5naWY_WC1BbXotQWxnb3JpdGhtPUFXUzQtSE1BQy1TSEEyNTYmWC1BbXotQ3JlZGVudGlhbD1BS0lBVkNPRFlMU0E1M1BRSzRaQSUyRjIwMjQwMTA4JTJGdXMtZWFzdC0xJTJGczMlMkZhd3M0X3JlcXVlc3QmWC1BbXotRGF0ZT0yMDI0MDEwOFQyMTU0MDVaJlgtQW16LUV4cGlyZXM9MzAwJlgtQW16LVNpZ25hdHVyZT1kYzE0YTRkMTExMDc0ODFlNjg0YmUzZDJlM2U1ZGI1ZDBkMTMzYWExNjRkOTQ3OGQ2ZWYxMzVkMjhkYWJhODlmJlgtQW16LVNpZ25lZEhlYWRlcnM9aG9zdCZhY3Rvcl9pZD0wJmtleV9pZD0wJnJlcG9faWQ9MCJ9.h7413btdiwSyyvzW4GNmBnE8SNFNU1QsJ1wXoPAoM48)
(映像は開発初期のものであり、処理速度・綺麗さ共にリリースしているバージョンは格段に品質が良いです）

ベクトル計算、当たり判定であったり日頃のゲームプログラミング経験が活きましたね。

処理としては、
- 上下左右から真ん中の磁石クラスタの輪郭の点群（二次元）を取得し
- 得られた上記の点群のうち、どれに次の単語を設置するのがよいか、評価関数が最良の値を示す点を走査する

というものです。

`joblib`ライブラリで並列処理化し、高速化しています。

## 最後に
僕は`GitHub`のレポジトリの数自体は多いのですが、ちょっと規模のあるものをOSSとして真面目にリリースまでもっていくのは初めてでした。

モジュールを結合していき、初めて動いたワードクラウドを出せた時、[中学1年生で初めてゲームを作ったとき](https://qiita.com/konbraphat51/items/b138683db352afd77714)と似た感動と快感が生まれました。
やっぱりプログラミングは楽しいですね。

:::note
- この記事にいいね
- [GitHubレポジトリ](https://github.com/konbraphat51/AnimatedWordCloud)にスター⭐

つけていただけると本当に嬉しいです。
初めてのリリースなので、お願いします。
:::

https://github.com/konbraphat51/AnimatedWordCloud