meowの覚え書き

write to think, create to understand

connpassの開催前イベントのキャンセル人数を粗く見積もる方法

f:id:meow_memow:20201102212947j:plain

connpassはIT勉強会のイベント管理機能や参加者管理するサービスなどを提供するプラットフォームです。
イベント主催者は開催日まで参加者を募りますが、途中で参加申込者のキャンセルも出ます。
主催者側としては、イベント開催前にキャンセル人数を見積もれると役に立つことがあるので、粗く見積もる方法を考えました。
手作業でも求められますが非常に手間がかかるので、urlを入れると見積もったキャンセル人数が出るpythonスクリプトを実装しました。

キャンセル人数を見積もるアイディア

現行の参加エントリーしている人(抽選中、キャンセル待ちを含む)を参加予定者と呼ぶことにします。
参加予定者は、参加するかキャンセルするかを確率的に決めていると仮定します。例えばコイントスで決める人ならば1/2ですし、3本ある くじ のうち1本だけある当たりくじを引いたら参加すると決めている人ならば1/3、トランプでハートが出た時だけ参加すると決めている人ならば1/4です。
キャンセル(予定)人数は、各参加予定者の確率の総和を求めた後、参加予定者の総和から引けば求められます。

キャンセル人数を見積もる例として上記のコイントス、くじ、トランプで決めている人達が参加予定者であることを考えます。
確率の総和は13/12=1.08です。参加予定者の総和は3です。したがって、キャンセル予定人数は3-1.08=1.92です。
このことから、約2名がキャンセルしそうだということがわかります。

ただし現実問題として参加する確率はわからないので、過去の参加傾向から推定します。すなわち過去のイベントのうち何回参加しているかの割合を参加予定者の確率とします。例えば、過去に10回イベントに参加登録したうち9回参加していたら確率は0.9とします。

アイディアの詳細

なぜ、このようになるのかの説明をします。 現時点でN人が参加エントリーをしているとします。i番目の参加予定者が参加したら1,キャンセルしたら0として定義した確率変数X _ iがあるとします。N個の確率変数X _ iはそれぞれ独立とします。
そして、参加する確率をp _ iとした時、X _ i \in \left\{ 1, 0 \right\}, P(X _ i=1)= p _ i, P(X _ i=0) = 1 - p _ iと表せます。

(現時点での)参加予定者が参加する総数もまた確率変数となり、X = \Sigma ^ {N} _ {i=1} X _ iと表すことができます。そして、この確率変数Xポアソン二項分布 に従うとみなせます。 このポアソン二項分布の平均は\mu = \Sigma ^ {N} _ {i=1} p _ iです。これは(現時点での)参加人数の期待値とみなせます。上述の例で参加予定者の参加確率の和を計算していたのは、この部分に当たります。

あとは、Nをこの\muで引けば、キャンセル人数となります。
ちなみに何故、参加予定者数ではなくキャンセル人数としたのかですが、参加者の期待値としてしまうと、未来に登録する参加者も変数として考慮しなければならず、問題が複雑化するためです。そのため、キャンセル人数としました。

以上でアイディアの説明は終わりです。大量に仮定を置いていることがわかります。この仮定によって生じる見積もりの限界は後述します。

実装

上述のアイディアを実装しました。イベントのURLを入力すると、キャンセル人数を出力するpython scriptです。なお、動かすのにconnpassアカウントは不要です。コードは下記のリポジトリに配置しています。

github.com

各ユーザの参加確率を求めるにはイベントの参加予定者リストと、参加予定ユーザの過去のイベント参加履歴が必要です。connpassはWebAPIを提供していますが、ユーザに関して取得できるものはありませんでした。したがって、必要な2つの情報はhtmlページのスクレイピングで収集します。connpass.com利用規約の第7条【禁止事項】にスクレイピング行為は載っていないので、https://connpass.com/robots.txt を遵守する形で機械的に情報取得させていただきます。もちろん、マナーとして、リクエストを連続で送る場合には1秒のsleepを入れています。

参加予定ユーザ一覧の取得

conpassのイベントページのurlから参加者一覧を取り出してリスト化する関数は下記です。
イベントページのhtmlデータを取得後、applicant_areaクラス下のparticipation_table_areaクラスのついたテーブルを取得します。
ユーザ情報へのURL内にユーザidがあるので、それを抽出してリストに格納して返しています。

def get_participants_id_list(event_url: str) -> IdList:
    """イベント参加予定者のユーザidのリストを取得

    Args:
        event_url (str): イベントページのURL

    Returns:
        IdList: ユーザidのリスト
    """

    # 申込みをしたユーザのページを取得
    participants_url = f'{event_url}/participation'
    r = requests.get(participants_url)
    soup = BeautifulSoup(r.text, 'html.parser')

    # ユーザ一覧のテーブルからユーザidを取得
    participants_id_list = []
    for participation_table_list in soup.select('.applicant_area .participation_table_area'):
        for participating_user in participation_table_list.select('.user'):
            user_url = participating_user.select('.display_name a')[0]['href']
            m = re.match('https://connpass.com/user/(.*)/', user_url)
            participants_id_list.append(m.group(1))

    return participants_id_list

現行のコードには実装漏れがあります。参加申込数が100人を超えると、次のページが発行されますが、それの対応をしていません。したがって、参加申込数が100人を超えるイベントに対してはエラーを出して終了しています。

ユーザのイベント参加割合を求める

取得した全ユーザに対し、ユーザのページから過去のイベントの参加履歴を見て、「終了」ステータスのイベントを「参加」したか「キャンセル」したかの情報をすべて収集し、最後に割合として求めます。もしもイベントが補欠だった場合、参加の意思があったかわからないので、カウント対象から除外します。
新規ユーザは、参加するかわからないので開発者が指定できるようにします。デフォルトでは0.5とします。

def calc_participation_rate(user_id: str, new_user_participation_prob: float = 0.5) -> float:
    """イベント参加予定者の過去のイベントに実際に参加した割合を求め、参加確率とする

    Args:
        user_id (str): ユーザid
        new_user_participation_prob (float, optional): 新規ユーザの場合の確率. Defaults to 0.5.

    Returns:
        float: イベント参加確率
    """
    user_url = f'https://connpass.com/user/{user_id}/'
    r = requests.get(user_url)
    soup = BeautifulSoup(r.text, 'html.parser')

    # イベントのテーブル部分を取得
    participating_total_num = int(
        soup.select('.square_tab .num')[0].get_text())

    if participating_total_num == 0:
        return new_user_participation_prob

    # connpassはユーザページにはイベントを10件ずつしか表示しないので、ページ数を予め取得する必要がある
    page_total_num = math.ceil(participating_total_num / 10.)

    total_register_num = 0
    participate_num = 0

    for page_idx in range(1, page_total_num + 1):
        if page_idx > 1:
            # 2ページ目以降はリクエストを送る間隔を設ける
            time.sleep(1)
            user_url = f'https://connpass.com/user/{user_id}/?page={page_idx}'
            r = requests.get(user_url)
            soup = BeautifulSoup(r.text, 'html.parser')

        # ページごとのユーザのイベントの参加状況を収集する
        results = soup.select('div.event_area .event_list')
        for result in results:
            event_status = result.select(
                '.event_thumbnail .label_status_event')

            # イベントの開催がすでに終わっているもののみカウントする
            if event_status[0].get_text() == '終了':
                # ユーザの参加状況を取得
                participate_status = result.select(
                    '.label_status_tag')[0].get_text()

                # 補欠のまま終了したイベントは参加イベントとしてノーカウント
                if participate_status == '補欠':
                    continue
                total_register_num += 1

                if participate_status == 'キャンセル':
                    continue
                else:
                    participate_num += 1

    participation_rate = participate_num / total_register_num

    print(f"ユーザID: {user_id}, 参加確率: {participation_rate}, 参加回数: {participate_num}, イベント申し込み数: {total_register_num}")

    return participation_rate

スクリプトの実行例

以上の内容の関数をestimate_number_of_cancellation.pyモジュールに入れました。コマンドライン実行できるようにしています。したがって、実行方法は、

  • $ python estimate_number_of_cancellation.py <event_url>
    • event_url: connpassのイベントページのURL

実行結果は下記のような形で標準出力されます。

イベントに参加予定のユーザIDリスト
['user1', 'user2']

全2ユーザー分の参加確率を算出中...
ユーザID: user1, 参加確率: 1.0, 参加回数: 119, イベント申し込み数: 119
ユーザID: user2, 参加確率: 0.8080808080808081, 参加回数: 80, イベント申し込み数: 99

見積もったキャンセル人数: 0.19191919191919204, 現在の参加登録人数: 2, 現在の参加人数の期待値:1.808080808080808

注意点

  • [重要]処理結果が返ってくるまでにとても時間がかかります。
    • ユーザのイベント参加ページの取得時1秒のスリープを入れています。そして、1ページあたりのユーザのイベント参加情報は10つまでしか表示されません。したがって(ユーザ数 * ユーザの過去のイベント参加数)/10秒時間がかかります。
      • たとえば1人のユーザが200イベント過去に参加していた場合は収集に20秒かかります。それがユーザ分繰り返されるので、実行に10分以上かかることもあると思います。
  • 先述の通り、イベントの参加予定者の数が100人を超えている場合の実装ができていないので、100人以上の場合は処理を中断しています。申し訳ございません。

この手法の限界

  • ユーザのイベント参加回数が少ない場合、参加確率が統計的に信頼できない
    • 例えばイベント参加回数が1回の場合、確率が1.0or0.0のどちらかになり、全体の数値の足を引っ張る可能性があります。
  • 無断欠席者はわからない
    • コロナ禍真っ只中の現在は特にオンライン開催が多く、イベントに申し込んで実際には参加しなかった、という数が多くなっている可能性が高いです。なので、確率がやはり信頼できない可能性があります。
  • 確率変数間の独立を仮定
    • 「あの人が行くから私も行こう」という可能性を見逃しています。別の見方をすると、誰かがキャンセルしたことで別の人がキャンセルすることを考慮していません。

まとめ

connpassのイベントに対し、参加をキャンセルする人数を大雑把に見積もる方法を考え、pythonスクリプトを開発しました。
実装には、速度面、数値の信頼性の問題があります。あくまで、概算用という位置づけです。
イベント運営者の勘で人数当てをした時の結果と比較すると面白いかもしれません。

参考文献