If you cannot open within RPubs, you can open it in a new browser tab.
本文的中文版: 明日方舟抽卡概率理论值的蒙特卡洛仿真估计
This repo is to estimate the theoretical probability of getting a specific 6★ operator on \(i^{th}\) pull/within \(i\) pulls in different types of banners in Arknights via Monte Carlo method.
The starting point of the pity system is configurable. I provided this flexibility for you if you are curious about the probability in a different mathematical model. With different settings for this value, you will see that the pity system has a different influence on the theoretical probability.
In this article, I will introduce how to use this code, giving several simple examples than you can have a first try. I will also discuss the mathematical model behind the pulling rules in Arknights, as well as the probabilities of several kinds of random events that you may care about. Then the key part of the code — random number generator — will be briefly explained, which I believe is inspiring and important. Finally, the estimated theoretical probability will be presented and analyzed, and have a look at some future work.
You can also jump to the part that you interested in directly:
Arknights is a tower defense mobile game developed by Chinese developers Studio Montagne (蒙塔山工作室) and Hypergryph. It was released in China on 1 May 2019, and worldwide on 16 January 2020 by Yostar. Arknights is available on iOS, Android and PC platforms and features gacha game mechanics. (Adopted From Wikipedia)
The game has hit No.1 overall on the iOS App Store top grossing ranking list for three times [1], TapTap Game Awards 2019 - Best Game, TapTap Game Awards 2019 - Most Influential China-Developed Game, TapTap Game Awards 2019 - Gamers’ Choice [2], Bilibili 2019 Game Review - Top Rated Game, Bilibili 2019 Game Review - Most Searched Game, Bilibili 2019 Game Review - Users’ Favorite Mobile Game [3], etc.
The game has achieved 40 million downloads worldwide [4] and 7 millions download outside the CN Server at the end of 2020, April. [5]
Official Website:
After git clone, cd into the directory and run make in the repo’s directory to build from the source code. Then an executable file named simulation_sequential will be generated.
Run make clean to remove all *.os and the executable files.
You need to specify some command line arguments to run the simulation:
./simulation_sequential [--help] [-t|--total-pull-time <value>] [--standard|--limited]
[-p|--pity <value>] [-n|--num-rate-up <value>] [-c|--current-pull]
The arguments in square brackets are optional, and their orders does not matter. You can use either the short name arguments (-t, -p and -n) or the long name arguments (--total-pull-time, --pity and --num-rate-up) as you wish.
Each arguments’ meaning is explained in the following table:
| Argument | Explanation |
|---|---|
--help |
Show the help message - the introduction of each argument and how to run the program Should not be specified with other arguments at the same time |
-t--total-pull-time |
Set how many times of pulling will be executed during the simulation Control the scale of the simulation Valid value: an integer between \([1, 18446744073709551615]\) (inclusive) |
--standard |
Estimate the theoretical probability in a standard banner |
--limited |
Estimate the theoretical probability in a limited banner |
-p--pity |
Set the starting point where the pity system comes into effect The pity system will start to increase the probability of getting a 6★ operator in the pull after the N-th pull (N is the number you specified)Valid value: an integer from \([0, 4294967295]\) (inclusive) |
-n--num-rate-up |
Set whether to simulate a single-rate-up banner or a double-rate-up banner Valid value: either \(1\) or \(2\) |
-c--current-pull |
Set how many times have you pulled but without getting a 6★ operator Valid value: an integer between [0, <-p|--pity value> + 49) (inclusive, exclusive) |
On default, if no arguments are provided, the program will simulate a double-rate-up limited banner with \(100,000,000\) times pulling with pity starting point equals to \(50\) (equivalent to run with --limited -t 100000000 -n 2 -p 50), which is same as in a limited banner in Arknights, unless you specify the corresponding arguments to override the default behavior of the program.
The bigger the value for total pull time is, the more accurate the result will be, but the program will consume more time to finish the simulation. The single thread simulation with -t 10000000000 (simulating \(10\) billion times of pulling) took about \(110\) seconds to finish on the platform that I used:
You can override the program’s default simulation configuration. For example, if you only specify -t 200000000 (or --total-pull-time 200000000) and still omit other arguments, the default value of total pull times (which is \(100,000,000\)) will be overridden, and the theoretical probability of a double-rate-up limited banner will be estimated under \(200,000,000\) times simulated pulling. If you also specify --standard and -n 1, then the program will simulate a single-rate-up standard banner under \(200,000,000\) times simulated pulling.
The program will try its best to find out any types of syntax errors that may occur in the command line arguments and give you the error reason (show in the picture below) — just like a simple compiler. However, do not fully rely on this error-check feature: always read the docs carefully ;-)
Here are several quick examples to start with:
See the help message
./simulation_sequential --help
Simulate a double-rate-up limited banner
./simulation_sequential --limited -t <a-positive-big-integer>
or just
./simulation_sequential -t <a-positive-big-integer>
Simulate a double-rate-up standard banner
./simulation_sequential --standard -t <a-positive-big-integer>
Simulate a single-rate-up standard banner
./simulation_sequential --standard -n 1 -t <a-positive-big-integer>
Simulate a single-rate-up banner which you will start to roll, but you have pulled for 42 times without getting a 6★ operator in another standard banner
./simulation_sequential --standard -n 1 -c 42 -t <a-positive-big-integer>
Simulate a single-rate-up limited banner (not appeared in Arknights yet!)
./simulation_sequential --limited -n 1 -t <a-positive-big-integer>
or just
./simulation_sequential -n 1 -t <a-positive-big-integer>
The recommended value for <a-positive-big-integer> is 100000000 or above.
It is recommended that you redirect the output to a file, for example
./simulation_sequential --limited -t 200000000 >> simu_limited_banner.res
since the output of the simulation program will be relatively long.
In this part, I will introduce the mathematical model behind the Arknights pulling rules, the definitions of random events, which are important to understanding the simulation results, and several method to calculate those probabilities.
Here, we define the following random events,
Here the target 6★ operator refers to the (one of the) 6★ operator(s) featured by the banner, and the \(A_i\) and \(S_i\) require that 6★/target 6★ operator is not obtained in all previous \(i-1\) pulls.
To make the notation simpler,
In one word, when you achieve your goal, \(i\) resets and starts counting from the beginning again.
Based on the definitions above, here we give several examples,
Who is the target 6★ operator is determined by you, as long as this operator is featured by a banner.
If you carefully exam the definitions, you will find that the probabilities of these random events, \(Pr(A_i)\), \(Pr(B_i)\), \(Pr(S_i)\), \(Pr(W_i)\) and \(Pr(F_i)\), are actually conditional probabilities — under the condition of “the pity system is reset”. We just did not explicitly write this out. This is also what the simulation results in this article actually mean — they are the probabilities of these events happening when the pity system just reset and you start to pull again. If you have already pulled several times without obtaining a 6★ operator in a previous standard banner, and you continue to pull in the next standard banner, the probability of getting a 6★/target 6★ or not on/within some pulls might be lightly different. However, currently, I think the difference may not be very big. (Hope I am right…)
I am currently working on improving the code and do some research on this question. Once finished, I will post a new article illustrating this question.
Please forgive me being wordy here. (I am not a native speaker of English) I hope I made myself clear by giving these examples that explain my definitions above :-)
We are curious about the values of \(Pr(S_i)\) and \(Pr(W_i)\), which are also what this repo attempts to estimate.
Calculating the probability that getting a (any) 6★ operator is relatively easy. Some previous works [7] have shown the numeric value from \(Pr(A_1)\) to \(Pr(A_{99})\). However, the link in reference [7] does not provide a formula. Theoretically,
\[ \begin{equation} Pr(A_i)=\left\{ \begin{aligned} & (0.98)^{i-1} \times 0.02&1 \le i\le 50 & \\ & (0.98)^{50} \times (\prod_{k=51}^{i-1} \frac{99-k}{50}) \times \frac{i-49}{50} & 50<i\le 98 && \ where\ i \in N_+\\ & (0.98)^{50} \times \prod_{k=1}^{48} \frac{49-k}{50} & i = 99 & \\ \end{aligned} \right. \end{equation} \]
With the results above, we can obtain the expectation of the number of pulls to get a 6★ operator, i.e., \(\sum_{i=1}^{99}i \cdot Pr(A_i)\).
This expectation is approximately \(34.59\), or saying, the probability of getting a 6★ operator in Arknights is \(2.89\%\).
Here the value \(34.59\) means that if we average all players’ pull history (how many times did they pull to get a 6★ operator), the final result will be it. We can also understand this from a different aspect — if you pull for \(N\) times, and you get 6★ operators for \(m\) times. Each time you pull \(n_i\) times to get a 6★ operator, where \(i \in N_+,\ 1 \le i \le m\). The following equation will hold
\[ \begin{aligned} \lim_{m \to +\infty} \frac {\displaystyle \sum_{i=1}^{m} n_i }{m} =& \lim_{N \to +\infty} \frac {\displaystyle \sum_{i=1}^{m} n_i }{m}\\ =& \sum_{i=1}^{99}i \cdot Pr(A_i)\\ \approx& 34.59 \end{aligned} \]
In fact, according to [8], the probability of getting a 6★ operator on the current pull is only decided by whether you got one in your previous pull, which satisfies the Markov property (or called “memorylessness”). So, we can also use Markov Chain to build the model for this question. Each state in the chain has the same meaning of \(B_i\), and there are \(100\) different states in this chain.
It seems not so straightforward to calculate the theoretical probability of getting a specific (target) 6★ operator. Since if you want to get the probability of getting your target 6★ operator at your \(N^{th}\) pull, it is possible that you got several other 6★ characters and reset the pity system many times until you get what you want at your last pull; it is also possible that you did not get any 6★ operators before your \(N^{th}\) pull. As you can see, there exists a lot of mutually exclusive scenarios. And we need to calculate the probability of each scenario and sum them together.
To have a clearer sense of how many scenarios existing, let’s count them based on how many times that you get a non-target 6★ operators from your first pull to your \((N-1)^{th}\) pull. We know in advance that the only one target 6★ operator is obtained in \(N^{th}\) pull. So, on each individual pull before \(N^{th}\) pull, you can whether get a non-target 6★ operator or not. It is possible that you did not get any non-target 6★ operator in \(N-1\) pulls, corresponding to \(\binom{N-1}{0}\) scenarios; it is also possible that you only get one non-target 6★ operator, which has \(\binom{N-1}{1}\) different situations; similarly, getting 2 non-target 6★ operators corresponding to \(\binom{N-1}{2}\) scenarios. We will find that the number of all exclusive scenarios is
\[ \sum_{i=0}^{N-1}\binom{N-1}{i} = 2^{N-1} \]
which means, if we take this intuitive approach to get the theoretical probability, we need to calculate the probabilities of \(2^{N-1}\) different scenarios. All of these indicate a terrible amount of work.
Some promising methods of obtaining these accurate theoretical value of \(Pr(S_i)\) are using Markov Chain and/or Dynamic Programming [9]. In [9], the value of \(Pr(S_i),\ i \in N_+,\ 1\le i \le 300\) were obtained. The author did not calculate the \(Pr(S_i),\ i \in N_+,\ i \ge 300\) due to the Spark System. However, other pointed out that the index in the author’s code was incorrect [10], and this method can not obtain the formulas of \(Pr(S_i)\).
I adopt the Monte Carlo simulation to estimate the theoretical \(Pr(S_i)\) in this repo.
To be specific, we define that a trial is pulling for one or more times until you get your target 6★ operator. Simulate \(N\) pulls (\(N\) is big enough), and finally you get your target 6★ operators for \(n\) times (means that \(n\) trials are finished). You pull \(i\) times to finish a trial happens \(m_i\) times, namely \(m_1,\ m_2,\ ...\) We regard each frequency as the corresponding estimated probability
\[ \frac{m_i}{n} \approx Pr(S_i),\ i \in N_+,\ 1 \le i \le n \]
where there exists
\[ \begin{aligned} &\lim_{n \to + \infty}\frac{m_i}{n}\\ =& \lim_{N \to + \infty}\frac{m_i}{n}\\ =&Pr(S_i),\ i \in N_+ \end{aligned} \]
which indicates that in order to get a more accurate result, it is recommended to provided a larger value for command line arguments -t or --total-pull-time to expect a larger \(n\).
When the number of simulated pull reaches the limitation given by the command line argument
-tor--total-pull-time, stop the simulation. It is possible that the last trial would be aborted because limitation is reached.
Now we take a look at how to calculate \(Pr(W_i)\). According to the definitions of \(W_i\) and \(S_i\), we can see that \(W_i\) means that one of \(S_1\), \(S_2\), …, \(S_{i-1}\), \(S_i\) happens. For example, \(W_4\) means that you can get your target 6★ operator on your first pull (\(S_1\)), or second pull (\(S_2\)), or third pull (\(S_3\)) or fourth pull (\(S_4\)). When one of these scenarios happen, you just stop pulling, so we can avoid dealing with some complex events (e.g., \(S_1\) happens then \(S_3\) happens, or \(S_3\) happens then \(S_1\) happens, or \(S_2\) happens twice, or \(S_1\) happens for four times).
So we can conclude that
\[ \begin{aligned} Pr(W_i)=& \sum_{k=1}^{i} Pr(S_k)\\ =& \sum_{k=1}^{i} \frac{m_k}{n},\ n\ is\ big\ enough \end{aligned} \]
Pulling in Arknights is a random experiment. To follow the same pulling rule in the simulation program, meanwhile ensuring the accuracy of the results, we need to generate a great number of high-quality uniformly distributed random numbers, and compare its value with several thresholds to decide whether we get a 6★/target 6★ operator. As a result, selecting a high-quality as well as blazing-fast random number generator (RNG) becomes the key part of the program.
rand() % n is Evil!It is quite common in many places that, we use the following code to generate a sequence of random number uniformly distributed in a given range, for example, \([0,\ 99]\)
int seed; // initialize seed with some value
srand(seed);
for(int i = 0; i < 16; ++i) {
int rand_num = rand() % 100;
cout << rand_num << endl;
}
By the way, the code above, as well as some topics discussed below, is adopt from a lecture given by Stephan T. Lavavej from Microsoft®, which is mainly about generating random numbers in C++. If you are interested in this topic, I would strongly recommended you to take a look at this cool lecture. Here is the link.
which is problematic!
Here is the problem.
First, let’s take a look at the period. In glibc, rand() uses Linear Congruential Generator (LCG) to produce random numbers. It may also use the Linear-Feedback Shift Register (LFSR) to generate the random number. According to [11], if LFSR is adopted, rand() only records last 32 output results. In other words, it can at most have \(2^{32}\) different states; if LCG is adopted, the period is at most same as the modulus m that LCG algorithm used to generating numbers [12]. In fact, [13] and [14] show that, in glibc implementation, the value of m is \(2^{31}-1\) (which is 0x7fffffff in hexadecimal). What’s more, the article from [15], as well as [16], the period of rand() is shown to be roughly \(2^{32}\). To estimate the \(Pr(S_i)\), we at least need to simulate one billion (more than \(2^{29}\)) times of pull. This number would be increased to ten billion (more than \(2^{33}\)) to obtain a better accuracy. We can conclude that, the period of rand() is not enough for our large scale Monte Carlo simulation program.
The original website of [15] is down, so I provide the link cached by Google.
Second, the quality of the distribution of numbers generated by rand() and rand() % n is considered as low. No guarantees are made by the standard to ensure the quality of the random numbers generated by rand() [17]. Actually, it is not truly uniformly distributed since it uses LCG or LFSR. Even assuming that rand() is perfectly uniformly distributed, rand() % n is still not. In this example from Stephan’s lecture, assuming that numbers from rand() is perfectly uniform-distributed in \([0,\, 32767]\), and we want to get a uniform distribution number in \([0,\ 99]\), as code snip below
int src = rand(); // assume uniform [0, 32767]
int desired_range = 100;
int dist = src % desired_range; // wrong!
This is a mistake is very easy to make. Why it is non-uniform distributed? The reason is actually very simple
int src = rand(); // assume uniform [0, 32767]
int desired_range = 100;
int dist = src % desired_range;
// src → dist
// [0, 99] → [0, 99]
// [100, 199] → [0, 99]
// [200, 299] → [0, 99]
// ...
// [32700, 32767] → [0, 67] -- problematic!
As you can see, the numbers in range \([0,\ 67]\) will have a slightly higher probability than the numbers in range \((67,\ 99]\). This error is introduced by the modulo. As long as the largest number that rand() can return (defined by RAND_MAX) is not a multiple of desired_range, this problem will be triggered. When estimating a \(Pr(S_i)\) which itself has a very small value, this modulo-error will impose a great relative error to our results.
What’s more, the following code is still not perfectly uniform distribution, because only when src is \(99\) will make dist equal to \(99\), however, for dist less than \(99\) can has multiple corresponding src values.
int src = rand(); // assume uniform [0, 32767]
int desired_range = 100;
int dist = static_case<int>(src * 1.0 / RAND_MAX) * (desired_range - 1);
the following code is still wrong, since there is no way to uniformly map \(32768\) integers to \(100\) integers…
int src = rand(); // assume uniform [0, 32767]
int desired_range = 100;
int dist = static_case<int>(src * 1.0 / (RAND_MAX + 1)) * desired_range;
In Stephan’s lecture, he gives a more detailed explanation of these pitfalls. You can refer to his lecture if you are interested.
<random> LibraryTo achieve a high quality of uniform distribute number in order to reduce the inaccuracy of estimated probabilities as much as I can, I adopt the 64-bit Mersenne Twister Engine mt19937_64 introduced in C++11 to generate the random numbers. The numbers produced by mt19937_64 are filtered through uniform_int_distribution. Finally, we obtained the perfectly uniform-distributed numbers. This approach eliminates, rather than just reduces, the errors discussed in previous section. Compared the 32-bit version mt_19937, the 64-bit version Mersenne Twister Engine can accept more entropy, and the number of non-zero terms in its characteristic polynomial is roughly twice as many as the 32-bit version [18].
A quick example code is shown below
#include <random> // required header
std::mt19937_64 mt(42); // 42 as the seed
std::uniform_int_distribution<unsigned int> dist(0, 99); // an uniform distribution
// on [0, 99] (both inclusive)
for (int i = 0; i < 100; ++i) {
unsigned int ran = dist(mt); // dist is driven by mt
std::cout << rand_num << std::endl;
}
This example uses a fixed random seed, so it will have a deterministic results. You can create a random_device object and do the following
std::radom_device rd;
std::mt19937_64 mt(rd());
In this repo, to ensure the independency between each simulation, I use the random_device to generate a random seed, and then feed this seed to mt19937_64.
According to [18], Mersenne Twister Engine mt19937_64 (as well as mt19937) has an extremely long period \(2^{19937}-1\), you do not need to worry about the generated numbers will repeat again even if you run this simulation for a lifetime of universe. It also has a extremely high quality of distribution (ensured by the standard), which satisfy our requirements.
To save the space, I will only show the numeric value of \(Pr(S_i), Pr(W_i)\), where \(i \in N_+, 1 \le i \le 100\) here. I will also show several line charts based on a larger range of pulls. You can refer the full results where \(i \in N_+, 1 \le i < 1000\) in the simulation output files under /res directory.
If you do not know what do \(Pr(S_i)\) and \(Pr(W_i)\) mean, you can refer to the section Abstracted Questions and Definitions.
There would be more metrics that can reasonably analyze the errors. I am not majored in statistics, and if you know more scientific way to measure the simulation error, I would be very appreciate if you can let me know.
Here, the theoretical value of \(Pr(S_1)\) of these three kinds of banner can be easily obtained. Here I will compare the estimated values of \(Pr(S_1)\) and its theoretical value to get the absolute error \(\Delta Pr(S_1)\). And assume that the quality of the Mersenne Twister Engine mt19937_64 is high enough, so that the absolute errors of all other \(Pr(S_i)\) have the same absolute value as \(Pr(S_1)\) has. Since the true value for \(Pr(S_i)\) when \(i > 50\) is hard to obtain, we will compare the estimated value with the absolute value of the absolute error \(|\Delta Pr(S_1)|\) to have a sense of the accuracy of the estimated \(Pr(S_i)\).
We say that if
\[ \frac{|\Delta Pr(S_1)|}{Pr(S_i)} \le 5\% \]
, we would think this estimated value is “accurate”. \(Pr(W_i)\) has the same “accurate” range since they are obtained based on the estimated \(Pr(S_i)\).
Note that since the true values of most of \(Pr(S_i)\) are not known, the relative errors cannot be determined.
Theoretically, in double-rate-up limited banner,
\[ \begin{aligned} Pr(S_1) &= 2\% \times 70\% \div 2\\ &= 0.7\% \end{aligned} \]
The estimated value of \(Pr(S_1)\) is \(0.700026\%\) (from res/limited_500000000000_double_up_simu_1.res). So the absolute error and its absolute value is
\[ \begin{aligned} \Delta Pr(S_1) &= 0.700026\% - 0.7\% \\ &= 0.000026\% \\ &= 2.6 \times 10^{-7} \\ | \Delta Pr(S_1) | &= 2.6 \times 10^{-7} \end{aligned} \]
The threshold of an accurate \(Pr(S_i)\) is
\[ \begin{aligned} |\Delta Pr(S_1)| \div 5\% &= 5.2 \times 10^{-6} \\ &= 0.00052\% \end{aligned} \]
\(Pr(S_i)\) needs to be equal or bigger than this threshold. After checking the data obtained from the simulation, all the estimated values of \(Pr(S_i)\) and \(Pr(W_i)\), where \(1 \le i \le 677\), are accurate.
Similarly, according the simulation results of double-rate-up standard banner (from res/standard_500000000000_double_up_simu_1.res), the estimated values of \(Pr(S_i)\) and \(Pr(W_i)\), where \(1 \le i \le 780\), are accurate.
Finally, according the simulation results of single-rate-up standard banner (from res/standard_500000000000_single_up_simu_1.res), the estimated values of \(Pr(S_i)\) and \(Pr(W_i)\), where \(1 \le i \le 394\), are accurate.
If we loosen the condition from \(5\%\) to \(10\%\), the accurate range for single-rate-up standard banner is \(1 \le i \le 433\). So I finally still include the data of \(1 \le i \le 500\). If we want a higher accuracy, then we need to run multiple times of simulation at a larger scale.
Note that the method of analyzing error here may not be scientific enough to correctly show how truly accurate the results are. It is also possible that the estimated values that beyond this range are accurate enough, too.
You may find that the estimate \(Pr(S_i)\) start to fluctuate when \(i\) is relatively big (especially when \(i\) is close to \(1000\)). Without the theoretical values (the true value) of \(Pr(S_i)\), we cannot say that those estimated value, although less likely, is not accurate.
Due to the limited time, I did not carry out a much more larger simulation. Currently, the results presented in this article is based on \(500,000,000,000\) simulated pulls, which needs about \(95\) minutes to finish on single thread. I may execute a larger scaled parallel simulation and average the results to achieve a higher accuracy.
Besides, we can also try a different RNG. Arknights is made by Unity Engine, and we could try the RNG that the Unity adopts, or try a different seeding method (for example, seeding based on time and/or player’s UID).
What’s more, currently I did not try the same simulation configuration but with different -p value. We can also see with different pity system starting points, how the curve of \(Pr(S_i)\) will change in its shape. This would also be an interesting question to have a look at — why the designer of the Arknights let the pity system starts after \(50^{th}\) pull, rather than after \(20^{th}\) pull or \(30^{th}\) pull?
Finally, we can have a way to automatically generate the line charts and find the turning points as well as the maximum values. I may implemented this functionality using Python in the future.
The markdown code for the table of content is automatically generated by ekalinin’s tool.
Since I am not majored in statistics, the discussion in this article has a limited depth. If you have any ideas about the method that can obtain these theoretical value of \(Pr(S_i)\) and \(Pr(F_i)\), or simulation error analysis, or some mistakes in this article, please do not hesitate to let me know. I would be greatly appreciate it — actually I am very curios about the theoretical way to get the accurate results. Welcome to post your precious thoughts😀.
Special thanks to my friend 浮生之夏@jlgg — he discussed a lot with me and also pointed out several mistakes that I made when I first learned about the Markov Chain model.
There are several fantastic repos built by others, which inspired me a lot — thank you guys!
💮Frizu - Arknights 6-star Headhunting Rate Analysis: in this repo the author did a large amount of research about the odds of getting a 6★ operator. Frizu has a series of articles Arknights 6★ Headhunting Rate Analysis, Arknights’ Non-6★ Headhunting Streak EDA, that illustrates his method, the math and the results.
💮GalAster - ArkKnightsCapsule: in this repo, the author estimated probabilities of getting one of the featured 6★ operators, or both of the featured 6★ operators in double-rate-up standard banner and double-rate-up limited banner. What’s more, the odds of making the featured 6★ operators reach their max potential are also estimated.