8
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ディップAdvent Calendar 2024

Day 17

分析現場の悲劇!データサイエンティストが作りがちな怖い関数をお見せします

Posted at

はじめに

こんにちは、事業会社で働いているデータサイエンティストです。

またアドベントカレンダーの季節になりましたね。

私がR言語を使い始めたのは、2017年の冬だったと記憶しています。当時、交換留学先の一橋大学で、優秀な先輩からプログラミング言語と定量分析を勧められ、台湾に帰国後、一人で学び始めました。

そのため、今年でR言語を使い始めて7年目になります。2021年からは、純粋なデータ分析にとどまらず、ビジネスの現場での活用も始めたため、自分は大半のR言語ユーザーよりも、R言語をプログラミング言語として真剣に向き合っていると認識しています。

今年の記事では、この7年間の経験を通じて気づいた、関数の作り込みすぎに関連するプログラミング初心者が陥りがちなアンチパターンを三つ紹介します。

ただ、あらかじめ強調しておきたいのは、Rcppなどを利用した他言語との連携や、高度なR言語の活用法を熟知している方は対象外であるという点です。むしろ、最適化の目的で、あえてここで紹介するアンチパターンを実行することもあるかもしれません。以下で紹介するアンチパターンは、あくまでも初心者や経験の浅い方を対象としています。

また、ここで紹介するアンチパターンはR言語、tidyverse(特にdplyr)を活用する際にフォーカスしているため、Pythonなどの他言語に当てはまらないかもしれません。

責任分解は大事

ここで紹介したいアンチパターンは、どれも関数の作り込みすぎに関連します。関数の作り込みすぎというのは、関数に過剰な役割を与えていることを意味します。

関数に過剰な役割を持たせると、コードの保守性や拡張性が著しく損なわれます。一つの関数が多くの責任を担っている場合、その一部を改良したり、他の方法に置き換えたりするのが非常に困難になります。また、そのような関数を利用する他の人のプログラムに、気づきにくいバグを引き起こす原因になる可能性もあります。この点については、本文で具体的な例を挙げて詳しく説明します。

さらに、関数が複雑化すると、その内部でどのような処理が行われているのかを把握するのも困難になります。コードのレビューやデバッグの際に余計な時間がかかり、チームでの協力作業にも支障をきたします。一人で行うプロジェクトでも、昨日のあなたと明後日のあなたは別人です。これはマジで信じてください💦

単一責任の原則(Single Responsibility Principle)に則り、関数を適切に分解して役割を明確にすることで、後から部分的に改善したり、新しい要件に対応したりする柔軟性を確保できます。

また、適切な責任分解は、他の人がコードの一部を再利用したり、別の方法で実装を改善したりする道を開きます。関数の役割を最小限に絞り、シンプルで分かりやすい設計を目指すことが、効率的なプログラミングの第一歩となります。

最後に、エンジニアがあまり認識していないようなので、もしかしたらデータサイエンティスト特有の問題かもしれませんが、作り込みすぎると、どうしても処理がモデルや前処理の範囲を超えて、あなたの専門外や苦手分野にまで広がってしまいます。そんなときは、無理にChatGPTに頼って作り込むよりも、むしろ最小限に抑えて、援軍(エンジニアチームや他のプログラミング言語)を入れるほうが良いです。

この記事の内容を実践せず、アンチパターンを続けること自体は全く問題ありません。ただし、その場合、痛い失敗を通じて同じことを学ぶ羽目になる可能性が高いでしょう。

さて、言葉だけだとわかりづらいと思いますので、ここでは実例を挙げて説明します。

アンチパターン1:勝手にいらない処理を仕込む

まず、この関数を考えてください:

my_function <- function(x){
  future::plan(future::multisession)
  x |>
    furrr::future_map(
      \(this_x){
        stringr::str_c(this_x, "ですよ!")
      },
      .progress = TRUE
    )
}

(ひとまず、stringr::str_cがベクトル処理に対応していることは忘れてください)

この関数はどんなことをしているのかというと

  • 分散処理の方法の定義(future::plan(future::multisession)
  • 繰り返し処理の方法の定義(furrr::future_map
  • 繰り返し処理の中身の定義(stringr::str_c(this_x, "ですよ!")
  • 進捗ログの有無(.progress = TRUE

実行結果はこうなります:

> my_function(1:5)
 Progress: ─────────────────────────────────────────────────────────────────────── 100%
[[1]]
[1] "1ですよ!"

[[2]]
[1] "2ですよ!"

[[3]]
[1] "3ですよ!"

[[4]]
[1] "4ですよ!"

[[5]]
[1] "5ですよ!"

入力に対してですよをつけたいのはすごいわかります。入力に対してですよをつけるのは簡単すぎるですが、もっと複雑な統計学・政治学方法論・計量経済学・計量生物学・機械学習などのモデルを想像してください。実質アルゴリズム的には構造が一緒です。

入力に対してですよをつけたい気持ちはわかります。ただ

  • 俺がどう分散処理するかを勝手に決めんな💢
  • 俺がどう繰り返し処理するかを勝手に決めんな💢
  • お前が勝手につけたログのせいで全体のログがぐちゃぐちゃになったぞこらぁ💢

という三つの批判が出てきます。

要するに、関数が勝手に環境や処理の仕様を決めてしまうのは迷惑極まりないということです。この関数は「便利そう」に見えるかもしれませんが、実は深刻な問題を引き起こします。

まず、関数内部でfuture::plan()を勝手に呼び出してしまうと、利用者が既に設定している分散処理のプランが上書きされてしまいます。その結果、他の処理で意図しない動作が起こる可能性が高くなります。「分散処理を使いたくない・使えない」あるいは「(エンジニアチームとの合意に基づいて)違う分散処理の設定を使いたい」という利用者の意図を完全に無視しています。その結果、この関数は他の場面での転用が難しく、特定の状況でしか役立たない「使い捨てのコード」になってしまいます。

次に、利用者によって、自分が設定した覚えのないログや処理が入り込むと、エラーの原因や挙動の確認が非常に困難になります。特に分散処理のような並列実行環境では、デバッグの手間が通常の数倍に跳ね上がります。

このように、関数が過剰に責任を持ちすぎると、利用者の負担が増えるだけでなく、コードの保守性や拡張性を著しく損ないます。

「自分は親切心でやってる」と思うかもしれませんが、むしろ利用者にストレスを与えるだけです。関数は「必要最小限の責任」に留め、環境や仕様の設定は利用者に委ねるべきです。

ではどうすればいいのかというと、この関数はこう設計すべきです:

my_good_function <- function(x){
  stringr::str_c(x, "ですよ!")
}

このように、完全に一つの入力に対して一つの出力を返す設計にすることで、利用者はより自由にこの関数を使って大量のデータをどう処理するかを検討できるようになります。

したがって、Rcppなどの外部プログラミング言語や外部ツール、または線形代数などの数学的性質を活用した計算でない限り、基本的にはこのように完全に一つの入力に対して一つの出力を返す設計が望ましいと思います。

アンチパターン2:重い中間処理を切り出さない

次に、よく見かけるアンチパターンとして「重い中間処理を切り出さない」という問題があります。例えば、以下のような関数を考えてみましょう。

my_function <- function(x) {
  purrr::reduce(1:1000, ~ .x + sqrt(.y)) + x
}

この関数は、入力xに対して1から1000までの平方根の合計を加算しています。この関数が問題となるのは、次の2点です。

まず、purrr::reduce(1:1000, ~ .x + sqrt(.y))の計算結果は、入力 x によって変わることはありません。これ自体が問題というわけではありませんが、より複雑な中間計算が含まれる場合、毎回同じ計算を繰り返していると、パフォーマンスが低下する可能性があります。

特に、大量のデータを処理する場合、計算を途中で切り出して効率化しないと、メモリや処理時間の過剰な消費が発生し、システム全体に悪影響を与えることになります。

また、こうした重い計算が関数内で直接行われていると、コードを読む人や後から保守する人にとって、その処理の流れが非常に分かりづらくなります。重い処理を切り出して小さな部品に分けることで、処理がどのタイミングで行われるか、どう進行しているかを把握しやすくなります。

具体的な解決策として、重い中間処理を一度変数として持っておくことをお勧めします:

big_result <- purrr::reduce(1:1000, ~ .x + sqrt(.y))

big_result + 12

このように、重い処理を切り出しておけば、計算結果を再利用したり、処理の途中を分けてデバッグしたり、並列処理を検討したりすることが容易になります。計算結果を途中で保存しておくことで、無駄な再計算を防ぐことができ、効率的なプログラム設計が可能となります。

アンチパターン3:作りたいから作った関数

最後に、データ分析においてよく見かけるのが「作りたいから作った関数」です。これは、関数が過剰に一般的で、特定の処理に対して必要ない抽象化を行っている場合に発生します。例えば、以下のようなコードが該当します:

tasu_ichi <- function(x){
  x + 1
}

(偏見かもしれませんが、このようなコードは、よく「エンジニアかっこいい!」と言ってR言語からPythonに切り替え、深く考えずにPythonのカプセル化スタイルのオブジェクト指向プログラミングに憧れる人たちのコードで見られます)

一見すると、非常に単純で理解しやすい関数に見えるかもしれませんが、この関数の問題点は、あまりにも単純すぎて、実際には他の方法で直接処理すれば十分であるという点です。この関数を使うためにわざわざ関数化する意味がほとんどないため、コードが不必要に冗長になるだけでなく、メンテナンスにも手間がかかります。

実際には、次のように直接処理を行うことの方が適切です。

x + 1

関数を作る理由は、再利用性やコードの可読性向上のためですが、このように非常に単純な処理で関数を作ることは、逆にコードの理解を難しくする場合があります。特に、関数が何をするかが一目でわかる場合、その処理を関数化することは避けた方が良いです。

意味のない関数を避けるためには、関数化する前に「本当にこの処理が一般的で、他の場所でも使う可能性があるか?」と自問自答することが重要です。再利用性が低い、あるいはコードの可読性向上に寄与しない場合、関数を作成する意味は薄くなります。

このアンチパターンを避けるためには、関数化すべきかどうかを慎重に判断する必要があります。冗長なコードを避けることで、より簡潔で効率的なプログラムを書くことができます。

結論

本記事では、データサイエンティストでプログラミングに詳しくない人が陥りがちなアンチパターン、特に「関数の作り込みすぎ」に関連する問題について説明しました。関数に過剰な責任を与えることは、コードの保守性や拡張性を著しく低下させ、結果的にバグや予期しない挙動を引き起こす原因となります。このような問題を避けるためには、関数設計において「単一責任の原則」を遵守し、責任分解を設計して、シンプルで分かりやすい設計を心がけることが重要です。また、環境設定や処理仕様を関数内で強制することなく、利用者に柔軟性を持たせることで、再利用性やメンテナンス性を向上させることができます。

初心者が直面するこれらの問題は、実際に経験を積むことで自然と学べますが、最初から適切な設計と注意を払うことで、より効率的で高品質なコードを書くことが可能になります。高品質なコードは、社会改善への貢献度を高めることに繋がります。

最後に、私たちと一緒に、データサイエンスの力で社会を改善したい方はこちらをご確認ください:

8
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?