Mortal

GitHub Workflow Status GitHub Workflow Status dependency status GitHub top language Lines of code GitHub code size in bytes license

Donate

Mortal (凡夫) is a free and open source AI for Japanese mahjong, powered by deep reinforcement learning.

The development of Mortal is hosted on GitHub at https://github.com/Equim-chan/Mortal.

Features

  • A strong mahjong AI that is compatible with Tenhou's standard ranked rule for four-player mahjong.
  • A blazingly fast mahjong emulator written in Rust with a Python interface. Up to 23K hanchans per hour1 can be achieved using the Rust emulator combined with Python neural network inference.
  • An easy-to-use mjai interface.
  • Free and open source.

WIP features

  • Serve as a backend for mjai-reviewer (formerly known as akochan-reviewer).
  • Limited reasoning support.

About this doc

This doc is work in progress, so most pages are empty right now.

Okay cool now give me the weights!

Read this post for details regarding this topic.

As planned, the trained model will be released after I add Mortal support to akochan-reviewer, which will then be renamed to mjai-reviewer.

License

Code

AGPL-3.0-or-later

Copyright (C) 2021-2022 Equim

This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.

This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.

You should have received a copy of the GNU Affero General Public License along with this program. If not, see https://www.gnu.org/licenses/.

Logo and Other Assets

CC BY-SA 4.0

1

Measured on NVIDIA® GeForce® RTX 2060 SUPER™ with AMD Ryzen™ 5 3600, game batch size 2000.

Docker Quick Start

A handy Dockerfile has been added to the project for an easy and quick start.

Warning

This Docker image is only designed for inference with mjai interface and is not targeted for training.

Build

$ git clone https://github.com/Equim-chan/Mortal.git
$ cd Mortal
$ sudo env DOCKER_BUILDKIT=1 docker build -t mortal:latest .

Prepare a trained model

The Docker image does not contain any model file of Model, therefore it must be prepared separately under a directory, which will be demostrated as /path/to/model/dir below. In this example, snapshot 22040703 is used.

Example

We are going to use Mortal to evaluate the next move for the scene shown in Figure 1.

Figure 1

First things first, we need to identify the POV's player ID. A player ID is an immutable number that identifies a specific player throughout one game. The rule is simple, the player sitting at the East at E1 is 0, and his shimocha (right) will be 1, toimen (across) will be 2, kamicha (left) will be 3. This works exactly the same as the tw parameter in Tenhou's log URL.

In this case, the POV's player ID is 2, because his seat is West at E1.

Mortal speaks mjai , a simple and easy-to-read stream format for mahjong records. From the perspective of player 2, the equivalant masked mjai events he has perceived so far are:

{"type":"start_game"}
{"type":"start_kyoku","bakaze":"E","dora_marker":"3s","kyoku":3,"honba":0,"kyotaku":0,"oya":2,"scores":[22000,23700,26000,28300],"tehais":[["?","?","?","?","?","?","?","?","?","?","?","?","?"],["?","?","?","?","?","?","?","?","?","?","?","?","?"],["1m","1m","4m","5m","1p","5p","8p","1s","4s","4s","6s","8s","N"],["?","?","?","?","?","?","?","?","?","?","?","?","?"]]}
{"type":"tsumo","actor":2,"pai":"6p"}
{"type":"dahai","actor":2,"pai":"1s","tsumogiri":false}
{"type":"tsumo","actor":3,"pai":"?"}
{"type":"dahai","actor":3,"pai":"1s","tsumogiri":false}
{"type":"tsumo","actor":0,"pai":"?"}
{"type":"dahai","actor":0,"pai":"9s","tsumogiri":false}
{"type":"tsumo","actor":1,"pai":"?"}
{"type":"dahai","actor":1,"pai":"9p","tsumogiri":false}
{"type":"tsumo","actor":2,"pai":"3s"}
{"type":"dahai","actor":2,"pai":"N","tsumogiri":false}
{"type":"tsumo","actor":3,"pai":"?"}
{"type":"dahai","actor":3,"pai":"9m","tsumogiri":false}
{"type":"tsumo","actor":0,"pai":"?"}
{"type":"dahai","actor":0,"pai":"1m","tsumogiri":false}
{"type":"tsumo","actor":1,"pai":"?"}
{"type":"dahai","actor":1,"pai":"1s","tsumogiri":false}
{"type":"tsumo","actor":2,"pai":"7s"}
{"type":"dahai","actor":2,"pai":"1p","tsumogiri":false}
{"type":"tsumo","actor":3,"pai":"?"}
{"type":"dahai","actor":3,"pai":"W","tsumogiri":false}
{"type":"tsumo","actor":0,"pai":"?"}
{"type":"dahai","actor":0,"pai":"1p","tsumogiri":false}
{"type":"tsumo","actor":1,"pai":"?"}
{"type":"dahai","actor":1,"pai":"W","tsumogiri":false}
{"type":"pon","actor":0,"target":1,"pai":"W","consumed":["W","W"]}
{"type":"dahai","actor":0,"pai":"2p","tsumogiri":false}
{"type":"tsumo","actor":1,"pai":"?"}
{"type":"dahai","actor":1,"pai":"9s","tsumogiri":false}
{"type":"tsumo","actor":2,"pai":"P"}
{"type":"dahai","actor":2,"pai":"8p","tsumogiri":false}
{"type":"tsumo","actor":3,"pai":"?"}
{"type":"dahai","actor":3,"pai":"C","tsumogiri":false}
{"type":"tsumo","actor":0,"pai":"?"}
{"type":"dahai","actor":0,"pai":"9p","tsumogiri":false}
{"type":"tsumo","actor":1,"pai":"?"}
{"type":"dahai","actor":1,"pai":"1s","tsumogiri":true}
{"type":"tsumo","actor":2,"pai":"7m"}
{"type":"dahai","actor":2,"pai":"7m","tsumogiri":true}
{"type":"tsumo","actor":3,"pai":"?"}
{"type":"dahai","actor":3,"pai":"7p","tsumogiri":false}
{"type":"tsumo","actor":0,"pai":"?"}
{"type":"dahai","actor":0,"pai":"C","tsumogiri":false}
{"type":"tsumo","actor":1,"pai":"?"}
{"type":"dahai","actor":1,"pai":"2s","tsumogiri":true}
{"type":"tsumo","actor":2,"pai":"6s"}
{"type":"dahai","actor":2,"pai":"6s","tsumogiri":true}
{"type":"tsumo","actor":3,"pai":"?"}
{"type":"dahai","actor":3,"pai":"E","tsumogiri":true}
{"type":"pon","actor":1,"target":3,"pai":"E","consumed":["E","E"]}
{"type":"dahai","actor":1,"pai":"2m","tsumogiri":false}
{"type":"tsumo","actor":2,"pai":"7p"}
{"type":"dahai","actor":2,"pai":"P","tsumogiri":false}
{"type":"tsumo","actor":3,"pai":"?"}
{"type":"dahai","actor":3,"pai":"8s","tsumogiri":false}
{"type":"tsumo","actor":0,"pai":"?"}
{"type":"dahai","actor":0,"pai":"E","tsumogiri":false}
{"type":"tsumo","actor":1,"pai":"?"}
{"type":"dahai","actor":1,"pai":"4p","tsumogiri":false}
{"type":"tsumo","actor":2,"pai":"8p"}
{"type":"dahai","actor":2,"pai":"8p","tsumogiri":true}
{"type":"tsumo","actor":3,"pai":"?"}
{"type":"dahai","actor":3,"pai":"9s","tsumogiri":false}
{"type":"tsumo","actor":0,"pai":"?"}
{"type":"dahai","actor":0,"pai":"S","tsumogiri":true}
{"type":"tsumo","actor":1,"pai":"?"}
{"type":"dahai","actor":1,"pai":"6s","tsumogiri":false}
{"type":"tsumo","actor":2,"pai":"9m"}
{"type":"dahai","actor":2,"pai":"9m","tsumogiri":true}
{"type":"tsumo","actor":3,"pai":"?"}
{"type":"dahai","actor":3,"pai":"2p","tsumogiri":false}
{"type":"tsumo","actor":0,"pai":"?"}
{"type":"dahai","actor":0,"pai":"2s","tsumogiri":true}
{"type":"tsumo","actor":1,"pai":"?"}
{"type":"dahai","actor":1,"pai":"8p","tsumogiri":true}
{"type":"tsumo","actor":2,"pai":"F"}
{"type":"dahai","actor":2,"pai":"F","tsumogiri":true}
{"type":"tsumo","actor":3,"pai":"?"}
{"type":"dahai","actor":3,"pai":"4p","tsumogiri":true}
{"type":"tsumo","actor":0,"pai":"?"}
{"type":"dahai","actor":0,"pai":"4m","tsumogiri":true}
{"type":"tsumo","actor":1,"pai":"?"}
{"type":"dahai","actor":1,"pai":"S","tsumogiri":true}
{"type":"tsumo","actor":2,"pai":"5mr"}
{"type":"dahai","actor":2,"pai":"5m","tsumogiri":false}
{"type":"tsumo","actor":3,"pai":"?"}
{"type":"reach","actor":3}
{"type":"dahai","actor":3,"pai":"N","tsumogiri":false}
{"type":"reach_accepted","actor":3}
{"type":"tsumo","actor":0,"pai":"?"}
{"type":"dahai","actor":0,"pai":"N","tsumogiri":false}
{"type":"tsumo","actor":1,"pai":"?"}
{"type":"dahai","actor":1,"pai":"F","tsumogiri":false}
{"type":"tsumo","actor":2,"pai":"9p"}

Save the mjai log content above into a file named log.json, then run:

$ sudo docker run -i --rm -v /path/to/model/dir:/mnt mortal 2 < log.json

This will output a series of new-line-separated JSONs, each of which represents Mortal's reaction to an mjai event that it is able to react to, with the last line corresponding to the scene illustrated above:

{
  "type": "dahai",
  "actor": 2,
  "pai": "9p",
  "tsumogiri": true,
  "meta": {
    "q_values": [
      -1.1929103,
      -1.5628747,
      -1.6204606,
      -1.607082,
      -1.3267958,
      -0.2436666,
      -1.5208447,
      -1.5280346,
      -1.5830542,
      -1.6640469,
      -1.1801766,
      -1.8054415
    ],
    "mask_bits": 17241923593,
    "is_greedy": true,
    "batch_size": 1,
    "shanten": 1,
    "eval_time_ns": 30352000
  }
}

From the JSON output we can clearly read that Mortal would like to discard 9p in this scene.

Tip

The field meta is not defined in mjai and is completely optional. In Mortal, this field is used to record metadata such as its network's raw outputs and evaluation time.

Don't shut down the process yet. Now let's go one turn further. The player discarded 9p, passed a 1m pon, and here it comes the next scene:

Figure 2

The mjai events the player perceived since Figure 1 are:

{"type":"dahai","actor":2,"pai":"9p","tsumogiri":true}
{"type":"tsumo","actor":3,"pai":"?"}
{"type":"dahai","actor":3,"pai":"2p","tsumogiri":true}
{"type":"tsumo","actor":0,"pai":"?"}
{"type":"dahai","actor":0,"pai":"8s","tsumogiri":false}
{"type":"tsumo","actor":1,"pai":"?"}
{"type":"dahai","actor":1,"pai":"1m","tsumogiri":false}
{"type":"tsumo","actor":2,"pai":"3p"}

Paste them into the running process's input (or just append these to log.json and re-run the command), and we will get Mortal's reactions to them.

First, there will be a none type action, which means Mortal would pass the 1m pon:

{
  "type": "none",
  "meta": {
    "q_values": [
      -1.2190987,
      -0.084070235
    ],
    "mask_bits": 37383395344384,
    "is_greedy": true,
    "batch_size": 1,
    "shanten": 1,
    "eval_time_ns": 29667100
  }
}

Then a dahai event will follow, which corresponds to the scene in Figure 2:

{
  "type": "dahai",
  "actor": 2,
  "pai": "1m",
  "tsumogiri": false,
  "meta": {
    "q_values": [
      -0.22652823,
      -1.7668037,
      -1.0071788,
      -1.7482929,
      -1.7783809,
      -1.5943735,
      -1.5575972,
      -1.5641792,
      -1.6780779,
      -1.7836256,
      -1.5739789,
      -1.915102
    ],
    "mask_bits": 17241794569,
    "is_greedy": true,
    "batch_size": 1,
    "eval_time_ns": 29686500
  }
}

We can tell that Mortal would choose to discard 1m at this point.

Build

Build required components

Prerequisites

To build and use Mortal, you need to have a Python environment and an up-to-date Rust compiler. If you plan to train, make sure you have a GPU installed.

It is recommended to use miniconda and rustup to setup the environment.

Instructions below will assume you already have miniconda and Rust installed.

Clone

$ git clone https://github.com/Equim-chan/Mortal.git
$ cd Mortal

From now on, the root directory of Mortal will be demostrated as $MORTAL_ROOT.

Create and activate a conda env

Working directory: $MORTAL_ROOT

$ conda env create -f environment.yml
$ conda activate mortal

Install pytorch

pytorch is not listed as a dependency in environment.yml on purpose so that users can install it with their favored ways as per their requirement, hardware and OS.

Check pytorch's doc on how to install pytorch in your environment. Personally, I recommand installing pytorch with pip.

Tip

Only torch is needed. You can skip the installation of torchvision and torchaudio.

Build and install libriichi

Working directory: $MORTAL_ROOT

$ cargo build -p libriichi --lib --release

For Linux

$ cp target/release/libriichi.so mortal/libriichi.so

For Windows (MSYS2)

$ cp target/release/riichi.dll mortal/libriichi.pyd

Test the environment

Working directory: $MORTAL_ROOT/mortal

$ python
Python 3.9.7 | packaged by conda-forge | (default, Sep 29 2021, 19:23:11)
[GCC 9.4.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import libriichi
>>> help(libriichi)

Optional targets

Run tests

Working directory: $MORTAL_ROOT

$ cargo test --workspace --no-default-features --features flate2/zlib -- --nocapture

Run benchmarks

Working directory: $MORTAL_ROOT

$ cargo test -p libriichi --no-default-features --bench bench

Build executable utilities

Working directory: $MORTAL_ROOT

$ cargo build -p libriichi --bins --no-default-features --release
$ cargo build -p exe-wrapper --release

Build documentation

Working directory: $MORTAL_ROOT/docs

$ cargo install mdbook mdbook-admonish mdbook-pagetoc
$ mdbook build

Meta

What does the name "Mortal" mean?

Mortal in this context means something opposed to supernatural or immortal.

There is no superpower in mahjong, nor any supernatural "fate's bless" to be relied on. Everyone is "mortal" and the AI is no different.

What the AI does is merely believing exclusively in the grand truth and never become emotional, with enough learning and practice and that's it. I've always believed that one of the reasons why many human players cannot defeat AIs in mahjong is that they are prone to becoming emotional, leading to minor but often fatal mistakes, which sometimes the players themselves deny to admit and blame on luck instead.

I got the inspiration from Kajiki Yumi, a character from Saki. She is indeed a mortal compared with other opponents she has played against, yet she tried her best as a mortal fighting for her own objective. "kajusan" was one of the names I have thought of, but I thought there were already a few mahjong projects based on character names from Saki.

I had a hard time thinking for a name. The project started with "OpenPhoenix", because it was at first a reproduction of Suphx, but after I changed many parts of it, it became less and less alike to Suphx, then I renamed it to "Reishanten". In the end, I thought the name was too hard to read and get its meaning, I came up with the name "Mortal".

When was the project started?

The project started on 2021-04-22. A prototype of PlayerState was made that day.

Why AGPL?

First of all, it is because the shanten algorithm (/libriichi/src/algo/shanten.rs) is a Rust port of tomohxx/shanten-number-calculator, which is licensed under GPL. As for the reason for it to be AGPL, my consideration is this project is natually easy to be exploited, such as being used for cheats or getting renamed then sold to unaware people, so even if I can't really stop such exploit, at least I want to do my part and make my attempt on what I can to stop this.

References

  1. Junjie Li, Sotetsu Koyamada, Qiwei Ye, Guoqing Liu, Chao Wang, Ruihan Yang, Li Zhao, Tao Qin, Tie-Yan Liu, and Hsiao-Wuen Hon. Suphx: Mastering mahjong with deep reinforcement learning. arXiv preprint arXiv:2003.13590, 2020.
  2. Dongqi Han, Tadashi Kozuno, Xufang Luo, Zhao-Yun Chen, Kenji Doya, Yuqing Yang, Dongsheng Li. Variational oracle guiding for reinforcement learning. In International Conference on Learning Representations, 2022.
  3. Shingo Tsunoda. Tenhou Manual. https://tenhou.net/man. Retrieved April 22, 2021.
  4. Louis Monier, Jakub Kmec, Alexandre Laterre, Thomas Pierrot, Valentin Courgeau, Olivier Sigaud, and Karim Beguir. Offline reinforcement learning hands-on. arXiv preprint arXiv:2011.14379, 2020.
  5. Artemij Amiranashvili, Alexey Dosovitskiy, Vladlen Koltun, and Thomas Brox. TD or not TD: Analyzing the role of temporal differencing in deep reinforcement learning. arXiv preprint arXiv:1806.01175, 2018.

Founders and original authors

Contributors

Nobody yet.

Sponsors

Donate❤️

Writing the code and training the model has cost me far more than I anticipated for a hobby. I would appreciate it if you could make a donation to me.

Cryptocurrencies

  • Monero
    4777777jHFbZB4gyqrB1JHDtrGFusyj4b3M2nScYDPKEM133ng2QDrK9ycqizXS2XofADw5do5rU19LQmpTGCfeQTerm1Ti
  • Bitcoin
    1Eqqqq9xR78wJyRXXgvR73HEfKdEwq68BT

Tenhou premium

My Tenhou screen name is 二宮蘭子. You could transfer premium days to me at https://tenhou.net/reg/transfer.cgi.