「ロト6 当せん数字予測アプリ」

「ロト6 当せん数字予測アプリ」 (以下、本アプリ) は、ロト6 の当せん数字の抽せんに用いられる セット球 のデータをもとに、次回の当せん数字を予測するためのヒントを提供するアプリです。

...が、実用性はほとんどありません (笑)。

サイト主は、本アプリと同じ方法で、数年にわたりロト6 の当せん数字を予測していますが、これまでに 5 等(1000 円)を数回獲得したことがある程度です。

本アプリは、あくまでも Python で開発されたアプリのデモ・サンプル作品としてお試しください。

ちなみに本アプリでは、過去の ロト6 の当せん数字データを CSV ファイルに出力することもできます。

ロト6 当せん数字予測アプリ

アプリについて

本アプリは、プログラミング言語 Python で開発したデスクトップアプリで、 GUI に Kivy(キヴィ)ライブラリ、 データの管理・集計に Polars(ポーラース)ライブラリを利用しています。

サイト主は、これまで Python で開発したアプリのデモ作品として、ゲームや 3D 関連のアプリを公開してきましたが、 今回は最も得意とするデータ処理を行う実用アプリです。

今回の ロト6 の当せん数字予測は、Polars によるデータ集計と Kivy によるデータ表示のデモンストレーションとしてはちょうどよい題材でした。

冒頭で申し上げたとおりアプリとしては役に立たないので、その分今回は、大雑把ながらコードの解説を行っております。
コードの解説は こちら

本アプリには、Windows / macOS / Linux の各 OS 対応版があります。

いずれも、各 OS 上で単体で動作する形式(exeapp)にビルドされているので、Python の動作環境のインストールは不要です。

本アプリの macOS 版は Apple シリコン 搭載 Mac のみ対応しています。
Intel プロセッサ 搭載 Mac では動作しません。

確認方法は こちら

アプリのダウンロード

以下のリンクから、本アプリとその説明書をダウンロードできます。

Fedora 以外の Linux ディストリビューションをご利用の場合は、Linux (Debian) 版 をダウンロードしてください。

ソースコードのダウンロード

以下のリンクから、本アプリのソースコードをダウンロードできます。

Python のプロジェクト・パッケージ管理に uv をご利用の場合は、 "uv sync" コマンドだけで環境を構築できます。

ソースコードをご利用の際は、 ライセンス の項目をお読みください。

また、ソースコードは Python 3.12、および添付の requirements_*.txt に記載された環境以外での動作確認は行っておりません。

ご利用は自己責任でお願いいたします。

更新履歴

v1.0.1.0 [2025/06/26]
  • datagrid を独立したパッケージに変更した。
  • MIT ライセンス全文の参照先 URL を変更した。
v1.0.0.0 [2025/06/18]
初版リリース。

コードの解説

Polars(ポーラース)とは

本アプリで利用している Polars は、Rust(ラスト)というプログラミング言語で書かれたデータ分析ライブラリで、 非常に高速かつメモリ効率の良いデータ処理を実現しています。
Python と Rust で利用できます。

Python のデータ分析の分野では、Pandas(パンダス)というライブラリが圧倒的なシェアを誇っていますが、 処理速度やメモリ効率の面で Polars は Pandas よりも優れているとされます。

超高速…だけじゃない!Pandasに代えてPolarsを使いたい理由

pandasから移行する人向け polars使用ガイド

Polars は、Excel などと同じように がある二次元の表データを扱うことができます。
扱い方は リレーショナル・データベース に近いですが、データベースの機能は有していないので、 データベースシステムではありません。

Polars は、列指向(column-oriented)というデータ構造を採用しており、データはすべて列単位で管理されます。

そのため、列の追加・削除はできますが、行の追加・削除を直接行う手段はありません。

行単位のループ処理も遅いです(普通はしませんが…)。

一度格納されたデータはすべて固定され、特定の要素(Excel でいうセル)の値を後から書き換えることもできません。

「それじゃ何もできないじゃないか!」

と思われるかもしれませんが、データ処理に精通していれば何かしらの代替手段は思いつくので、特に困ることはありません。
このような制約があるからこそ、非常に高速な処理が実現可能なのです。

ただやはり、初心者の方には扱いにくいと思います。
少なくとも Excel の感覚で扱うことは困難でしょう。

DataFrame(データフレーム)について

Polars では、ひとつの DataFrame(データフレーム)というオブジェクトとして扱います。

DataFrame オブジェクトは Python の変数に代入できるので、その変数に対して、あらかじめ用意されているメソッドで操作を行います。

本アプリに収録されている当せん数字データの DataFrame の各列の名前と構成は以下のとおりです。

  • "times"(抽選回:整数)
  • "first_num"(第 1 数字:整数)
  • "second_num"(第 2 数字:整数)
  • "third_num"(第 3 数字:整数)
  • "fourth_num"(第 4 数字:整数)
  • "fifth_num"(第 5 数字:整数)
  • "sixth_num"(第 6 数字:整数)
  • "bonus_num"(ボーナス数字:整数)
  • "ball_set"(セット球:文字列)
  • "numbers"(第 1 〜 6 数字を列記したリスト)
  • "numbers_with_bonus"(第 1 〜 6 数字とボーナス数字を列記したリスト)

DataFrame に格納できるデータは、数値や文字列だけでなく、ひとつの要素(セル)に複数の値をまとめて格納できる リスト型 というものがあります。
上記の "numbers" 列や "numbers_with_bonus" 列に格納されているのが、そのリスト型のデータです。

例えば上記の DataFrame に対して、ひとつの当せん数字を検索するとき、第 1 〜 6 数字が別々の列に入ったままだと、それぞれの列に対して別々に検索を行う必要があります。
しかし、"numbers" 列や "numbers_with_bonus" 列に当せん数字がひとまとめになっていれば、この列に対してだけ検索を行えばよいわけです。

DataFrame のファイル出力・読み込み

DataFrame のデータは、そのままファイルに出力することができます。
例えば、DataFrame を CSV ファイルに出力する場合、Python では write_csv メソッドを使って、以下のように記述します。

df.write_csv("output.csv")

先頭の df は、出力する DataFrame が格納された変数です。
上記の場合、"output.csv" という名前でファイルに出力されます。

なお、リスト型の列は CSV ファイルには出力できないので、予め除外しておく必要があります。
本アプリのソースコードの main_screen.py モジュール(ファイル)の 546 行目 _write_csv メソッドを参考にしてください。

CSV ファイルを読み込む場合は、read_csv メソッドを使って、以下のように記述します。

import polars as pl
    df = pl.read_csv("output.csv")

変数 df には、"output.csv" ファイルから読み込まれたデータが DataFrame として代入されます。

ところで、Polars の DataFrame のひとつひとつの列には、決まったデータ型の値しか格納することができません。
例えば、整数 を格納すべき列には、文字列実数(小数を含む数値)はひとつたりとも格納することはできません。

これは、データベース等を扱ったことがあれば当然のことなのですが、 Excel とは大きく異なる点です。

上記のコードように、ファイル名だけを指定した read_csv メソッドで CSV ファイルを読み込むと、 DataFrame の各列のデータ型は自動的に決定されますが、それでは不都合が生じる場合があります。
詳しくは、DataFrame の行の追加・更新について をご覧ください。

例えば本アプリの当せん数字データを CSV ファイルから読み込む場合は、 厳密には以下のように、各列のデータ型を指定してやる必要があります。

import polars as pl

    schema_dict = {
        "times": pl.UInt32,
        "first_num": pl.UInt8,
        "second_num": pl.UInt8,
        "third_num": pl.UInt8,
        "fourth_num": pl.UInt8,
        "fifth_num": pl.UInt8,
        "sixth_num": pl.UInt8,
        "bonus_num": pl.UInt8,
        "ball_set": pl.Utf8,
    }

    df = pl.read_csv("loto6.csv", schema=schema_dict)

変数 schema_dict は、各列の名前とデータ型のペアを格納した辞書データです。
schema_dict のような列構成を示したデータを スキーマ情報 といいます。

pl.UInt32 は 0 〜 4,294,967,295 の範囲の整数、 pl.UInt8 は 0 〜 255 の範囲の整数、pl.Utf8 は 文字列 です。
このスキーマ情報を read_csv メソッドの引数 schema に渡してやります。

CSV ファイルにはスキーマ情報を保存できないので、CSV ファイルを読み込むときは、このような書き方をしなければなりません。

なので、本アプリの当せん数字データは、スキーマ情報を含め DataFrame のデータ構造をそのまま保存できる Parquet(パーケット)というファイル形式で保存しています。

Parquetファイルをざっくりと理解してみる

DataFrame を Parquet ファイルに出力する場合は、write_parquet メソッドを使って、以下のように記述します。

df.write_parquet("loto6.parquet", compression="zstd")

上記のように引数 compression を指定すれば、自動的にファイルを圧縮してくれるので、ファイルのサイズを小さくできます。

データ構造がそのまま保存されているので、Parquet ファイルを読み込む場合は、 read_parquet メソッドを使って、以下のように記述するだけです。

import polars as pl
    df = pl.read_parquet("loto6.parquet")

DataFrame のデータ構造をそのまま入出力できるので、Parquet ファイルの読み書きは高速です。

本アプリでは、当せん数字データの Parquet ファイル "loto6.parquet" を、アプリの起動時に自動的に読み込み、終了時に自動的に保存しています。
ソースコードの main_screen モジュールの 512 行目 _read_parquet メソッドと、535 行目 _write_parquet メソッドを参考にしてください。

「各セット球の選出回数」の集計処理

では、DataFrame の集計処理はどのように行うかを見ていきます。

セット球情報画面
セット球情報画面

本アプリの「セット球情報画面」の左の表「各セット球の選出回数」の集計は、 ball_sets_screen.py モジュールの 106 行目 _get_ball_set_count_data メソッドで行っています。

同メソッドでは、画面表示用のデータを作成して戻り値(答え)として返しているだけで、集計結果の表示は行っていません。
以下が、同メソッドのコードです(コメントは省いてあります)。

def _get_ball_set_count_data(self, df: pl.DataFrame) -> list[dict[str, int | str]]:

        return (
            df.group_by("ball_set")
            .agg(pl.count("ball_set").alias("count"))
            .select(pl.all().sort_by("ball_set"))
            .with_columns((pl.col("count") / pl.sum("count")).alias("rate"))
            .to_dicts()
        )

3 行目の return 以下のコードは、見やすさのために改行されているだけで、実際にはひと続きのコードです。
つまり、「各セット球の選出回数」の集計は、1 行のコードだけで行っていることになります。

詳しく見ていきましょう。
変数 df は、すべての当せん数字データが格納された DataFrame です。
これを group_by メソッドにより、"ball_set" 列のデータ(セット球)別にグループ化し、新しい DataFrame を作成します。

df.group_by("ball_set")

group_by メソッドの処理により作成された、セット球別にグループ化された DataFrame に対して、 agg メソッドでグループごとに処理を行います。

.agg(pl.count("ball_set").alias("count"))

先頭の "."(ドット)が、前の処理の続きであることをあらわしています。

agg メソッドの後の括弧内のコードを エクスプレッション(Expression) といいます。
エクスプレッションは処理の流れを記述しているだけで、実際の処理は agg メソッドで実行されます。

【Polars 入門】エクスプレッションを完全に理解する

ここでのエクスプレッションの内容(以下のコード)は、pl.count 関数でグループ内のデータ数(セット球の選出回数)をカウントし、 その選出回数を alias メソッドで新しく追加した "count" 列に出力するというものです。

pl.count("ball_set").alias("count")

次に、グループ別の "count" 列が追加された DataFrame を、sort_by メソッドで セット球の順番(A から J の順)に並べ替えます。
sort_by メソッドもエクスプレッションの一部なので、実際には select メソッドで処理が実行され、新しい DataFrame が作成されます。

.select(pl.all().sort_by("ball_set"))

次に、各セット球の選出回数 pl.col("count") を、すべてのセット球の選出回数の合計 pl.sum("count") で割って「選出率」を算出し、 alias メソッドで新しく追加した "rate" 列にそれぞれ出力します。

このエクスプレッションが with_columns メソッドで実行され、新しい DataFrame が作成されて、集計は完了です。

.with_columns((pl.col("count") / pl.sum("count")).alias("rate"))

最後に、to_dicts メソッドで、集計済みの DataFrame を画面表示用のデータに変換しています。

.to_dicts()

上記のコードように、複数のメソッドを "."(ドット)でつなげて連続的に記述する方法を メソッドチェーン といいます。

メソッドチェーンは、今どきの主要なプログラミング言語では、ごく普通の書き方です。
決して特別な書き方ではありません。

環境によっては、メソッドチェーンを多用しすぎると、かえって可読性や保守性が低下することもあります。
しかし Polars については、処理速度の向上など大きなメリットがあるため、メソッドチェーンによる記述が前提となっているようです。

【Polars 入門】エクスプレッションを完全に理解する
(メソッドチェーンについて)

「各セット球の選出回数」の表示処理

上記の _get_ball_set_count_data メソッドで作成された表示用のデータを実際に画面に表示する処理は、 ball_sets_screen.py モジュールの 96 行目で、以下のとおり行っています。

self._datagrid_ball_sets.data = self._get_ball_set_count_data(df)

変数 self._datagrid_ball_sets「各セット球の選出回数」の集計結果を表示するための DataGrid ウィジェット(表示用の部品)です。
詳しくは、 DataGrid ウィジェットについて をご覧ください。

集計結果の表示処理は、_get_ball_set_count_data メソッドで作成された表示用のデータを、 このウィジェットの data プロパティに代入する、この 1 行だけで行っています。
表のセルに、ひとつひとつループ処理で値を入れていく必要はありません。

このような集計と表示処理を、例えば Excel の VBA マクロ で書くとしたらどれほど大変なことになるかは、 容易に想像がつくと思います。

「各数字の出現状況」の集計処理

「セット球別集計画面」の左の表「各数字の出現状況」の集計処理についても見てみましょう。

セット球別集計画面
セット球別集計画面

この集計処理は、by_ball_set_screen.py モジュールの 274 行目 _get_frequency_count_dataframe メソッドで、 以下のとおり行っています(コメントは省いてあります)。

def _get_frequency_count_dataframe(self, df: pl.DataFrame, ball_set: str) -> pl.DataFrame:

        col_name: str = "numbers_with_bonus" if self._include_bonus else "numbers"

        df_new: pl.DataFrame = (
            df.select(["ball_set", col_name])
            .filter(pl.col("ball_set") == ball_set)
            .explode(col_name)
            .group_by(col_name)
            .agg(pl.len().alias("count"))
            .with_columns(
                (
                    pl.col("count") / df.filter(pl.col("ball_set") == ball_set).height
                ).alias("rate")
            )
            .select(pl.all().sort_by(["count", col_name], descending=True))
        )

        if self._include_bonus:
            df_new = df_new.with_columns(pl.col(col_name).alias("numbers"))

        return df_new

「セット球別集計画面」では、「集計にボーナス数字を含めない / 含める」ボタンの選択状態により集計結果が変わります。

同ボタンの状態は self._include_bonus 変数(bool 型)に代入されているので、 集計の際に当せん数字データの "numbers" 列(ボーナス数字を含まないリスト)を参照するのか、 "numbers_with_bonus" 列(ボーナス数字を含むリスト)を参照するのかを、以下のコードで判断しています。

col_name: str = "numbers_with_bonus" if self._include_bonus else "numbers"

ここでは、「集計にボーナス数字を含めない」方の
col_name = "numbers" として解説します。

次の、df_new: pl.DataFrame = の行は、一旦置いておきます。

次のコードの先頭の df は、当選番号データの DataFrame です。
ここから、select メソッドにより "ball_set"(セット球)列と "numbers" 列だけを取り出して、 新しい DataFrame を作成します。

df.select(["ball_set", col_name])

次のコードの変数 ball_set には、画面の「セット球:」ボタンで選択されたセット球の名前(A 〜 J)が代入されています。
filter メソッドで、DataFrame の "ball_set" 列の値が、 変数 ball_set と一致する行のデータだけを抽出して、新しい DataFrame を作成します。

.filter(pl.col("ball_set") == ball_set)

次の explode メソッドで、"numbers" 列に格納されている当せん数字のリストを 縦方向(行方向)に展開します。
これで "numbers" 列に、リストではなく、単独の当せん数字が格納された新しい DataFrame が作成されます。

なぜ explode メソッドによる処理が必要かといえば、当せん数字がリストに格納されたままだと、 当せん数字をグループ化したりカウントしたりすることができないからです。

.explode(col_name)

次の group_by メソッドにより、"numbers" 列の当せん数字別に DataFrame をグループ化します。

その次の agg メソッドで各グループのデータ数をカウントし、 その結果を出力した新しい count 列を追加して、新しい DataFrame を作成します。
この count 列の値が、各当せん数字の「出現回数」になります。

.group_by(col_name)
    .agg(pl.len().alias("count"))

次の with_columns メソッドは、一旦置いておきます。

その次の以下のコードで、DataFrame の "ball_set" 列の値が、変数 ball_set と一致する行だけを抽出し、 height プロパティでその行数を取得しています。
これが、画面の「セット球:」ボタンで選択されているセット球の「選出回数」になります。

df.filter(pl.col("ball_set") == ball_set).height

"count" 列の各当せん数字の出現回数を、上記のセット球の選出回数で割ることにより、 各当せん数字の「出現率」を算出できるので、これを alias メソッドで新しい "rate" 列に出力します。

そして、with_columns メソッドにより、実際に "rate" 列が追加された新しい DataFrame が作成されます。

.with_columns(
        (
            pl.col("count") / df.filter(pl.col("ball_set") == ball_set).height
        ).alias("rate")
    )

最後に sort_by メソッドで "count" 列の値(出現回数)が多い順に並べ替え、 select メソッドで並べ替えられた新しい DataFrame を作成します。

.select(pl.all().sort_by(["count", col_name], descending=True))

ここまで、メソッドチェーンにより事実上 1 行で処理を終えた DataFrame は、以下のコードにより、 変数 df_new に代入します。

このように、メソッドチェーンを使えば、元の DataFrame を変更することなく、処理済みの DataFrame を取得することができます。

df_new: pl.DataFrame = (
        # メソッドチェーンによる処理
    )

画面で「集計にボーナス数字を含める」が選択されていた場合、 変数 df_new の出現回数が格納された列の名前は "numbers_with_bonus" になっていますが、 表示用のデータは出現回数の列名が "numbers" である必要があります。

なので、「集計にボーナス数字を含める」が選択されていた場合は、 以下のコードにより新たに "numbers" 列を追加し、"numbers_with_bonus" 列のデータをそこに格納(コピー)しています。

if self._include_bonus:
        df_new = df_new.with_columns(pl.col(col_name).alias("numbers"))

ちなみに、この _get_frequency_count_dataframe メソッドにより作成された DataFrame には、 この後「累積分布関数の値」の列を追加しなければならないので、 このメソッド内では to_dicts メソッドによる表示用データへの変換は行っていません。

上記の集計結果に「累積分布関数の値」の列が追加された DataFrame が代入された変数 df_by_number は、 by_ball_set_screen.py モジュールの 245 行目で、to_dicts メソッドで表示用データに変換し、 表のウィジェット self._datagrid_frequency_countdata プロパティに代入しています。
これで、集計済みのデータが画面に表示されます。

self._datagrid_frequency_count.data = df_by_number.to_dicts()

「'◯' と同時に出現している数字」の集計処理

「セット球別集計画面」の右の表「'◯' と同時に出現している数字」の集計処理についても見てみましょう。

セット球別集計画面
セット球別集計画面

この集計処理は、by_ball_set_screen.py モジュールの 308 行目 _get_co_occurrence メソッドで、 以下のとおり行っています(コメントは省いてあります)。

def _get_co_occurrence(self, df: pl.DataFrame, df_by_number: pl.DataFrame, ball_set: str, number: int) -> pl.DataFrame:

        col_name: str = "numbers_with_bonus" if self._include_bonus else "numbers"

        df_new: pl.DataFrame = (
            (
                df.select(["ball_set", col_name])
                .filter(
                    (pl.col("ball_set") == ball_set)
                    & (pl.col(col_name).list.contains(number))
                )
                .explode(col_name)
                .group_by(col_name)
                .agg(pl.len().alias("co_occurrence"))
            )
            .join(df_by_number, on=col_name, how="inner")
            .select(pl.all().sort_by(["co_occurrence", "count"], descending=True))
        ).filter(pl.col(col_name) != number)

        if self._include_bonus:
            df_new = df_new.with_columns(pl.col(col_name).alias("numbers"))

        return df_new

3 行目の "col_name: str = … if …" は、前出の 「各数字の出現状況」の集計処理 と同じで、「集計にボーナス数字を含めない / 含める」ボタンの状態によって、 集計の際に当せん数字データの DataFrame の "numbers""numbers_with_bonus" のどちらの列のデータを参照するかを判断します。

ここでは、col_name = "numbers"(ボーナス数字を含めない)として解説します。

col_name: str = "numbers_with_bonus" if self._include_bonus else "numbers"

変数 df はすべての当せん数字データの DataFrame で、 ここから select メソッドにより "ball_set" 列(セット球)と "numbers" 列だけを取り出して、 新しい DataFrame を作成します。

df.select(["ball_set", col_name])

変数 ball_set には画面上で選択されているセット球、変数 number には「各数字の出現状況」 の表でユーザーが選択した数字が代入されています。
filter メソッドで、"ball_set" 列の値と変数 ball_set の値が 一致し、かつ、 "numbers" 列のリストに変数 number の値が含まれる行だけを抽出して、新しい DataFrame を作成します。

.filter(
        (pl.col("ball_set") == ball_set)
        & (pl.col(col_name).list.contains(number))
    )

次からの explodegroup_byagg メソッドの処理は、前出の 「各数字の出現状況」の集計処理 と同じです。

"numbers" 列に格納された当せん数字のリストを行方向に展開し、 当せん数字別にグループ化して、グループごとのデータ数をカウントした値(同時出現回数)を、 新しい "co_occurrence" 列に出力して、新たに DataFrame を作成します。

.explode(col_name)
    .group_by(col_name)
    .agg(pl.len().alias("co_occurrence"))

この DataFrame には、左の「各数字の出現状況」の表で集計した「出現回数」列を追加したいので、 次の join メソッドで、両方の表の DataFrame を 内部結合 させて、 新しい DataFrame を作成しています。

内部結合というのは、Excel でいうと、"numbers" 列の値(当せん数字)を手がかりに、 VLOOKUP 関数で「各数字の出現状況」の表の「出現回数」の値を引っぱってきて、 関数がエラーでない行だけを残すイメージです。

join メソッドの最初の引数に指定している変数 df_by_number が「各数字の出現状況」 の表の DataFrame です。
次の引数 on には、結合の手がかりになる列の名前(ここでは "numbers")を指定し、 内部結合の場合は、次の引数 how"inner" を指定します。

.join(df_by_number, on=col_name, how="inner")

これで、DataFrame に "count" 列(出現回数)が追加されたので、次の sort_by メソッドで "co_occurrence" 列の値(同時出現回数)と "count" 列の値を大きい順に並べ替え、 select メソッドで新しい DataFrame を作成します。

.select(pl.all().sort_by(["co_occurrence", "count"], descending=True))

「'◯' と同時に出現している数字」の集計なので、当せん数字が ◯ の行は集計結果から除外しなければなりません。

そこで、最後の filter メソッドで、当せん数字が ◯(変数 number)以外の行だけを抽出して、新しい DataFrame を作成しています。
これで、当せん数字が ◯ の行が削除されたことになります。

).filter(pl.col(col_name) != number)

「Polars には、行の削除を直接行う手段がない」と先述しましたが、この方法を使えば、行を削除するのと同じことができるので問題ないわけです。

DataFrame の行の追加・更新について

本アプリの「データ管理画面」の上部には、抽選回・第 1 〜 6 数字・ボーナス数字・セット球を入力するための入力ボックスがあります。

データ管理画面
データ管理画面

「Polars には、行の追加を直接行う手段がない」と先述しましたが、本アプリでは、 各入力ボックスに入力したデータを「追加・更新」ボタンで一覧表に追加することができます。

また、「Polars は、特定の要素の値を後から書き換えることができない」と先述しましたが、本アプリでは、 一覧表から特定の行を選択し、入力ボックスに表示されたデータを書き換えて「追加・更新」ボタンをクリックすれば、 一覧表のデータを上書きすることもできます。

これらの処理はどのように実現しているのか、詳しく見てみましょう。

今から説明する方法は、手作業で入力されたデータを既存の DataFrame に 1 回ずつ追加するといった 「処理速度が重要でないケース」に限られます。
既存の DataFrame に自動的に繰り返し新しい行を追加するような「処理速度が重要なケース」には向かないことを、 先に申し上げておきます。

データの追加・更新処理については、main_screen.py モジュールの 376 行目 on_modify_button_click メソッドで行っています。

def on_modify_button_click(self) -> None:
        """「追加・更新」ボタンがクリックされたとき。"""

        if not self._validate_values():
            return

        record: dict[str, int | str] = {
            "times": int(self._text_times.text),
            "first_num": int(self._text_1st_num.text),
            "second_num": int(self._text_2nd_num.text),
            "third_num": int(self._text_3rd_num.text),
            "fourth_num": int(self._text_4th_num.text),
            "fifth_num": int(self._text_5th_num.text),
            "sixth_num": int(self._text_6th_num.text),
            "bonus_num": int(self._text_bonus_num.text),
            "ball_set": self._text_ball_set.text.upper(),
        }

        df: pl.DataFrame = self._shared_vars.df
        if df.filter(pl.col("times") == record["times"]).is_empty():
            self._append_record(record)
            self._display_status(f"'第 {record['times']} 回' のデータを追加しました。")
        else:
            times: int = int(record["times"])
            self._shared_vars.df = self._shared_vars.df.filter(pl.col("times") != times)
            self._append_record(record)
            self._display_status(f"'第 {record['times']} 回' のデータを更新しました。")

        self._selected_times = -1
        self._clear_input_text()

まず、各入力ボックスに入力された値がすべて正しいか、以下のコードでチェックしています。
入力された値がひとつでも正しくなければ、このメソッドはここで終了します。

if not self._validate_values():
        return

次のコードでは、データ名(DataFrame の列名)と、 入力ボックスに入力されたデータのペアを格納した辞書データを作成して、変数 record に代入しています。

record: dict[str, int | str] = {
        "times": int(self._text_times.text),
        "first_num": int(self._text_1st_num.text),
        "second_num": int(self._text_2nd_num.text),
        "third_num": int(self._text_3rd_num.text),
        "fourth_num": int(self._text_4th_num.text),
        "fifth_num": int(self._text_5th_num.text),
        "sixth_num": int(self._text_6th_num.text),
        "bonus_num": int(self._text_bonus_num.text),
        "ball_set": self._text_ball_set.text.upper(),
    }

元となっている当せん数字のデータは、変数 self._shared_vars に格納されたオブジェクトの変数 df として、アプリ全体で共有しています。
ただ、そのまま扱うには名前が長すぎるので、以下のコードで、ローカル変数 df に代入しています。

df: pl.DataFrame = self._shared_vars.df

以下の if 文では、元となる当せん数字データの DataFrame の "times" 列(抽選回)に、 入力ボックスに入力された抽選回が存在するかを判断しています。

まず、filter メソッドで、当せん数字データから対象となる抽選回と一致する行だけを抽出した DataFrame を取得します。
この DataFrame が であれば「該当する抽選回は存在しない」ということで、 入力されたデータを追加する処理を行います。
is_empty メソッドは、DataFrame が空であれば True を返します。

「該当する抽選回がすでに存在する」場合は、入力されたデータを上書きする処理を行います。

if df.filter(pl.col("times") == record["times"]).is_empty():
        # データを追加する処理

    else:
        # データを上書きする処理

以下のコードは、データを追加する処理です。

入力データが代入された変数 record を、元となる当せん数字データに _append_record メソッドで追加しています。
_append_record メソッドについては後述します。

if df.filter(pl.col("times") == record["times"]).is_empty():
        self._append_record(record)
        self._display_status(f"'第 {record['times']} 回' のデータを追加しました。")

以下のコードは、データを上書きする処理です。

変数 times は、上書き予定のデータの抽選回です。
次の filter メソッドで、元となる当せん数字データ self._shared_vars.df から上書き予定の抽選回以外の行を抽出して、元となる当せん数字データに代入し直しています。
これで、上書き予定の抽選回のデータが、元の当せん数字データから削除されたことになります。

この当せん数字データに、新たに上書き予定のデータを追加すれば、結果的にデータを上書きしたことになります。

データを追加する処理と同じく、次の _append_record メソッドで、データを追加しています。

else:
        times: int = int(record["times"])
        self._shared_vars.df = self._shared_vars.df.filter(pl.col("times") != times)
        self._append_record(record)
        self._display_status(f"'第 {record['times']} 回' のデータを更新しました。")

上記のデータを追加する _append_record メソッドは、main_screen.py モジュールの 644 行目にあります。

def _append_record(self, record: dict[str, int | str]) -> None:

        schema: dict[str, pl.datatypes.DataType] = {
            "times": pl.UInt32,
            "first_num": pl.UInt8,
            "second_num": pl.UInt8,
            "third_num": pl.UInt8,
            "fourth_num": pl.UInt8,
            "fifth_num": pl.UInt8,
            "sixth_num": pl.UInt8,
            "bonus_num": pl.UInt8,
            "ball_set": pl.Utf8,
        }
        df_new: pl.DataFrame = pl.DataFrame(record, schema=schema)
        df_new = self._append_numbers_column(df_new)
        self._shared_vars.df = pl.concat([self._shared_vars.df, df_new]).select(
            pl.all().sort_by("times")
        )
        self._display_data()

このメソッドの引数 record は、当せん数字データの DataFrame の列名と、 入力ボックスに入力されたデータのペアの辞書データです。

次の変数 schema に代入されているのは、 DataFrame のファイル出力・読み込み で、CSV ファイルを読み込む際に必要と説明していた スキーマ情報 (当せん数字データの DataFrame の各列の名前とデータ型のペアの辞書データ)です。

schema: dict[str, pl.datatypes.DataType] = {
        "times": pl.UInt32,
        "first_num": pl.UInt8,
        "second_num": pl.UInt8,
        "third_num": pl.UInt8,
        "fourth_num": pl.UInt8,
        "fifth_num": pl.UInt8,
        "sixth_num": pl.UInt8,
        "bonus_num": pl.UInt8,
        "ball_set": pl.Utf8,
    }

pl.DataFrame コンストラクタの引数に、上記の recordschema を渡してやることで、 入力ボックスに入力されたデータが格納された新しい DataFrame を作成することができます。

df_new: pl.DataFrame = pl.DataFrame(record, schema=schema)

次の _append_numbers_column メソッドで、上記の新しい DataFrame に、 第 1 〜 6 数字をまとめたリスト型の "numbers" 列と、 それにボーナス数字を加えたリスト型の "numbers_with_bonus" 列を追加しています。
これで、新しい DataFrame と、元の当せん数字データの列構成が同じになります。

df_new = self._append_numbers_column(df_new)

「Polars には、行の追加を直接行う手段はない」と先述しましたが、pl.concat 関数により 「複数の DataFrame を縦方向に結合する」ことはできます。

以下のコードのとおり、pl.concat 関数の引数に、結合したい DataFrame のリストを渡してやることで、 元の当せん数字データの DataFrame に新しい DataFrame を結合して、新しい DataFrame を作成することができます。

その DataFrame を、次の sort_by メソッドで、抽選回の順番に並べ替えています。

self._shared_vars.df = pl.concat([self._shared_vars.df, df_new]).select(
        pl.all().sort_by("times")
    )

上記の方法により、DataFrame に新しい行を追加するのと同じことはできるのですが、 この「DataFrame の縦結合」には注意が必要です。

まず、結合するすべての DataFrame の 列の名前データ型 が、完全に一致している必要があります。
DataFrame のファイル出力・読み込み で、「CSV ファイルを読み込む read_csv メソッドの引数にスキーマ情報を指定しないと、DataFrame の各列のデータ型は自動的に決定される」 と説明しましたが、DataFrame の列のデータ型が自動的に決定されると、 「結合する DataFrame 同士の列のデータ型が一致しない」という不具合が発生する場合があります。

これについては、cast メソッドによって列のデータ型を後から変更することもできるので、 必ずしも CSV ファイルを読み込む際にスキーマ情報を指定する必要はありませんが、 列のデータ型には注意しておく必要があります。

また、列指向の Polars にとって、DataFrame の行数を増やすという処理は非常に負担が大きく、処理に時間がかかります。
DataFrame の縦結合を頻繁に繰り返すようなコードは、書くべきではありません。

DataGrid ウィジェットについて

「各セット球の選出回数」の表示処理 で説明したとおり、本アプリでは、集計結果の DataFrame を to_dicts メソッドで表示用のデータに変換して、 それを表示用のウィジェットの data プロパティに代入するだけで、集計結果を表示することができます。

self._datagrid_ball_sets.data = self._get_ball_set_count_data(df)

本アプリで利用している、その表示用のウィジェットが DataGrid(データグリッド)です。
DataGrid は、サイト主が自作した "datagrid" パッケージに含まれており、"datagrid" フォルダに収録しています。

DataGrid の正体は、Kivy の RecycleView ウィジェットを配置した BoxLayout です。
RecycleView は、Kivy で「スクロール可能な二次元形式の表」を表示するウィジェットですが、 デフォルトでは ヘッダー(列見出し)を表示する機能がありません。
RecycleView にヘッダーを表示する方法はいくつかありますが、サイト主は、BoxLayout を使って RecycleView とヘッダーを別々に配置する方法を採っていました。

ただ、毎回そのコードを書くのが面倒だったので、今回パッケージ化した次第です。
パッケージ化するついでに、表に表示されたデータをクリックすると、その行を選択状態にして、 その行のデータを取得できる機能を実装しています。

DataGrid は、BoxLayout を継承したクラスなので、main_screen.py モジュールの 217 行目のように、 KV コード により単独で配置できます。

DataGrid:
        id: datagrid
        viewclass: 'DataGridMainRow'
        size_hint: 1.0, 0.9
        pos_hint: {'center_x': 0.5, 'center_y': 0.5}

上記の viewclass プロパティで指定している "DataGridMainRow" というのは、 datagrid_main_row.py モジュールで定義しているクラスの名前です。
この DataGridMainRow クラスは、datagrid パッケージに含まれる RowItem クラスを継承しています。
RowItem を継承したクラスでは、表のレコード行 1 行分の列のレイアウトを定義します。

例えば、DataGridMainRow の場合は、datagrid_main_row.py モジュールの 19 行目以下のように、 datagrid パッケージに含まれる DataLabel ウィジェットを、列の数だけ並べて定義しています。

<DataGridMainRow>:
        selectable: True

        DataLabel:
            text: str(root.times)
            size_hint_x: 0.12
            text_size: self.size
            halign: 'right'
        DataLabel:
            text: f"{root.first_num :0>2}"
            size_hint_x: 0.11
            text_size: self.size
            halign: 'center'
    ...

そして、datagrid_main_row.py モジュールの 117 行目以下のように、クラスの定義で、 当せん数字データの DataFrame の列名と同じ名前のプロパティを記述しておけば、to_dicts メソッドで変換した DataFrame のデータを data プロパティに代入するだけでデータを表示することができます。

class DataGridMainRow(RowItem):
        """メイン画面のデータグリッドのレコード行を定義したクラス"""

        times: int = NumericProperty(0)
        """抽選回"""

        first_num: int = NumericProperty(0)
        """第 1 数字"""

        second_num: int = NumericProperty(0)
        """第 2 数字"""

        ...

ちなみに、ヘッダー行のレイアウトは、datagrid_main_row.py モジュールの 68 行目 "<DataGridMainHeader>" 以下で定義しています。
ここでは、datagrid パッケージに含まれる HeaderLabel ウィジェットを、列の数だけ配置しています。

<DataGridMainHeader>:
        HeaderLabel:
            text: "抽選回"
            size_hint_x: 0.12
            text_size: self.size
            halign: 'center'
        HeaderLabel:
            text: "第 1"
            size_hint_x: 0.11
            text_size: self.size
            halign: 'center'
        ...

例えば、この HeaderLabel を ButtonSpinner ウィジェットに置き換えれば、 「表の列見出しをクリックしてデータの並べ替えやフィルタリングを行う機能」を、 datagrid パッケージに手を加えることなく実装できます。

なお、上記で定義された DataGridMainHeader ウィジェットは、main_screen.py モジュールの 254 行目で、 Python コードにより DataGrid ウィジェットの header_item プロパティに代入しています。

self._datagrid.header_item = DataGridMainHeader()

この header_item プロパティへの DataGridMainHeader ウィジェットの代入を KV コード で行う場合は、DataGridMainHeader クラスの import 文も KV コードで記述する必要があるので、ご注意ください。

ライセンス

「ロト6 当せん数字予測アプリ」(以下 本ソフトウェア)の著作権は、 開発者である 筒井敏文 が保有します。

本ソフトウェアのバイナリファイル、およびソースコードは MIT ライセンス の下で配布します。
本ソフトウェアのバイナリファイル、およびソースコードの改変や再配布は自由に行うことができます。
ただし再配布の際には必ず、添付の "LICENSE.TXT" ファイルを配布物にも添付するか、 または配布物のわかりやすい場所に以下の 3 行を記載してください。

Copyright (c) 2025 toshifumi tsutsui
Released under the MIT license
https://wpandora8.net/the_mit_license.html

なお、著作権者は、本ソフトウェアのバイナリファイル、およびソースコードに起因または関連し、 あるいはバイナリファイルおよびソースコードの使用またはその他の扱いによって生じる一切の請求、 損害、その他の義務について何らの責任も負わないものとします。