そぬばこ

備忘録とか、多分そんな感じ。

Python のフォーマッタ、ちょっと拡大して静的リントツールやパッケージ管理ツールに関する雑談

ただ思ったことを書き連ねています。 特に深い意味はない話です。

black と isort の微妙な違い

black は Python の著名なフォーマッタで、 isort は特に import 周りのソートを行う著名なフォーマッタです。

github.com

github.com

もちろん、役割の範囲が異なるので、そもそも違いとはなんぞやと感じる方のほうが多いと思います。 一応機能の話をしておくと、 black の機能が isort と競合する部分があるため、機能的な共存は isort 側に以下のような設定を入れることで可能になっています。

[tool.isort]
profile = "black"

個人的な微妙な違いへの興味は、 --diff オプションで得られる出力の性格にあります。 次のコードに対する、black と isort の結果の違いを見てみましょう

import typing
import os


print( "1" )

まずは black から。フォーマットがかからないように --check をつけています。

$ black --check --diff tmp.py
--- tmp.py  2024-02-27 16:05:01.986972+00:00
+++ tmp.py  2024-02-27 16:05:19.849258+00:00
@@ -1,6 +1,5 @@
 import typing
 import os


-print( "1" )
-
+print("1")
would reformat tmp.py

Oh no! 💥 💔 💥
1 file would be reformatted.

次に isort です。

$ isort --check --diff tmp.py
ERROR: /Users/nersonu/tmp/blog_202402/tmp.py Imports are incorrectly sorted and/or formatted.
--- /Users/nersonu/tmp/blog_202402/tmp.py:before    2024-02-28 01:05:01.986972
+++ /Users/nersonu/tmp/blog_202402/tmp.py:after 2024-02-28 01:05:30.731620
@@ -1,6 +1,5 @@
+import os
 import typing
-import os
-

 print( "1" )

似ていますがいろいろ微妙に異なっていますね。 1つずつ見ていきましょう。

1つ目は、メッセージの位置です。 black は最後に、isort は最初に「フォーマットかけるぜ」というメッセージが入っています。 また、これは主観的な感覚ですが、 isort のメッセージのほうがなんとなくシステマティックです。

# black
Oh no! 💥 💔 💥
1 file would be reformatted.
# isort
ERROR: /Users/nersonu/tmp/blog_202402/tmp.py Imports are incorrectly sorted and/or formatted.

2つ目は、フォーマット対象を示すファイルパスの表記の仕方です。 black は相対パスなのに対し、isort は絶対パスになっています。 さらに、 isort にはパスのあとに :before , :after の文字列があります。

# black
--- tmp.py
+++ tmp.py
# isort
--- /Users/nersonu/tmp/blog_202402/tmp.py:before
+++ /Users/nersonu/tmp/blog_202402/tmp.py:after

3つ目は、時間表記です。 black は UTC なのに対し、 isort はシステムの時間表記が使われています。 ちなみに TZ 環境変数を渡したとき、 black は依然として UTC ですが、 isort は指定したタイムゾーンになります。

# black
2024-02-27 16:05:01.986972+00:00
# isort
2024-02-28 01:05:01.986972

このように、ライブラリ2つ並べて出力を見てみるだけで文化の違いを感じることができます。 ところで、これらは恐らく diff コマンドの -u オプションから影響を受けていそうです。

$ diff -u tmp.py formatted_tmp.py
--- tmp.py  2024-02-28 02:14:11
+++ formatted_tmp.py    2024-02-28 02:15:20
@@ -1,5 +1,5 @@
-import typing
 import os
+import typing


-print( "1" )
+print("1")

OS や環境依存で diff の結果が変わる可能性もあるので、一概に確定的なことはここでは言いませんが、なんとなく、 black も isort も完全に仕様として一致しているわけではなさそうです。 不思議ですね。

些細な違いで困ること?

このような些細な違いで実際に困ることはあるのでしょうか? 例えばこの diff の結果を使うような状況が……?

ところで、みなさんは Reviewdog を知っていますか?

github.com

これは、何かのライブラリやツールでコード解析をした結果等を GitHub*1 で PR comment として出力できるようなツールです。

Reviewdog では、 解析結果のインプットのフォーマットとして、 diff のフォーマットに対応しています。 つまり、 black や isort の結果を使うことが出来ると考えられるわけですね。

ここで、タイムゾーン以外はほぼ diff のフォーマットの体裁を成している black は比較的簡単に使うことができます。 このノリで isort でやろうとすると痛い目を見るわけです。主にファイルパス周辺で。 実際に使うには出力を sed などで置換して消したりし、Reviewdog 側が解釈できるようにしてあげる必要があります。 あまり世で知れ渡っていないようですが、別言語でも Reviewdog でうまく扱えるように、シェルスクリプト上で出力を置換してあげるみたいなケースは他にあるようですね *2

black や isort に別れを告げる

ところで、ruff というツールをご存知でしょうか。

github.com

Rust 製の Python の静的リントツールですが、気づけばフォーマットも出来るようになっていました。

github.com

ruff についての説明は世に溢れているので省きますが、まだ開発途上でバグはありつつも Rust 製の非常に高速なツールです。 flake8 のような静的リントの置き換えだけでなく、black, isort のフォーマッタも ruff ですべて置き換わっていくことでしょう。

Python の開発ツールのこれから

ruff を開発している Astral 社は、同様に Rust で pip-tools の代替を目指す uv の開発も行っています。

github.com

Python の cargo を目指すというところで、 ruff を以前から採用し、 uv についても導入した rye ですが、最近作者が Astral 社にその開発を引き渡した *3 ことが話題になりました。

github.com

lucumr.pocoo.org

フォーマッタ、静的リント、パッケージ管理ツールと、基本的な開発ツールがどんどん rye に集約されていっており、まさに cargo を目標にしていることがはっきりわかりますね。 以前紹介した PDM や、 Poetry も依存解決に installer 等を使うようになってかなり高速化しましたが、これから rye が台頭してくると、まだまだ Python の開発ツールの戦乱期は終わりを迎えそうにはありません。

github.com

github.com

github.com

そんなことをぼんやりと考えながら、わたしの有給休暇の火曜日が終わっていきました。

*1:厳密には CLI だけでも使えるがここでは省略

*2:著者は友人と一度だけこの話をしました

*3:この表現は厳密には正しくないが、割愛。詳しくは記事を読んでほしい

サブスクを整理する

この記事は whywaita Advent Calendar 2023 11日目の記事です。

遅刻です。スイマセン。 日曜日は呑気に同期とキャンプした帰りで、寿司食いながら「なんか忘れてる気がするんだよな……」と呟いたんですがこれだったんですね。 ちなみに、去年も11日目の記事だったらしいです。

nersonu.hatenablog.com

前日 (10日目) は、id:yu_ki_kun_0 さんの 【WIP】お台場に1年半くらい住んでみた感想 - 上から下まで面白いことを… でした。 住むところは非常に大事ですよね。 今自分も、通勤で必ず座れる駅に住んでいるので、推しポイントは非常に共感できました。

さて今回はストックしている技術ネタも無いので、あんまり面白くないですが契約しているサブスクを列挙していこうと思います。 きっとどこかで whywaita さんの役に立つことでしょう (適当)

生活マスト枠

1Password

1password.com

言わずと知れたパスワード管理ツールです。 最近は Passkey の対応や、 SSH の鍵管理にも一役買っています。 生体認証系と相性がいいのはありがたいですよね。

年額 $39.47 です。

Moneyforward ME

moneyforward.com

個人用家計簿アプリです。 複数口座や複数クレカの自動連携のために有料のサービスを使っています。 これが無いと破産します。 スタンダードコースで十分です。

年額 ¥5,500 です。

Sleep Cycle

www.sleepcycle.com

睡眠トラッキング・目覚ましアプリです。 浅い眠りか深い眠りかを判定して、なんかいい感じのタイミングで起こしてくれるのでずっと使っています。 Pixel Watch にも対応していて、腕のバイブレーションで起きやすい感じがしています。

年額 ¥3,000 です。

Google One

one.google.com

Google Drive に大体のファイルを集約させていて、ストレージの容量が必要なため契約しています。 アホほど使っているわけでもないので、ベーシックプランではあります。

月額 ¥250 です。

ChatGPT Plus

openai.com

簡単な技術検索や、アイデアの発散等に主に使っています。 最近は GPTs によって、論文の読解の補助にも便利ですね。

月額 $20 です。

論文読み枠

論文を読むために、 ChatGPT 含めて3つのサブスクを契約しているようです。 全部使っているので解約は考えていません。

Paperpile

paperpile.com

論文管理ツールです。 共有も楽ですし、参考文献表記 (もちろん BibTeX にも使えますが、 pptx にベタ貼りするときに便利) が楽なので使っています。 さよなら Mendeley 。 PDF の保存先が Google Drive なので Google One が手放せなくなるが玉に瑕ですかね。 共有に使ってるので一応 Business Plan にしてるんですが、このせいで高くなってます。

年額 $119.88 です。

Readable

readable.jp

英語の PDF ファイルを、レイアウトを維持したまま日本語に直すアプリケーションです。 論文を読むんだったら GPTs で代替できる気もするんですが、正確性と英語の勉強の点でまだまだ使い続けそうです。

月額 ¥980 です。

動画・音楽枠

DAZN

www.dazn.com

野球とかサッカーとかを主に観ます。 スポーツ観戦好きです。 年間通して観るものもあるので、旧 DAZN for docomo で安く観続けています。

月額 ¥1,925 です。

dアニメストア

animestore.docomo.ne.jp

ぜんぜんみてないあは

月額 ¥550 です。

Youtube Premium

www.youtube.com

Chromecast with Google TV をぶっ刺しているので、広告無しがありがたいです。 たまに Youtube Music も使ってます。

月額 ¥1,280 です。

ABEMA プレミアム

abema.tv

麻雀系の見逃し再生に使っています。 たまにスポーツとか将棋とかを観ています。

月額 ¥980 です。

Spotify

open.spotify.com

基本的に音楽を聴くときは Spotify を使っています。 レコメンドの体験が非常に良く感じます。 UX 面でかなり好きです。

月額 ¥980 です。

ゲーム枠

Nintendo Switch Online + 追加パック 個人プラン

www.nintendo.co.jp

Splatoonポケモン系でオンラインサービスを使うので契約しています。 最近はたまに GBA のゲームを遊んだりしているので、追加パックを入れてたりという感じです。

年額 ¥4,800 です。

PlayStation Plus エッセンシャル

www.playstation.com

Switch ほどではないですが、オンラインサービスを使うゲームがあるので契約しています。 PS5 はほとんど起動してないです。

年額 ¥6,800 です。

生活その他枠

Amazon Prime

www.amazon.co.jp

Amazon でそこそこ買い物するので、よく恩恵をこうむっています。

年額 ¥4,900 です。

クラシルプレミアム

www.kurashiru.com

料理をする際にはかなり使っています。 UI が好きです。

月額 ¥480 です。

まとめ

思ったよりも使ってないサービスが少ないのが恐ろしいなと感じました。 全然使っていないサービスが山程あれば、この機会に解約してやろうと思っていましたが、どうやら解約は一旦しなさそうだな〜と眺めながら適当に考えています。 みなさんはサブスクはほどほどに、賢い生活を送ってください。

Sansan を退職しました

2023年10月末でSansan株式会社を退社しました。

2021年4月に修士卒で新卒で入社し、約2年半ほどお世話になりました。 今思い返しても新卒として入社してよかった会社だと思います。 技術的な部分もビジネス的な部分も学ぶことができて非常に成長したなという実感がありますし、何よりも周りのメンバーに恵まれたなというのが一番ツイていたと思います。

やってきたこと

「研究員」という名目の職種で入社しました。 とはいいつつも、一般的には機械学習エンジニアと呼ばれる職種に近い業務をしていたと思います *1

大半は「ContractOne」と呼ばれる契約書を取り扱うプロダクトにおいて、契約書のデータ化を行うタスクに時間を注ぎました。 自然言語処理や画像処理といった技術検証はもちろんのこと、これらの技術を使った情報抽出を行うアプリケーション全体の開発に携わりました。 少し特殊だったことは、 Sansan のデータ化プロダクトの大半に人間のオペレータが入力するフローが存在することです。 これらのフローを考慮したり、オペレータのマネジメントを行うメンバーとの連携も重要な要素でした。 このような環境に身を置くことで、技術力だけではなく、他のメンバーとのコミュニケーションの重要性や、コストや KPI の重要性を多く学ぶことができました。

また、研究開発部という横串の組織において、(機械学習エンジニアというよりは) Python エンジニアという観点での開発生産性の向上に努めました。 当時まだ requirements.txt がほとんどを占めていた状況で Poetry によるパッケージ管理を推進してみたり *2 、structlog でログを構造化させてみたり、 reviewdog が叱ってくれる Python の CI の叩きを作ってみたり *3 しました。 今年は部の新卒研修の設計をしたりもしました *4。 前年度から環境構築の部分のドキュメントを整備してみたりと、研究開発部内のエンジニアにかなり協力してもらいながら、自由にいろいろうるさい人をやっていた感じです。 好き放題やってすいませんでした。

学会のスポンサーブースもいくつか運営のリードをさせていただきました。 今年は熊本にお邪魔したりしましたね。 Sansan には技術系のブランディングをメインで行うメンバーがおり、非常にお世話になりました。

転職について

元々、新卒のキャリアは3〜5年目の間に一区切りつけようと考えていました。 これは単に同じ組織に居続けて、井の中の蛙大海を知らずになることを恐れていたからです。 一つの会社で成果を出し続けることも非常に難しいことですが、果たして20代でそこに特化してしまっても良いのだろうか。 同じ会社にいて別のチャレンジをしても、それは果たして他でも通用するチャレンジをしたと言えるのだろうかと、人にとってはどうでも良いようなそういう面倒くさいメンタリズムが根底にはありました。

一方で実際に転職を決意したのはもっとポジティブな理由です。 機械学習を主軸にしつつ、今よりももっとエンジニアリング能力を伸ばせて、将来的にもっとプロダクトを前線で作っていくポジションを見据えて仕事が出来る、そんな絵に描いた餅みたいな環境を手に入れようと思ったからです。 就職したときからどういったキャリアを歩みたいのかということはずっと課題であり、分析もエンジニアリングも、なんなら研究よりの開発もなんでも出来そうなので新卒で Sansan に入ったというところがありました *5 。 もちろんやりたいアピールはかなりしましたし、だいぶわがままで面倒くさい社員だったという自覚はあります。 手挙げで挑戦を許容してくれる非常に良い組織ではありましたが、事業フェーズや現状の組織体制とは折り合いが合わない部分もあるなと感じ (あくまでも主観) 、新しい環境を求めました。

おわりに

転職活動の際には、友人知人含め様々な方々や会社様のお時間いただき、本当にありがとうございました。 11月1日からは、株式会社サイバーエージェントにて、機械学習エンジニアとして新しいスタートを切りました。 インターン時代のご縁もあり、結果的には一番最初に相談させていただいた会社にお世話になることになりました。 転職の決め手や現在取り組んでいることについては、差し支えない範囲で機会があれば文をしたためる日があるやもしれません。

最後になりましたが、前職の同僚の方々には本当にお世話になりました。 来年は Sansan のオフィスも渋谷に移転する *6 というところもありますが、物理的な距離もそんなに離れていませんので、ぜひ一杯付き合っていただければ幸いです。

*1:最近は募集要項も「機械学習エンジニア」と表記していますし: https://media.sansan-engineering.com/randd

*2:Poetry が正解だったのかは未だよくわかりませんが、社内 Poetry 芸人をやっていたおかげで主題じゃない記事で、 Poetry の Tips (今となっては古いバージョン) として参照されることがたまにありました: https://nersonu.hatenablog.com/entry/sansan-advent-calendar-2021

*3:そのうち在籍メンバーが記事を書いてくれるんではなかろうかと期待しています(ぶん投げ)

*4:今年は外に記事を出していて、最高の研修設計メンバーでした

*5:実際やらせてもらいました

*6: https://jp.corp-sansan.com/news/2023/0718.html

高速かつ PEP 582 で仮想環境を捨てる Python パッケージマネージャ PDM を試す

この記事は Sansan Advent Calendar 2022 19日目の記事です。

前日は fujisyo32 さんの

zenn.dev

でした。

今年は特に画像周りで拡散モデルの話題で持ち切りでしたね。言語生成周りの研究も非常に興味深いです。

はじめに

私が所属する研究開発部では、Python のパッケージマネージャとして Poetry を標準的に利用しています。

github.com

Rust のように toml でパッケージを人間が認識しやすい形で管理できる点は非常に魅力的であり、setup.py, requirements.txt, setup.cfg, MANIFEST.in 等を代替できるため非常に便利です。 しかしながら最近、Poetry を用いたインストールやパッケージ追加等の依存解決に凄まじく時間を要しており、なんとか速度削減して開発のサイクルを早めることは出来ないかなと感じております。

そこで今回は、以前からあくまで個人的に試したいと思っていた高速なパッケージマネージャである PDM を試してみたいと思います。

PDM とは

公式のロゴ

PDM も Poetry と同様に pyproject.toml によってパッケージを管理する Python パッケージマネージャです。

github.com pdm.fming.dev

Poetry との大きな差異としては、次の2点が挙げられます。

  • PEP 582 に基づき、仮想環境を使わずに利用できる仕組みを搭載 (利用するかは選択可能)
  • 依存解決を含めて、動作が非常に高速

一方で CLI ツールとしては Poetry と操作感が非常に似ており Poetry ユーザであれば簡単に利用することが可能です。早速試していきましょう。

PDM を試す

紹介時の PDM のバージョンは 2.3.3 です。

インストール

インストール方法は公式HP等を参照してください。よくある curl で持ってくるやつです。

$ curl -sSL https://raw.githubusercontent.com/pdm-project/pdm/main/install-pdm.py | python
Installing PDM (2.3.3): Creating virtual environment
Installing PDM (2.3.3): Installing PDM and dependencies
Installing PDM (2.3.3): Making binary at /Users/nersonu/.local/bin
Usage: pdm [-h] [-V] [-c CONFIG] [-v] [-I] [--pep582 [SHELL]] {add,build,cache,completion,config,export,import,info,init,install,list,lock,publish,remove,run,search,self,plugin,show,sync,update,use,venv} ...

    ____  ____  __  ___
   / __ \/ __ \/  |/  /
  / /_/ / / / / /|_/ /
 / ____/ /_/ / /  / /
/_/   /_____/_/  /_/

Commands:
  {add,build,cache,completion,config,export,import,info,init,install,list,lock,publish,remove,run,search,self,plugin,show,sync,update,use,venv}
    add                 Add package(s) to pyproject.toml and install them
    build               Build artifacts for distribution
    cache               Control the caches of PDM
    completion          Generate completion scripts for the given shell
    config              Display the current configuration
    export              Export the locked packages set to other formats
    import              Import project metadata from other formats
    info                Show the project information
    init                Initialize a pyproject.toml for PDM
    install             Install dependencies from lock file
    list                List packages installed in the current working set
    lock                Resolve and lock dependencies
    publish             Build and publish the project to PyPI
    remove              Remove packages from pyproject.toml
    run                 Run commands or scripts with local packages loaded
    search              Search for PyPI packages
    self (plugin)       Manage the PDM program itself (previously known as plugin)
    show                Show the package information
    sync                Synchronize the current working set with lock file
    update              Update package(s) in pyproject.toml
    use                 Use the given python version or path as base interpreter
    venv                Virtualenv management

Options:
  -h, --help            show this help message and exit
  -V, --version         show the version and exit
  -c CONFIG, --config CONFIG
                        Specify another config file path(env var: PDM_CONFIG_FILE)
  -v, --verbose         -v for detailed output and -vv for more detailed
  -I, --ignore-python   Ignore the Python path saved in the .pdm.toml config
  --pep582 [SHELL]      Print the command line to be eval'd by the shell

Successfully installed: PDM (2.3.3) at /Users/nersonu/.local/bin/pdm

PDM でプロジェクトを立ち上げる

プロジェクトのルートディレクトリで pdm init すれば、対話式でプロジェクトを立ち上げることが出来ます。

$ pdm init
Creating a pyproject.toml for PDM...
Please enter the Python interpreter to use
0. /Users/nersonu/.pyenv/shims/python3 (3.10)
1. /Users/nersonu/.pyenv/shims/python (3.10)
2. /Users/nersonu/.pyenv/versions/3.10.9/bin/python3.10 (3.10)
3. /Users/nersonu/.pyenv/shims/python3.10 (3.10)
4. /Users/nersonu/.pyenv/shims/python3.9 (3.9)
5. /usr/local/bin/python3.9 (3.9)
6. /Users/nersonu/.pyenv/versions/3.9.13/bin/python3.9 (3.9)
7. /usr/bin/python3 (3.8)
8. /usr/bin/python2.7 (2.7)
9. /Users/nersonu/Library/Application Support/pdm/venv/bin/python (3.10)
Please select (0): 0
Using Python interpreter: /Users/nersonu/.pyenv/shims/python3 (3.10)
Would you like to create a virtualenv with /Users/nersonu/.pyenv/versions/3.10.9/bin/python3? [y/n] (y): y
Is the project a library that will be uploaded to PyPI [y/n] (n): n
License(SPDX name) (MIT):
Author name (nersonu):
Author email (nersonu@gmail.com):
Python requires('*' to allow any) (>=3.10): >=3.9
Changes are written to pyproject.toml.
$ ls
pyproject.toml

特徴的な点としては、利用する Python インタプリタを指定することです。 ここで指定したインタプリタは、.pdm.toml に書かれます。

[python]
path = "/Users/nersonu/.pyenv/shims/python3"

なお、ユーザごとに利用するインタプリタが異なることから、このファイルは git などで commit しないように気をつけましょう。

また、 Poetry や Rust の Cargo のように src ディレクトリや README ファイルは生成されません。

生成された pyproject.toml はこのようになっています。

[tool.pdm]

[project]
name = ""
version = ""
description = ""
authors = [
    {name = "nersonu", email = "nersonu@gmail.com"},
]
dependencies = []
requires-python = ">=3.9"
license = {text = "MIT"}

pyproject.toml の形式は PEP 621 に基づいており、Poetry とは若干形式が異っていますね。

仮想環境については、 pdm init の際に生成するようにした (以下)

Would you like to create a virtualenv with /Users/nersonu/.pyenv/versions/3.10.9/bin/python3? [y/n] (y): y

ため、pdm run でルートに作られた .venv を利用して実行することができます。

$ pdm run which python
/Users/aomi/workspace/try_pdm/init_pdm/.venv/bin/python

パッケージの追加

poetry add と同じような形で pdm add でパッケージの追加が可能です。

$ pdm add numpy pandas
Adding packages to default dependencies: numpy, pandas
🔒 Lock successful
Changes are written to pdm.lock.
Changes are written to pyproject.toml.
Synchronizing working set with lock file: 5 to add, 0 to update, 0 to remove

  ✔ Install six 1.16.0 successful
  ✔ Install python-dateutil 2.8.2 successful
  ✔ Install pytz 2022.7 successful
  ✔ Install pandas 1.5.2 successful
  ✔ Install numpy 1.23.5 successful

🎉 All complete!
$ ls
pdm.lock pyproject.toml

pdm.lock が生成され、poetry.lock と同様に依存関係等がここに残されます。

また、 pyproject.toml は以下のように更新されています。

[tool.pdm]

[project]
name = ""
version = ""
description = ""
authors = [
    {name = "nersonu", email = "nersonu@gmail.com"},
]
dependencies = [
    "numpy>=1.23.5",
    "pandas>=1.5.2",
]
requires-python = ">=3.9"
license = {text = "MIT"}

Poetry と同様に、開発用ライブラリとライブラリのグループ管理が可能です。 Poetry と若干異なるところは、開発用ライブラリをグループと同様に依存関係に含めるか、独立させるかオプションの指定の仕方で選べるところです。 今回は依存関係に含めない形で、開発用ライブラリとして pytest をインストールする形を紹介します。

$ pdm add -d pytest
Adding packages to dev dev-dependencies: pytest
🔒 Lock successful
Changes are written to pdm.lock.
Changes are written to pyproject.toml.
Synchronizing working set with lock file: 7 to add, 0 to update, 0 to remove

  ✔ Install exceptiongroup 1.0.4 successful
  ✔ Install iniconfig 1.1.1 successful
  ✔ Install tomli 2.0.1 successful
  ✔ Install pluggy 1.0.0 successful
  ✔ Install packaging 22.0 successful
  ✔ Install attrs 22.1.0 successful
  ✔ Install pytest 7.2.0 successful

🎉 All complete!

pyproject.toml では tool.pdm.dev-dependencies の項に追加されています。

[tool.pdm]
[tool.pdm.dev-dependencies]
dev = [
    "pytest>=7.2.0",
]

[project]
...

グループ追加 (ここでは "plot" という名前にしています) は以下のように行えます。

$ pdm add -G plot matplotlib seaborn
Adding packages to plot dependencies: matplotlib, seaborn
🔒 Lock successful
Changes are written to pdm.lock.
Changes are written to pyproject.toml.
Synchronizing working set with lock file: 13 to add, 0 to update, 0 to remove

  ✔ Install cycler 0.11.0 successful
  ✔ Install pyparsing 3.0.9 successful
  ✔ Install kiwisolver 1.4.4 successful
  ✔ Install contourpy 1.0.6 successful
  ✔ Install seaborn 0.12.1 successful
  ✔ Install fonttools 4.38.0 successful
  ✔ Install pillow 9.3.0 successful
  ✔ Install matplotlib 3.6.2 successful

🎉 All complete!

最終的な pyproject.toml は次のようになりました。

[tool.pdm]
[tool.pdm.dev-dependencies]
dev = [
    "pytest>=7.2.0",
]

[project]
name = ""
version = ""
description = ""
authors = [
    {name = "nersonu", email = "nersonu@gmail.com"},
]
dependencies = [
    "numpy>=1.23.5",
    "pandas>=1.5.2",
]
requires-python = ">=3.9"
license = {text = "MIT"}

[project.optional-dependencies]
plot = [
    "matplotlib>=3.6.2",
    "seaborn>=0.12.1",
]

なお、 project.optional-dependencies の中に dev を含めたい場合は -dG で出来るようです。

virtualenv との併用

pyenv local hoge のように、 pyenvpyenv-virtualenv を利用している場合の挙動を確認しておきましょう。

$ pyenv local try_pdm
$ pip -V
pip 22.3.1 from /Users/nersonu/.pyenv/versions/3.10.9/envs/try_pdm/lib/python3.10/site-packages/pip (python 3.10)

試しに pdm init してみると、仮想環境を生成する質問がなくなり、生成されなくなりました。

$ pdm init
Creating a pyproject.toml for PDM...
Please enter the Python interpreter to use
0. /Users/nersonu/.pyenv/shims/python3 (3.10)
1. /Users/nersonu/.pyenv/shims/python (3.10)
2. /Users/nersonu/.pyenv/versions/3.10.9/bin/python3.10 (3.10)
3. /Users/nersonu/.pyenv/shims/python3.10 (3.10)
4. /Users/nersonu/.pyenv/shims/python3.9 (3.9)
5. /usr/local/bin/python3.9 (3.9)
6. /Users/aomi/.pyenv/versions/3.9.13/bin/python3.9 (3.9)
7. /usr/bin/python3 (3.8)
8. /usr/bin/python2.7 (2.7)
9. /Users/nersonu/Library/Application Support/pdm/venv/bin/python (3.10)
Please select (0): Using Python interpreter: /Users/nersonu/.pyenv/shims/python3 (3.10)
Is the project a library that will be uploaded to PyPI [y/n] (n):
License(SPDX name) (MIT):
Author name (nersonu):
Author email (nersonu@gmail.com):
Python requires('*' to allow any) (>=3.10): >=3.9
Changes are written to pyproject.toml.

このまま pdm run でコマンド実行してみたところ、既に設定した仮想環境を利用できました。 既にプロジェクトで仮想環境が設定されている場合、認識して使ってくれるようですね。

PEP 582 の世界を体験する

PEP 582 とは

peps.python.org

__pypackages__ というディレクトリをルートに置いておき、ここに保存されているパッケージを使おうという考えです。 こうすることで、仮想環境作ってそこでライブラリを入れて……という一連の流れをスキップ出来ます。 PEP 582 に対応しているツールはほとんどなく、PDM はこれを実現している希少なソフトウェアと言えるかもしれません。早速体験してみます。

PDM で PEP 582 の機能を有効にする

公式ページには、 bash でのやり方が載っていますが、自分は zsh を使っているので適当に .zshrc に突っ込んでみます。

$ pdm --pep582 >> ~/.zshrc
$ exec $SHELL

プロジェクトを作ってみる

先に __pypackages__ ディレクトリを作っておきます。

$ mkdir __pypackages__

pdm init してみます。

pdm init
Creating a pyproject.toml for PDM...
Please enter the Python interpreter to use
0. /Users/nersonu/.pyenv/shims/python3 (3.10)
1. /Users/nersonu/.pyenv/shims/python (3.10)
2. /Users/nersonu/.pyenv/versions/3.10.9/bin/python3.10 (3.10)
3. /Users/nersonu/.pyenv/shims/python3.10 (3.10)
4. /Users/nersonu/.pyenv/shims/python3.9 (3.9)
5. /usr/local/bin/python3.9 (3.9)
6. /Users/nersonu/.pyenv/versions/3.9.13/bin/python3.9 (3.9)
7. /usr/bin/python3 (3.8)
8. /usr/bin/python2.7 (2.7)
9. /Users/nersonu/Library/Application Support/pdm/venv/bin/python (3.10)
Please select (0):
Using Python interpreter: /Users/nersonu/.pyenv/shims/python3 (3.10)
Would you like to create a virtualenv with /Users/nersonu/.pyenv/versions/3.10.9/bin/python3? [y/n] (y): n
You are using the PEP 582 mode, no virtualenv is created.
For more info, please visit https://peps.python.org/pep-0582/
Is the project a library that will be uploaded to PyPI [y/n] (n): n
License(SPDX name) (MIT):
Author name (nersonu):
Author email (nersonu@gmail.com):
Python requires('*' to allow any) (>=3.10):
Changes are written to pyproject.toml.

You are using the PEP 582 mode, no virtualenv is created.
For more info, please visit https://peps.python.org/pep-0582/

ちゃんと有効化されてそうです。試しに numpy でも追加してみましょう。

$ pdm add numpy
Adding packages to default dependencies: numpy
🔒 Lock successful
Changes are written to pdm.lock.
Changes are written to pyproject.toml.
Synchronizing working set with lock file: 1 to add, 0 to update, 0 to remove

  ✔ Install numpy 1.23.5 successful

🎉 All complete!
...

$ tree -L 3 __pypackages__
__pypackages__
└── 3.10
    ├── bin
    │   ├── f2py
    │   ├── f2py3
    │   └── f2py3.10
    ├── include
    └── lib
        ├── numpy
        └── numpy-1.23.5.dist-info

numpy が __pypackages__ 配下に入っていますね。面白い。

PEP 582 を実際に使用することはなかなか無いと思いますが、初学者にとって仮想環境に触れずに済むようなパッケージ管理は非常に魅力的です。 これを実現できる PDM なかなか良さげですね。

速度比較

前回の記事を流用します。実は、比較先の一番左が PDM でした。 nersonu.hatenablog.com

Python のパッケージマネージャの速度比較をしている Web ページから引用しています。画像は前2つは前回と同じです。

install 速すぎ 👍👍👍
Poetry とは比べ物にならない 👍👍

Poetry で一番ネックな部分である install 周りが超速いです。素晴らしいですね。

一方でパッケージの単純追加は Poetry に負けています (これだけ Poetry 1.3.1)。

add は Poetry に劣る
それにしても install が速いです。 Poetry を使っていると、コンテナをビルドする際の poetry install がとてつもなく時間がかかるため、こういった点は PDM に軍配が上がるんだろうなと感じています。

おわりに

今回は PDM を試しましたが、なかなか面白く、高速で良い感じでした。 いつか機会があれば業務でも導入してみたいですね。

明日は id:kur0cky さんです。皆様、よいクリスマスを。

Poetry 1.3.0 がリリースされたよ

この記事は whywaita Advent Calendar 2022 11日目の記事です。

前日は id:hinananoha さんの

kokura.hatenadiary.jp

でした。壊れると悲しいですよね。

ということで、今回は whywaita さんでなにかするネタが思いつかなかったため、よく壊れる Python のパッケージマネージャ Poetry の最新バージョンについて書こうと思います。

Poetry とは

Poetry は Python のパッケージマネージャで、 requirements.txtsetup.py を代替していい感じにパッケージングしたり toml ベースでわかりやすくライブラリ管理できるソフトウェアです。

github.com python-poetry.org

ぼくと Poetry

9月頃、1.2.0 がリリースされました。

社内向けのスライドでキレている様子

公式のアナウンスでは 1.1.x と 1.2.x 系で互換があるとのことでしたが、壊れてしまい、社内でキレていました。

1.3.0 リリース 🎉

2022年12月9日、1.3.0 がリリースされました。(そのぼくがキレていた Issue は解決していませんが、特にキレてはないです)

1.2 では git 経由のインストールで subdirectory オプションが使えるようになったり*1、pyproject.toml のグループ機能が拡充されたりと個人的にかなり楽しみな機能追加が多くありました。

今回は特に個人的に楽しみなアップデートは無いので、さらっと気になったものだけさらっていこうと思います。

作業ディレクトリを CLI のオプションで指定可能に

-C, -directory で作業ディレクトリを指定することが、CLI 上で出来るようになりました。

$ poetry install -C python_workspace/

poetry.lock のフォーマットが変更に

Poetry 1.2.2 は 1.3 のフォーマットを読めるとかなんとからしいです。 試しに以下のパッケージを追加したもので、1.2.2 と 1.3 で diff をとってみましたが、よくわかりませんでした。

$ poetry add numpy pandas matplotlib
Using version ^1.23.5 for numpy
Using version ^1.5.2 for pandas
Using version ^3.6.2 for matplotlib

@generated で、自動生成を示す説明が付いているところは、今回の追加機能を反映されているものでしょう。

速度

Poetry は以下のような Issue が立つほどユーザが速度改善を望んでいます。

github.com

今回 poetry updatepoetry lock の挙動やキャッシュ周りに修正が入っているらしいので、Python のパッケージマネージャの速度比較をしている Web ページを見ながら、速度の影響が出ているのか少し見てみましょう。

Install 👎

lock と update 👍

一番利用する poetry install の時間がさらにかかっていそうでかなり厳しいものがありますね。

さいごに

なんやかんや言いつつも、管理が大変な requirements.txt や黒魔術のような setup.py にはもう戻りたくないため、 Poetry には今後もお世話になることと思います。

あと来年は whywaita さんをネタに出来るように、なにか考えていこうと思います。

明日は id:kyontan2 さんです。

*1:インストールしたい先の社内ライブラリがモノレポ構成とかだと特に助かった

Python のセイウチ演算子 (Walrus operator) ":=" と仲良くする

はじめに

セイウチ演算子 (Walrus operator) は Python 3.8 で登場した代入式を利用するための演算子です。

なんとなく最近使うことが多いので、自分のユースケースを軽くまとめます。

セイウチ演算子のイメージ

C言語 を利用したことがある人ならば、C99 での for 文のカウント変数の宣言のようなものだと思ってください。

これを

int i = 0;
for (i = 0; i < 10; i++) {
    ;
}

こうする話です。

for (int i = 0; i < 10; i++) {
    ;
}

Python のセイウチ演算子

詳しくは What's New In Python 3.8 の説明通りです。 記事執筆が2022年なので、既にリリースから3年もの月日が経過していますね。

例えば if 文内で評価した式を再利用している際に、評価対象を出力するための関数等の呼び出しの重複を防いだりすることが出来ます。

これを

if len(list_object) > 10:
    print(len(list_object))

こうする話です。

if (n := len(list_object)) > 10:
    print(n)

3.7 以前では n への代入を if 文の前に書けば重複の呼び出しは回避できますが、セイウチ演算子 := を使って if 文内に書くことができます。

なお、> 手前の () を外すと、先に > が評価されてしまい、 n には真理値が代入、 if は n を評価するため if 文としての処理は変わりませんが n の値が変わるため、気をつけなければなりません。

個人的ユースケース

What's New In Python 3.8 でのユースケースで事足りていますが、個人的に便利に使っているユースケースを書き残しています。

正規表現

これは、公式の例にもあるものです。 マッチオブジェクトを if 文の中で利用するため、一番使っているかもしれません。

if match := re.search(r"regex_pattern", text):
    # 正規表現にマッチした際の処理
    result_span = match.span()

環境変数の呼び出し

os.environ から値を取り出す際に dict.get() を利用する際に使えます。 直接環境変数os.environ["ENVVAR"] で取り出すと対応する環境変数に値が無い場合に KeyError になりますが、無い場合を許したい処理に工夫の余地があるのです。 dict.get() は key に対応する値が無い場合、デフォルトで None を返す*1仕様になっており、これを利用した if 文でセイウチ演算子が活躍します。

if envvar := os.environ.get("ENVVAR"):
    # 環境変数がある場合の処理
    print(envvar)

スライスによる取り出し

Python のスライスをリストのようなオブジェクトに与えた際、完全にインデックス外を指す場合に空のリストを返す性質があります。

a = [1, 2, 3]

print(a[0:2])  # [1, 2]
print(a[1:4])  # [2, 3] : 取れるところまで取る
print(a[3:4])  # [] : 空

Python はリストが空かどうかのチェックを if 文で書く際、リスト長の長さを取って判定するのではなく、そのままリストを評価させて空であれば False となることを利用してそのまま if list_object: とすることは有名です *2 が、スライスを利用した場合に空のリストを返すことを利用して、そのままセイウチ演算子を使う処理を書くことがあります。

if sliced_list := list_object[slice_object]:
    # スライスで取れた場合の処理
    print(sliced_list)

おわりに

他にも while 文や内包表記で活躍するセイウチ演算子ですが、非常に便利な反面、コードの可読性を損なう書き方になる場合も多くあります。 下手な乱用には気をつけなければなりません (自戒)。

*1:実際は default 引数に設定された値を返すが、その初期値が None

*2:PEP 8の Style Guide を参照

gokart + PyTorch Lightning でいい感じに深層学習モデルを動かす

この記事は Sansan Advent Calendar 2021 20日の記事です。

前日は、id:kur0cky さんの

kur0cky.hatenablog.com

でした。

私は過去陸上部だった時代があるのですが、個人的にはフォームも気にしたほうがいいと思います(感想)。

この記事は何か

私が所属している研究開発部には、パイプラインに則ってコードを書こうという文化が浸透してきました。 これらのパイプラインのパッケージは、弊社の研究員が弊社ブログにて既に様々書いているので、ぜひこちらを御覧ください。

buildersbox.corp-sansan.com

buildersbox.corp-sansan.com

さらに、PyTorch のラッパである PyTorch Lightning はいいぞとの布教をとある研究員の方から受け、良さそうだなとなりました。 PyTorch Lightning については、とある研究員の方がいつだかに書いた記事もぜひご覧ください。

buildersbox.corp-sansan.com

こういったパイプラインやラッパは処理の切り分けが明確になることで、コードの可読性を向上させたり等様々なメリットがあります。 せっかくなので私もこのびっぐうぇーぶに乗って、パイプラインである gokart と PyTorch Lightning を組み合わせてサクッとコードを書いてみようと思います。

準備

今回は gokart を使うので、雑に cookiecutter からテンプレートを持ってきて使います。 エムスリーさんが用意しているものがあるので、ありがたく使っていきます。

github.com

次に、 PyTorch を poetry で入れていきましょう。

ところで、PyTorch と poetry はボチボチ相性が悪いです。 PyTorch はアーキテクチャ等の環境に沿ったものを入れないといけませんが、例えば、特定の source から参照する方法で入れることを試みることができます。

github.com

一方、↑の Issue にのように source を指定すると、他のライブラリまで指定した source を参照しようとしていまい、うまく動きません。

[tool.poetry.dependencies]
python = ">=3.9,<3.10"
gokart = "*"
torch = { version = "=1.9.0+cpu", source = "pytorch" }
torchvision = { version = "=0.10.0+cpu", source = "pytorch" }

[[tool.poetry.source]]
name = "pytorch"
url = "https://download.pytorch.org/whl/cpu/"
secondary = true

上記は、そのうまく動かない例です。 gokart をこの PyTorch で指定している url から取ってこようとして 403 が返ってきます。

これはそもそも PyTorch のインストールが PEP 503 に対応したことで、 pipenv の設定がシンプルになるというものがあり、これ poetry でも出来るやんけと思ったら出来なかったという不具合です。

github.com

Downgrading to Poetry 1.0.10 might be a workaround (ontop of my previous comment) as per: python-poetry/poetry#4704 (comment)

Haven't tested because it's too much of a pain, switching to pip!

github.com

しんどいですね。

今回は、最悪ですが whl の url を直接見に行く*1ことで一旦の回避策とします。 こちらですが、 今現在最新の poetry 1.1.12 では、 torchvision が torch の "x.x.x+cpu" に依存しているのにも関わらず 、 '+' 以降がうまく解釈できずに "x.x.x" に依存してるからうまくいかないよと怒られます。 この記事では、さらに苦肉の策として対応されている poetry 1.2.0a2 のプレビュー版を使っています。 誰か私を楽にしてください。

[tool.poetry.dependencies]
python = ">=3.9,<3.10"
gokart = "*"
torch = { url = "https://download.pytorch.org/whl/cpu/torch-1.10.1%2Bcpu-cp39-cp39-linux_x86_64.whl" }
torchvision = { url = "https://download.pytorch.org/whl/cpu/torchvision-0.11.2%2Bcpu-cp39-cp39-linux_x86_64.whl" }

Python のバージョンに依存するので ">=3.9,<3.10" にしてます。

ここまで、書いたら poetry install です。やっと PyTorch が入りましたね。 PyTorch Lightning は poetry が torch と torchvision のバージョンに合わせて依存解決出来るので問題ありません。

書いた

準備が出来たのでサクッとコードを書きました。 特に PyTorch Lightning は初めて書いたので、もっといい感じの書き方があればぜひ教えて下さい。 今回は ResNet で CIFAR-10 データで学習評価まで行うものにしました*2

gokart のタスクは以下の4つに分けてみました。 それぞれサラッと見ていきましょう。

データ前処理

gokart

import gokart
import luigi


class GokartTask(gokart.TaskOnKart):
    task_namespace = 'sansan_adcal_2021'


class PreprocessDataModuleTask(GokartTask):
    _v: int = luigi.IntParameter(default=0)

    def run(self):
        data_module = DataModule()
        data_module.prepare_data()

        self.dump(data_module)

PyTorch Lightning の LightningDataModule

from pathlib import Path

import pytorch_lightning as pl
import torch
from torch.utils.data import DataLoader
from torchvision import datasets, transforms


class DataModule(pl.LightningDataModule):
    def __init__(self, dataset_root_path: Optional[Path] = None, train_size: float = 0.8, seed: int = 1111):
        super().__init__()
        self.dataset_root_path = dataset_root_path
        self.train_size = train_size
        self.seed = seed
        if self.dataset_root_path is None:
            # gokart の中間ファイルの出力に合わせて resources 以下に入れることにする
            self.dataset_root_path = Path(__file__).resolve().parents[2].joinpath('resources', 'dataset')
        self.data_transforms = transforms.Compose([
            transforms.Resize(256),
            transforms.CenterCrop(224),
            transforms.ToTensor(),
            transforms.Normalize(
                mean=[0.485, 0.456, 0.406],
                std=[0.229, 0.224, 0.225]
            ),
        ])

    def prepare_data(self) -> None:
        datasets.CIFAR10(
            root=self.dataset_root_path,
            download=True
        )

    def setup(self, stage: Optional[str] = None) -> None:
        if stage == "fit":
            all_train_dataset = datasets.CIFAR10(
                root=self.dataset_root_path,
                train=True,
                transform=self.data_transforms
            )
            len_train_dataset = int(self.train_size * len(all_train_dataset))
            len_val_dataset = len(all_train_dataset) - len_train_dataset
            self.train_dataset, self.val_dataset = torch.utils.data.random_split(
                all_train_dataset,
                [len_train_dataset, len_val_dataset],
                generator=torch.Generator().manual_seed(self.seed)
            )
        elif stage == "test":
            self.test_dataset = datasets.CIFAR10(
                root=self.dataset_root_path,
                train=False,
                transform=self.data_transforms
            )

    def train_dataloader(self) -> DataLoader:
        return DataLoader(self.train_dataset, batch_size=256, num_workers=8)

    def val_dataloader(self) -> DataLoader:
        return DataLoader(self.val_dataset, batch_size=256, num_workers=8)

    def test_dataloader(self) -> DataLoader:
        return DataLoader(self.test_dataset, batch_size=256, num_workers=8)

定義した DataModule に基本的にデータセットとしての裁量を託しています。 一度だけ呼ぶ prepare_data() メソッドは、このタスク内で呼ぶことにしました。 今回特に定義をしていませんが、前処理に関するパラメータや train, val のデータセットの分割のシード値等を、gokart 側で受け取って DataModule に渡せると良さそうです。

モデル準備

gokart

class PrepareModelTask(GokartTask):
    _v: int = luigi.IntParameter(default=0)

    def run(self):
        model_module = ModelModule()

        self.dump(model_module)

PyTorch Lightning の LightningModule

import torchmetrics
from torchvision.models import resnet34


class ModelModule(pl.LightningModule):
    def __init__(self, *args, **kwargs) -> None:
        super().__init__()

        self.model = resnet34(pretrained=True)
        self.model.fc = torch.nn.Linear(512, 10)
        self.criterion = torch.nn.CrossEntropyLoss()
        self.val_acc = torchmetrics.Accuracy()
        self.test_acc = torchmetrics.Accuracy()

    def configure_optimizers(self):
        optimzier = torch.optim.Adam(self.model.parameters(), lr=1e-3)
        return optimzier

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        return self.model(x)

    def training_step(self, batch, *args, **kwargs) -> torch.Tensor:
        x, y = batch
        pred_y = self.forward(x)

        loss = self.criterion(pred_y, y)
        self.log("train_loss", loss)
        return loss

    def validation_step(self, batch, *args, **kwargs) -> torch.Tensor:
        x, y = batch
        pred_y = self.forward(x)

        loss = self.criterion(pred_y, y)
        self.val_acc(pred_y, y)
        self.log("val_loss", loss)
        self.log("val_acc", self.val_acc, on_step=True, on_epoch=True)
        return loss

    def test_step(self, batch, *args, **kwargs) -> torch.Tensor:
        x, y = batch
        pred_y = self.forward(x)

        loss = self.criterion(pred_y, y)
        self.test_acc(pred_y, y)
        self.log("test_loss", loss)
        self.log("test_acc", self.test_acc, on_step=True, on_epoch=True)

モデルの構築・準備も同様に gokart はタスクの分割と、パラメータの受け渡し口としての使い方が良さそうに感じています。 (データセット同様、今回は何もパラメータを渡してないですが)

モデル訓練

class TrainTask(GokartTask):
    _v: int = luigi.IntParameter(default=1)

    def requires(self):
        return {
            "dataset": PreprocessDataModuleTask(),
            "model": PrepareModelTask()
        }

    def run(self):
        data_module: pl.LightningDataModule = self.load("dataset")
        model_module: pl.LightningModule = self.load("model")

        trainer = pl.Trainer(
            max_epochs=10,
            min_epochs=1
        )

        data_module.setup('fit')
        trainer.fit(model_module, datamodule=data_module)

        self.dump(trainer)

データセットのタスクとモデルのタスクを依存させるようにして、それぞれの LightningModule を渡しています。 各 epoch の checkpoints は PyTorch Lightning 側で持つので、ここを gokart に持たせる必要はないと判断しました。

モデル評価

class EvaluateTask(GokartTask):
    _v: int = luigi.IntParameter(default=0)

    def requires(self):
        return {
            "dataset": PreprocessDataModuleTask(),
            "model": PrepareModelTask(),
            "trainer": TrainTask(),
        }

    def run(self):
        data_module: pl.LightningDataModule = self.load("dataset")
        model_module: pl.LightningModule = self.load("model")
        trainer: pl.Trainer = self.load("trainer")

        data_module.setup("test")
        result = trainer.test(model_module, datamodule=data_module)

        self.dump(result)

Trainer を持ってこさせるようにしています。 評価の処理そのものは LightningModule 側で持っているので、結果を dump() させておいて、後で参照しやすいようにだけしておきました。

まとめと所感

今回は gokart と PyTorch Lightning を組み合わせて、深層学習モデルを動かすサンプルを書いてみました。 深層学習モデル部分の裁量を PyTorch Lightning に持たせ、処理を gokart のタスクで切ることで全体的に処理フローが見えやすい形になったかと思います。 学習のコードがややわかりづらくなりがちな PyTorch のコードは、 PyTorch Lightning で書くことで嫌でも処理がわかりやすくなって良いですね。 また、アドカレとしての締切の時間の都合上、直書きしてしまったパラメータが多いのですが、こういうパラメータはタスクごとに gokart で受け渡せるようにしておくともっと良いかと思います。

コードの全体像は後日まとめて GitHub 上に公開しようと思います。 文章を書いた人間としては、なんだか Poetry 上での torch + torchvision インストールバトルが本題になってしまったようで複雑な気持ちもありますが、許してください。

*1:ここ (https://download.pytorch.org/whl/torch_stable.html) にあります

*2:時間がかかるので、今回は epoch 数等適当に少なめで動作確認だけしました。