Open5

Kaggleの自動化コード、集積地 (自由に改良して、このスクラップに書き込んだり、ブログ等で公開してほしいです)

currypurincurrypurin

コンペの終了日までの日数を可視化するコード

  • 毎日投稿しているbot(@DataCompeAlerts)はこちら
  • ビジュアライズするKaggleのノートブックはこちら

このコードをCloudFunctionsで毎日動かしています。

import io
import matplotlib.pyplot as plt
import seaborn as sns
import logging
from flask import Response, Request
from dotenv import load_dotenv
from datetime import datetime as dt
from pytz import timezone
import os
import base64
import requests
import json

load_dotenv()

is_debug_mode = os.getenv("DEBUG", "True") == "True"
if is_debug_mode:
    logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(levelname)s - %(message)s')
else:
    logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')


def plot_competition_fig():
    os.environ['KAGGLE_USERNAME'] = os.getenv('KAGGLE_USER_NAME')
    os.environ['KAGGLE_KEY'] = os.getenv('KAGGLE_KEY')
    from kaggle.api.kaggle_api_extended import KaggleApi
    kaggle_api = KaggleApi()
    kaggle_api.authenticate()
    now = dt.now(timezone('UTC'))

    competitions = []
    competitions_list = kaggle_api.competitions_list()
    for competition in competitions_list:
        if getattr(competition, 'awardsPoints') and not getattr(competition, 'submissionsDisabled'):
            deadline = getattr(competition, 'deadline').astimezone(timezone('UTC'))
            enabled_date = getattr(competition, 'enabledDate').astimezone(timezone('UTC'))
            diff = deadline - now
            if diff.days >= 0:
                competitions.append({
                    'title': getattr(competition, 'title'),
                    'enabledDate': enabled_date,
                    'deadline': deadline,
                    'days_to_end': diff.days
                })

    # 残り日数が長い順にソート
    competitions.sort(key=lambda x: x['days_to_end'], reverse=True)

    plt.figure(figsize=(12, 8))  # 縦幅を調整

    sns.set(style='white')
    plt.rcParams["font.size"] = 18

    shift_amount = 0.1  # 左にシフトする量
    vertical_offset = -0.1  # 全体を少し下にシフトする

    for i, competition in enumerate(competitions):
        start_date = competition['enabledDate']
        days_to_end = competition['days_to_end']
        days_from_start = (now - start_date).days

        start_pos = 0.5 - (min(days_from_start, 90) / 180)
        end_pos = 0.5 + (min(days_to_end, 100) / 180)

        start_pos -= shift_amount
        end_pos -= shift_amount

        plt.plot((start_pos, 0.5 - shift_amount), (i + vertical_offset, i + vertical_offset), linewidth=6.0, c='blue')
        plt.plot((0.5 - shift_amount, end_pos), (i + vertical_offset, i + vertical_offset), linewidth=6.0, c='orange')
        if days_to_end > 100:
            plt.text(end_pos - 0.005, i + vertical_offset + 0.02, "~", fontsize=20, rotation=90, ha='center', va='center')
            plt.text(end_pos - 0.008, i + vertical_offset + 0.02, "~", fontsize=20, rotation=90, ha='center', va='center')
        plt.text(1.03, i + vertical_offset, f'{days_to_end}', va='center', fontsize=20, ha='right', c='orange')
        plt.text(1.08, i + vertical_offset, 'days', va='center', fontsize=8, ha='right')
        plt.text(-0.06, i + 0.1 + vertical_offset, competition['title'], ha='left', va='bottom', fontsize=20)

    plt.yticks([])
    plt.xticks([])
    plt.title('Kaggle Competitions: Countdown to Deadline', fontsize=22)
    plt.text(0.4, -0.01, dt.strftime(now, '%Y-%m-%d'), fontsize='14', ha='center', va='top', transform=plt.gca().transAxes)
    plt.plot([0.5 - shift_amount, 0.5 - shift_amount], [-0.5, -0.4], color='black', linestyle='--', linewidth=1.5)
    plt.ylim(-0.5, len(competitions) - 0.5)
    plt.xlim(-0.1, 1.1)
    plt.subplots_adjust(left=0.03, right=0.97, bottom=0.08, top=0.92, hspace=0.3)

    # 画像をメモリに保存し、base64エンコード
    buf = io.BytesIO()
    plt.savefig(buf, format='png')
    buf.seek(0)
    image_base64 = base64.b64encode(buf.read()).decode('utf-8')
    buf.close()
    return image_base64


def post_kaggle_deadlines_info(request: Request):
    try:
        image_base64 = plot_competition_fig()

        twitter_auth_keys = {
            "consumer_key": os.getenv("CONSUMER_KEY"),
            "consumer_secret": os.getenv("CONSUMER_SECRET"),
            "access_token": os.getenv("ACCESS_TOKEN"),
            "access_token_secret": os.getenv("ACCESS_TOKEN_SECRET"),
            "bearer_token": os.getenv("BEARER_TOKEN")
        }

        request_data = {
            "platform": ["twitter"],
            "message": "Deadlines for Ongoing Kaggle Competitions! 🕒",
            "media": image_base64,
            "mdeia": None,
            "twitter_auth_keys": twitter_auth_keys,
            "is_debug": False
        }

        url = os.getenv("POST_URL")

        # POSTリクエストを送信
        response = requests.post(url, json=request_data)
        response.raise_for_status()
        response_body = json.dumps({"message": response.text})
        return Response(response_body, content_type="application/json"), 200

    except Exception as e:
        logging.error(f"Error occurred: {e}")
        response_body = json.dumps({"error": str(e)})
        return Response(response_body, content_type="application/json"), 500


if __name__ == "__main__":
    post_kaggle_deadlines_info(None)

currypurincurrypurin

Notebookが公開されていたら、Discordに通知する。

コードはGistで公開しています。

(注)3月13日のLTではクラウドで動かしていると話しましたが、常時稼働のMac miniで動かしていました。

currypurincurrypurin

DiscussionにTopicが作られていたら、Discordに通知する

1. Discussionの投稿が自分の登録メールに届くようにする!

コンペのDiscussionで、Follow forumを選択しておくことで、トピックが作成されるたびにメールが届きます。Discordに通知したいコンペで、Follow forum選択します。
※ Follow forum and commentsを選択すると、コメントが投稿されてもメールが届く
※ GASを利用するため、メールはgmailにする必要があります。Kaggleの登録メールがgmailではない場合はgmailへ自動転送などすれば、同じ感じで使えると思います。

2. メールにラベルを自動でつけるようにする。

トピックを作成したときのメールの件名には、「[コンペ名] Topic: 」が先頭に含まれます。この件名の場合には、自動でラベルが付くように設定します。

gmailで検索ワードを入れて、検索オプションをクリックします。

フィルタを作成をクリックします。

「ラベルをつけるを」選択し、「ラベル名を入力」。
「一致するスレッドにもフィルタを適用する」を選択し、フィルタを作成をクリックします。

これでこれまで届いたメールと、これから届くメールに指定のラベルがつくようになりました。

3. GASの設定

https://script.google.com/home/ にアクセスし、新しいプロジェクトを作成します。

エディタが表示されるので、次のコードを貼り付けます。

function discord(postMsg) {
  const webhooks = '';  // discordのwebhookURLを入力
  const payload = {
    'content': postMsg,
    'username': 'Kerneler',
    'parse': 'full'
  };
  const params = {
    'method': 'post',
    'payload': payload,
    'muteHttpExceptions': true
  };
  response = UrlFetchApp.fetch(webhooks, params);
}

function mails() {
  const searchQuery = "label:llmprompt";  // gmailのlabel名を入力。「llmprompt」というラベルになっているので、自分のラベルに修正してください。
  const checkSpanMinute = 60;
  const dt = new Date();
  dt.setMinutes(dt.getMinutes() - checkSpanMinute);
  const threads = GmailApp.search(searchQuery);
  const msgs = GmailApp.getMessagesForThreads(threads);
  for (const thread of threads) {
    const lastMsgDt = thread.getLastMessageDate();
    if (lastMsgDt.getTime() < dt.getTime()) {
      break;
    }
    const messages = thread.getMessages();
    for (const message of messages) {

      const msgPlainText = message.getPlainBody();

      // 改行を適切に整形する
      const formattedText = msgPlainText
        .replace(/(\r\n|\r|\n){3,}/g, '\n\n') // 3回以上の連続した改行を2回の改行に置換
        .replace(/\\\*{2,}/g, '**')
        .replace(/\_{2,}/g, '__')
        .replace(/\s*(\r\n|\r|\n)\s*/g, '\n') // 改行の前後の空白を削除し、単一の改行に置換
        .replace(/\s{2,}/g, ' ') // 2つ以上の連続した空白を1つの空白に置換
        .trim(); // 前後の空白を削除

      // 2つ目のNewTopic以降に絞る
      function extractTextAfterSecondNewTopic(formattedText) {
        const regex = /^.*?New Topic.*?New Topic(.*)/s;
        const match = formattedText.match(regex);
        
        if (match && match[1]) {
          return match[1].trim();
        } else {
          return "";
        }
      }
      const extractedText = extractTextAfterSecondNewTopic(formattedText);

      // 末尾の "Reply on Kaggle" 以降のテキストを削除
      const cleanedText = extractedText.replace(/Reply on Kaggle.*$/s, '').trim();

      // 1行目をH1形式にして "タイトル:" をつける
      const lines = cleanedText.split("\\n");
      let title = lines[0].trim();

      title = title.replace(/^in \*\*LLM Prompt Recovery\*\*: /, '').replace(/\*\*/g, '');
      const titleWithPrefix = `# New Discussion Topic: ${title}`;
      lines[0] = titleWithPrefix;
      const formattText = lines.join("\\n");

      // 投稿用のテキストを作成
      const postMsg = `${formattText}`;

      discord(postMsg);
    }
  }
}

左にある「トリガー」、「トリガーを追加」と進む。

実行する関数を選択に「mails」、イベントのソースを選択に「時間主導型」、時間の感覚を選択に「1時間」を選択し保存を選択し、設定完了です。

参考: GASでGmailをDiscordに転送できるようにした

currypurincurrypurin

サブミット時間を計測するコード

2022年7月version

https://zenn.dev/currypurin/scraps/47d5f84a0ca89d

tqdmによる表示に対応したversion

スコアリング後の経過時間だけではなく、スコアリング中に、何分経ったか表示したい場合にはこちらのコードになります。
次の1の設定を行い、2のコードを実行します。

  1. アプリケーションを設定する

tqdmのプログレスバーの通知のDiscordの箇所にアプリケーションの設定方法があるので、これを行います。
ここで取得したものが、次のコードの「DISCORD_BOT_TOKEN 」と「DISCORD_CHANNEL_ID」になります。

import sys
from kaggle.api.kaggle_api_extended import KaggleApi
import datetime
import time
import requests
import os
from dotenv import load_dotenv
from tqdm.contrib.discord import tqdm


load_dotenv()

WEBHOOK_URL = os.getenv('WEBHOOK_URL')
if not WEBHOOK_URL:
    raise ValueError("WEBHOOK_URLが設定されていません。 .envファイルを確認してください。")
DISCORD_BOT_TOKEN = os.getenv('DISCORD_BOT_TOKEN')
if not DISCORD_BOT_TOKEN:
    raise ValueError("DISCORD_BOT_TOKENが設定されていません。 .envファイルを確認してください。")
DISCORD_CHANNEL_ID = os.getenv('DISCORD_CHANNEL_ID')
if not DISCORD_CHANNEL_ID:
    raise ValueError("DISCORD_CHANNEL_IDが設定されていません。 .envファイルを確認してください。")


def send_discord_notification(message):
    webhook_url = WEBHOOK_URL
    payload = {'content': message}
    headers = {'Content-Type': 'application/json'}
    response = requests.post(webhook_url, json=payload, headers=headers)
    print(message)
    return response


def main():
    send_discord_notification('submit check start')

    api = KaggleApi()
    api.authenticate()

    # コマンドライン引数がある場合はその番号、なければ 0 番目の submission を対象とする
    submission_index = int(sys.argv[1]) if len(sys.argv) > 1 else 0
    submissions = api.competition_submissions(COMPETITION)

    # submissionが存在するかチェック
    if not submissions:
        print("No submissions found.")
        sys.exit()

    if submission_index >= len(submissions):
        print("Invalid submission index.")
        sys.exit()

    latest_submission = submissions[submission_index]
    latest_ref = str(latest_submission.ref)
    submit_time = latest_submission.date

    if latest_submission.status != 'pending':
        print('サブミットされているnotebookはなし')
        sys.exit()

    pbar = tqdm(total=540, token=DISCORD_BOT_TOKEN, channel_id=DISCORD_CHANNEL_ID)
    while True:
        # 最新の submission 一覧を取得して、対象の submission を探す
        submissions = api.competition_submissions(COMPETITION)
        current_submission = next((sub for sub in submissions if str(sub.ref) == latest_ref), None)
        if current_submission is None:
            print("Submission not found.")
            pbar.close()
            sys.exit(1)

        status = current_submission.status
        now = datetime.datetime.utcnow()  # submit_timeがUTCであることを前提
        elapsed_time = int((now - submit_time).total_seconds() / 60) + 1

        if status == 'complete':
            message = (
                f'run-time: {elapsed_time} min, LB: {current_submission.publicScore}, '
                f'file_name: {current_submission.fileName}, description: {current_submission.description}\n'
                f'{current_submission.url}'
            )
            print('\r' + message)
            send_discord_notification(message)
            sys.exit(0)
        else:
            print('\r' + f'elapsed time: {elapsed_time} min, file_name: {current_submission.fileName}', end='')
            pbar.update(1)  # 1分ごとに更新
            time.sleep(60)


if __name__ == '__main__':
    COMPETITION = 'titanic'  # コンペ名を設定
    main()