Haskell版 コンビネータを用いた曲レコメンデーション
2025-06-30 10:00:25
原文: Song recommendations from Haskell combinators by Mark Seemann
IOを含むリストの走査。リファクタリングの一例。
この記事は、「関数型プログラミングによる設計の代替アプローチ」シリーズの一部です。前回の記事では、サンプルコードを標準的なF#のコンビネータによる合成へとリファクタリングする方法を紹介しました。これは、関数型プログラミングの概念や構文を使った、現実的な課題へのアプローチですが、私自身はこれを本当の意味での関数型アーキテクチャとは見なしていません。
Haskell版は、三つの言語バージョンの中でいちばんイディオマティックになると思われるかもしれません。ところが実際には、この記事のコードはF#版よりも見栄えを良くするのに苦労しました。原因は後述しますが、Haskellのデフォルトである右から左への合成順序と、一部の演算子の優先順位ルールが組み合わさって、ややこしくなってしまうことにあります。
サンプルコードの前提については、過去の記事を参照してください。この記事で紹介しているコードは、Gitリポジトリの combinators ブランチにあるもので、「曲レコメンデーションのHaskellへの移植」で紹介したコードをリファクタリングしたものです。
ここでの目標は、レコメンデーションアルゴリズム全体の中から純粋関数を抽出し、=<<、<$>、traverse などの標準的なコンビネータを用いて合成することです。
ローカルな状態変更の除去
まず取り組んだのは、‘baseline’ コードベースで使用されていた IORef によるローカルな状態変更を排除することでした。これはそれほど難しくありませんでした。途中のマイクロコミットに興味のある方は、Gitリポジトリをご覧ください。中間の状態はこんな感じになりました:
getRecommendations srvc un = do
-- 1. 自ユーザーのトップスクロブルを取得
-- 2. 同じ曲を聴いた他ユーザーを取得
-- 3. そのユーザーのトップスクロブルを取得
-- 4. 曲を集約してレコメンドを生成
-- Impure
scrobbles <- getTopScrobbles srvc un
-- Pure
let scrobblesSnapshot = take 100 $ sortOn (Down . scrobbleCount) scrobbles
-- Impure
recommendations <-
join <$>
traverse (\scrobble ->
fmap join $
traverse (\otherListener ->
fmap scrobbledSong .
take 10 .
sortOn (Down . songRating . scrobbledSong) .
filter (songHasVerifiedArtist . scrobbledSong) <$>
getTopScrobbles srvc (userName otherListener)) .
take 20 <$>
sortOn (Down . userScrobbleCount) .
filter ((10_000 <=) . userScrobbleCount) =<<
getTopListeners srvc (songId $ scrobbledSong scrobble))
scrobblesSnapshot
-- Pure
return $ take 200 $ sortOn (Down . songRating) recommendations
確かに、アルゴリズムの見た目としては読みやすいとは言えませんが、これはあくまで途中段階です。いつも申し上げている通り、Haskellのコードは基本的に右から左に読むのが普通ですし、複数行にわたる場合は、下から上に読んだほうが理解しやすいこともあります。このことを理解したうえで、Haskellの一般的な知識とインデントの工夫を組み合わせれば、そこまで読みにくいわけではありませんが、半年後に自分がこのコードをまた見るとなると、あまり気が進みません。そして、仮に同僚がいたとしても、このコードを見せたいとは到底思えない出来です。
細かく見ると、標準の >>= 演算子ではなく、逆方向のバインド演算子 =<< を使っていることに気づくかもしれません。私はHaskellを書くとき、これをよく使います。というのも、Haskellの多くは右から左へと構成されており、=<< はその方向性と一致するからです。一方、>>= はモナド的な処理を左から右へと合成します。これは(西洋の読者にとっては)より自然に思えるかもしれませんが、それ以外の部分がすべて右寄りの読み順なので、>>= を使うと読み方が混乱してしまうのです。
私自身、西洋出身としては左から右へ読む方が好きですが、Haskellの逆方向の流れに抗うのはけっこう大変です。
-- Pure や -- Impure というコメントが示しているように、純粋な関数と副作用のある処理を混ぜると、全体が不純になります。そういう混ぜ方をすればするほど、純粋な部分は減ってしまいます。
単一の式へ
この中間段階から、全体をひとつの不純な式にまとめるまでには、あまり手間はかかりませんでした。
getRecommendations srvc un =
-- 1. 自ユーザーのトップスクロブルを取得
-- 2. 同じ曲を聴いた他ユーザーを取得
-- 3. そのユーザーのトップスクロブルを取得
-- 4. 曲を集約してレコメンドを生成
take 200 . sortOn (Down . songRating) <$>
((\scrobbles ->
join <$>
traverse (\scrobble ->
fmap join $
traverse (\otherListener ->
fmap scrobbledSong .
take 10 .
sortOn (Down . songRating . scrobbledSong) .
filter (songHasVerifiedArtist . scrobbledSong) <$>
getTopScrobbles srvc (userName otherListener)) .
take 20 <$>
sortOn (Down . userScrobbleCount) .
filter ((10_000 <=) . userScrobbleCount) =<<
getTopListeners srvc (songId $ scrobbledSong scrobble))
(take 100 $ sortOn (Down . scrobbleCount) scrobbles)) =<<
getTopScrobbles srvc un)
とはいえ、読みやすくなったとは言えませんでした。
補助関数
これまでの取り組みと同じように、適切に命名された補助関数を抽出すると、少し読みやすくなります。たとえば次のようなものです:
getUsersOwnTopScrobbles :: [Scrobble] -> [Scrobble]
getUsersOwnTopScrobbles = take 100 . sortOn (Down . scrobbleCount)
これは一行だけなので、そこまで印象的ではないかもしれませんが、どれも特に複雑な処理ではありません。最も大きな関数でも以下のようなものです:
getTopScrobblesOfOtherUsers :: [Scrobble] -> [Song]
getTopScrobblesOfOtherUsers =
fmap scrobbledSong .
take 10 .
sortOn (Down . songRating . scrobbledSong) .
filter (songHasVerifiedArtist . scrobbledSong)
他の関数についてはGitリポジトリで見ることができます。どれもモジュールからエクスポートしていないので、実装の詳細として、将来的に変更や削除が可能です。
ここまで来れば、全体の処理を合成できます。
getRecommendations srvc un =
-- 1. 自ユーザーのトップスクロブルを取得
-- 2. 同じ曲を聴いた他ユーザーを取得
-- 3. そのユーザーのトップスクロブルを取得
-- 4. 曲を集約してレコメンドを生成
(aggregateTheSongsIntoRecommendations . getTopScrobblesOfOtherUsers) . join <$>
((traverse (getTopScrobbles srvc . userName) .
getOtherUsersWhoListenedToTheSameSongs) . join =<<
(traverse (getTopListeners srvc . (songId . scrobbledSong)) .
getUsersOwnTopScrobbles =<< getTopScrobbles srvc un))
一部のかっこが複数行にまたがって、慣例とは異なる書き方になっています。これは、アルゴリズムを4つのステップに分けて見やすくしつつ、Haskellの構文上の制約を守り、hlintの警告も出ないように工夫した結果です。
目を細めて見れば、join のような演算子やグルーコードは背景に溶け込むはずだと主張することもできるかもしれませんが、今回に関しては自分でもそれはちょっと無理があると思います。
このようにコードを合成するだけでは、自己説明的なコードに近づかなかったので、私は不満を感じています。前回の記事で紹介したF#での合成の方が、その点ではうまくいっていた気がします。
シンタックスシュガー
この記事の目的は、標準的なコンビネータを使ってアルゴリズムを構成する方法を示すことにありました。しかしここまで述べてきたように、可能ではあるものの、コードの可読性は期待通りにはなりませんでした。
Haskellに詳しい読者からすれば、私のやり方に不満かもしれません。なぜなら、Haskellにはもっと良いやり方があるからです。do記法というシンタックスシュガーを使えば、以下のように書くこともできます:
getRecommendations srvc un = do
-- 1. 自ユーザーのトップスクロブルを取得
-- 2. 同じ曲を聴いた他ユーザーを取得
-- 3. そのユーザーのトップスクロブルを取得
-- 4. 曲を集約してレコメンドを生成
userTops <- getTopScrobbles srvc un <&> getUsersOwnTopScrobbles
otherListeners <-
traverse (getTopListeners srvc . (songId . scrobbledSong)) userTops <&>
getOtherUsersWhoListenedToTheSameSongs . join
songs <-
traverse (getTopScrobbles srvc . userName) otherListeners <&>
getTopScrobblesOfOtherUsers . join
return $ aggregateTheSongsIntoRecommendations songs
処理を名前付き変数で分けて書くことで、読み順を上から下にできます。また、Data.Functor の <&> 演算子を使えば、各行の中身も左から右へと読めるようになります。
IO に関わる操作を純粋関数と混在させるという制約のもとでは、私としてはこれがベストな形でした。
結論
このように純粋関数と副作用のある処理を混在させるのは、アプリケーションのエントリーポイント(たとえば main)ように、全体を組み立てるときには必要になることがあります。ただ、一般的に見て関数型プログラミングとして好ましいスタイルとは言えません。getRecommendations は非決定的な処理であり、全体が不純です。
とはいえ、Haskellのコードでもこういう構成が必要になる場面はあります。だから、このような実装方法を紹介する意義はあると思います。ただし、他にも代替となるアーキテクチャは存在します。