Close

How To Implement Glicko-2 In PL/SQL #JoelKallmanDay

I like Chess, I like Math, and I like PL/SQL – implementing a chess rating algorithm in pl/sql satisfies three itches in one.

The Glicko system was invented by Dr. Mark Glickman in 1995 as a way of rating players and it, along with its successor Glicko-2, have been adopted in a variety of online chess servers such as in chess.com, fresschess.org, and lichess.org. The system is game-agnostic though and is used for rating players in other games such as Go at online-go.com and the card game Dominion at dominion.games.
Dr. Glickman has released both Glicko and Glicko-2 algorithms to the public domain.

The main idea of the Glicko systems is not just the rating itself, but also how much that rating changes. Thus each player also has measure of confidence in how accurate the rating is. The Glicko-2 system is further enhanced by the introduction of volatility. Thus a player might be rated 1500 with a low deviation value of 10; implying the player has a 95% confident the player is rated between 1490 and 1510. That would apply in either system.

The Glicko-2 volatility is an attempt to measure external uncertainty in the other two numbers. The volatility can vary by game and host of each implementing system but the general idea is a player that is playing often and is rated 1500 should have a reliable rating with low deviation. If that player then takes an extended break from the game, it’s not certain that rating is still reliable when the player returns. This absence means the rating is more volatile and thus the previously low deviation is adjusted to allow for greater changes up or down in the rating based on new game results.

Both Glicko systems are described in papers found at http://www.glicko.net/glicko.html. I have intentionally named many of my variables and functions to follow the names presented in Glickman’s description. Thus you will find v_tau, big_e, v_big_a, v_big_c, v_illinois, etc. While these don’t necessarily follow my normal coding style; my hope is this pattern will make it easier to follow along in my code when reading the source paper. I found them helpful in checking my implementation was true to the documentation.

The Glicko2 package follows. It has no dependencies on other objects except for DBMS_OUTPUT which is used in the example code to display the rating changes.

CREATE OR REPLACE PACKAGE glicko2
AS
    -- Based on Glicko-2 system by Professor Mark E. Glickman, Boston University, March 22, 2022
    -- http://www.glicko.net/glicko/glicko2.pdf
    --
    --
    -- PL/SQL Implementation by Sean D. Stuber
    --
    --
    --                    .///.
    --                   (0 o)
    ---------------0000--(_)--0000---------------
    --
    --  Sean D. Stuber
    --  sean.stuber@gmail.com
    --
    --             oooO      Oooo
    --------------(   )-----(   )---------------
    --             \ (       ) /
    --              \_)     (_/

    TYPE player_rec IS RECORD
    (
        id            INTEGER,
        rating        NUMBER,
        deviation     NUMBER,
        volatility    NUMBER,
        mu            NUMBER,
        phi           NUMBER,
        g_of_phi      NUMBER
    );

    TYPE game_rec IS RECORD
    (
        player1    INTEGER,
        player2    INTEGER,
        result     INTEGER -- 0=draw, 1=player1 wins, 2=player2 wins
    );

    TYPE player_tab IS TABLE OF player_rec;

    TYPE game_tab IS TABLE OF game_rec;

    PROCEDURE display_player(p_player IN player_rec);

    FUNCTION game_score(p_games IN game_tab, p_player IN INTEGER, p_opponent IN INTEGER)
        RETURN NUMBER;

    PROCEDURE set_tau(p_tau IN NUMBER);

    FUNCTION get_tau
        RETURN NUMBER;

    FUNCTION initialize_player(p_id           IN INTEGER,
                               p_rating       IN NUMBER DEFAULT 1500,
                               p_deviation    IN NUMBER DEFAULT 350,
                               p_volatility   IN NUMBER DEFAULT 0.06)
        RETURN player_rec;

    FUNCTION estimated_variance(p_player IN player_rec, p_players IN player_tab)
        RETURN NUMBER;

    FUNCTION estimated_delta(p_player IN player_rec, p_players IN player_tab, p_games IN game_tab)
        RETURN NUMBER;

    FUNCTION new_volatility(p_player IN player_rec, p_delta IN NUMBER, p_variance IN NUMBER)
        RETURN NUMBER;

    FUNCTION new_rating(p_player       IN player_rec,
                        p_volatility   IN NUMBER,
                        p_variance     IN NUMBER,
                        p_players      IN player_tab,
                        p_games        IN game_tab,
                        p_tau          IN NUMBER DEFAULT NULL)
        RETURN player_rec;

    PROCEDURE update_all_players(p_players IN OUT player_tab, p_games game_tab, p_tau IN NUMBER DEFAULT NULL);

    PROCEDURE example;
END;
CREATE OR REPLACE PACKAGE BODY glicko2
AS
    g_tau                     NUMBER := 0.5;

    c_glicko_factor           NUMBER := 173.7178;
    c_convergence_tolerance   NUMBER := 0.000001;

    PROCEDURE display_player(p_player IN player_rec)
    IS
    BEGIN
        DBMS_OUTPUT.put_line('-------------------------');
        DBMS_OUTPUT.put_line('ID: ' || p_player.id);
        DBMS_OUTPUT.put_line('rating:     ' || p_player.rating);
        DBMS_OUTPUT.put_line('deviation:  ' || p_player.deviation);
        DBMS_OUTPUT.put_line('volatility: ' || p_player.volatility);
        -- Probably don't need/want to see these unless debugging
        --  DBMS_OUTPUT.put_line('mu:         ' || p_player.mu);
        --  DBMS_OUTPUT.put_line('phi:        ' || p_player.phi);
        --  DBMS_OUTPUT.put_line('g_of_phi:   ' || p_player.g_of_phi);
        DBMS_OUTPUT.put_line('-------------------------');
    END display_player;

    PROCEDURE set_tau(p_tau IN NUMBER)
    IS
    BEGIN
        g_tau := p_tau;
    END set_tau;

    FUNCTION get_tau
        RETURN NUMBER
    IS
    BEGIN
        RETURN g_tau;
    END get_tau;

    FUNCTION g(p_phi IN NUMBER)
        RETURN NUMBER
    IS
        c_pi   CONSTANT NUMBER := ACOS(-1);
    BEGIN
        RETURN 1 / SQRT(1 + (3 * p_phi ** 2) / c_pi ** 2);
    END g;

    FUNCTION initialize_player(p_id           IN INTEGER,
                               p_rating       IN NUMBER DEFAULT 1500,
                               p_deviation    IN NUMBER DEFAULT 350,
                               p_volatility   IN NUMBER DEFAULT 0.06)
        RETURN player_rec
    IS
    BEGIN
        RETURN player_rec(id           => p_id,
                          rating       => p_rating,
                          deviation    => p_deviation,
                          volatility   => p_volatility,
                          mu           => (p_rating - 1500) / c_glicko_factor,
                          phi          => p_deviation / c_glicko_factor,
                          g_of_phi     => g(p_deviation / c_glicko_factor));
    END initialize_player;

    FUNCTION big_e(p_player_mu IN NUMBER, p_opponent IN player_rec)
        RETURN NUMBER
    IS
    BEGIN
        RETURN 1 / (1 + EXP(-p_opponent.g_of_phi * (p_player_mu - p_opponent.mu)));
    END big_e;

    FUNCTION estimated_variance(p_player IN player_rec, p_players IN player_tab)
        RETURN NUMBER
    IS
        v_variance   NUMBER := 0;
        v_big_e      NUMBER;
    BEGIN
        FOR j IN 1 .. p_players.COUNT
                     WHEN p_players(j).id != p_player.id
        LOOP
            v_big_e := big_e(p_player.mu, p_players(j));
            v_variance := v_variance + ((p_players(j).g_of_phi ** 2) * v_big_e * (1 - v_big_e));
        END LOOP;

        v_variance := 1 / v_variance;
        RETURN v_variance;
    END estimated_variance;

    FUNCTION game_score(p_games IN game_tab, p_player IN INTEGER, p_opponent IN INTEGER)
        RETURN NUMBER
    IS
        v_score   NUMBER;
    BEGIN
        FOR i
            IN 1 .. p_games.COUNT
                   WHEN (p_games(i).player1 IN (p_player, p_opponent) AND p_games(i).player2 IN (p_player, p_opponent))
        LOOP
            v_score :=
                CASE
                    WHEN p_games(i).result = 0 THEN 0.5
                    WHEN p_games(i).result = 1 AND p_games(i).player1 = p_player THEN 1
                    WHEN p_games(i).result = 2 AND p_games(i).player2 = p_player THEN 1
                    ELSE 0
                END;
        END LOOP;

        RETURN v_score;
    END game_score;

    FUNCTION estimated_delta(p_player IN player_rec, p_players IN player_tab, p_games IN game_tab)
        RETURN NUMBER
    IS
        v_variance   NUMBER := estimated_variance(p_player, p_players);
        v_delta      NUMBER := 0;
    BEGIN
        FOR j IN 1 .. p_players.COUNT
                     WHEN p_players(j).id != p_player.id
        LOOP
            v_delta :=
                  v_delta
                + (  p_players(j).g_of_phi
                   * (game_score(p_games, p_player.id, p_players(j).id) - big_e(p_player.mu, p_players(j))));
        END LOOP;

        RETURN v_variance * v_delta;
    END estimated_delta;

    FUNCTION illinois_volatility(p_x            IN NUMBER,
                                 p_delta        IN NUMBER,
                                 p_phi          IN NUMBER,
                                 p_variance     IN NUMBER,
                                 p_volatility   IN NUMBER)
        RETURN NUMBER
    IS
        v_e_x          NUMBER := EXP(p_x);
        v_phisquared   NUMBER := p_phi ** 2;
    BEGIN
        RETURN     v_e_x
                 * (p_delta ** 2 - v_phisquared - p_variance - v_e_x)
                 / (2 * ((v_phisquared + p_variance + v_e_x) ** 2))
               - ((p_x - LN(p_volatility ** 2)) / g_tau ** 2);
    END illinois_volatility;

    FUNCTION new_volatility(p_player IN player_rec, p_delta IN NUMBER, p_variance IN NUMBER)
        RETURN NUMBER
    IS
        v_a          NUMBER := LN(p_player.volatility);
        v_big_a      NUMBER := v_a;
        v_big_b      NUMBER;
        v_big_c      NUMBER;
        v_k          INTEGER;
        v_illinois   NUMBER;
        v_f_a        NUMBER;
        v_f_b        NUMBER;
        v_f_c        NUMBER;
    BEGIN
        IF p_delta ** 2 >= p_player.phi ** 2 + p_variance
        THEN
            v_big_b := LN(p_delta ** 2 - p_player.phi ** 2 - p_variance);
        END IF;

        IF p_delta ** 2 <= p_player.phi ** 2 + p_variance
        THEN
            v_k := 1;

            LOOP
                v_illinois :=
                    illinois_volatility(v_a - v_k * g_tau, p_delta, p_player.phi, p_variance, p_player.volatility);

                IF v_illinois < 0
                THEN
                    v_k := v_k + 1;
                    CONTINUE;
                END IF;

                EXIT;
            END LOOP;

            v_big_b := v_a - v_k * g_tau;
        END IF;

        v_f_a := illinois_volatility(v_big_a, p_delta, p_player.phi, p_variance, p_player.volatility);

        v_f_b := illinois_volatility(v_big_b, p_delta, p_player.phi, p_variance, p_player.volatility);

        WHILE ABS(v_big_b - v_big_a) > c_convergence_tolerance
        LOOP
            v_big_c := v_big_a + ((v_big_a - v_big_b) * v_f_a / (v_f_b - v_f_a));
            v_f_c := illinois_volatility(v_big_c, p_delta, p_player.phi, p_variance, p_player.volatility);

            CASE
                WHEN v_f_c * v_f_b <= 0
                THEN
                    v_big_a := v_big_b;
                    v_f_a := v_f_b;
                ELSE
                    v_f_a := v_f_a / 2;
            END CASE;

            v_big_b := v_big_c;
            v_f_b := v_f_c;
        END LOOP;

        RETURN EXP(v_big_a / 2);
    END new_volatility;

    FUNCTION new_rating(p_player       IN player_rec,
                        p_volatility   IN NUMBER,
                        p_variance     IN NUMBER,
                        p_players      IN player_tab,
                        p_games        IN game_tab,
                        p_tau          IN NUMBER DEFAULT NULL)
        RETURN player_rec
    IS
        v_result   player_rec;
        v_phi2     NUMBER;
        v_mu       NUMBER := 0;
    BEGIN
        IF p_tau IS NOT NULL
        THEN
            set_tau(p_tau);
        END IF;

        v_result.id := p_player.id;

        v_phi2 := SQRT(p_player.phi ** 2 + p_volatility ** 2);
        v_result.phi := 1 / SQRT(1 / (v_phi2 ** 2) + 1 / p_variance);
        v_result.g_of_phi := g(v_result.phi);

        FOR j IN 1 .. p_players.COUNT
                     WHEN p_players(j).id != p_player.id
        LOOP
            v_mu :=
                  v_mu
                + (  p_players(j).g_of_phi
                   * (game_score(p_games, p_player.id, p_players(j).id) - big_e(p_player.mu, p_players(j))));
        END LOOP;

        v_result.mu := p_player.mu + v_result.phi ** 2 * v_mu;

        v_result.rating := c_glicko_factor * v_result.mu + 1500;
        v_result.deviation := c_glicko_factor * v_result.phi;
        v_result.volatility := p_volatility;

        RETURN v_result;
    END new_rating;

    PROCEDURE update_all_players(p_players IN OUT player_tab, p_games game_tab, p_tau IN NUMBER DEFAULT NULL)
    IS
        v_variance          NUMBER;
        v_delta             NUMBER;
        v_volatility        NUMBER;
        v_updated_players   player_tab := player_tab();
    BEGIN
        IF p_tau IS NOT NULL
        THEN
            set_tau(p_tau);
        END IF;

        v_updated_players.EXTEND(p_players.COUNT);

        FOR i IN 1 .. p_players.COUNT
        LOOP
            v_variance := estimated_variance(p_players(i), p_players);

            v_delta := estimated_delta(p_players(i), p_players, p_games);

            v_volatility := new_volatility(p_players(i), v_delta, v_variance);

            v_updated_players(i) := new_rating(p_players(i), v_volatility, v_variance, p_players, p_games);
        END LOOP;

        p_players := v_updated_players;
    END update_all_players;

    PROCEDURE example
    IS
        v_players      player_tab
            := player_tab(initialize_player(1, 1500, 200, 0.06),
                          initialize_player(2, 1400, 30, 0.06),
                          initialize_player(3, 1550, 100, 0.06),
                          initialize_player(4, 1700, 300, 0.06));

        v_games        game_tab := game_tab(game_rec(1, 2, 1), game_rec(1, 3, 2), game_rec(1, 4, 2));
        v_variance     NUMBER;
        v_delta        NUMBER;
        v_volatility   NUMBER;
        v_updated      player_rec;
    BEGIN
        DBMS_OUTPUT.put_line('Before');

        display_player(v_players(1));

        set_tau(0.5);

        v_variance := estimated_variance(v_players(1), v_players);
        v_delta := estimated_delta(v_players(1), v_players, v_games);
        v_volatility := new_volatility(v_players(1), v_delta, v_variance);

        v_updated := new_rating(v_players(1), v_volatility, v_variance, v_players, v_games);

        DBMS_OUTPUT.put_line('After');
        display_player(v_updated);
    END example;
END;

In Dr. Glickman's paper he includes an example calculating a player's rating after competing against 3 other ranked players. The package includes the EXAMPLE procedure to run his example.

BEGIN
    glicko2.example;
END;
/

Before
-------------------------
ID: 1
rating:     1500
deviation:  200
volatility: .06
-------------------------
After
-------------------------
ID: 1
rating:     1464.050670539089251000090331474725619233
deviation:  151.516524124304315076736848217911789433
volatility: .0599959844006778356377413158384369581414
-------------------------

Player 1, won 1 game and lost 2 others. While each of the results was maybe to be expected (won vs lower rank, lost vs higher rank), player 1's rating still dropped a few points from 1500 to 1464. The initial deviation of 200 lowered as the system became a little more confident in its rating after the additional input. The volatility remained mostly the same. It was already fairly static, but it did lower very slightly as the ratings were fresh and thus confidence was as high as possible given the available data.
The mu, phi, and g_of_phi values are for internal use within the system and can be ignored.

Extending that example to include matches between the other players we can update all of the players ratings at the end of a rating period. I've kept player 1's results the same, player 2, loses every game, player 3 wins two and loses one, player 4 wins every game.

DECLARE
    c_tau       NUMBER := 0.5;
    v_players   glicko2.player_tab
        := glicko2.player_tab(glicko2.initialize_player(1, 1500, 200, 0.06),
                              glicko2.initialize_player(2, 1400, 30, 0.06),
                              glicko2.initialize_player(3, 1550, 100, 0.06),
                              glicko2.initialize_player(4, 1700, 300, 0.06));

    v_games     glicko2.game_tab
        := glicko2.game_tab(glicko2.game_rec(1, 2, 1),
                            glicko2.game_rec(1, 3, 2),
                            glicko2.game_rec(1, 4, 2),
                            glicko2.game_rec(2, 3, 2),
                            glicko2.game_rec(2, 4, 2),
                            glicko2.game_rec(3, 4, 2));
BEGIN
    DBMS_OUTPUT.put_line('Before');

    FOR i IN 1 .. v_players.COUNT
    LOOP
        glicko2.display_player(v_players(i));
    END LOOP;

    glicko2.update_all_players(v_players, v_games, c_tau);

    DBMS_OUTPUT.put_line('After');

    FOR i IN 1 .. v_players.COUNT
    LOOP
        glicko2.display_player(v_players(i));
    END LOOP;
END;

Before
-------------------------
ID: 1
rating:     1500
deviation:  200
volatility: .06
-------------------------
-------------------------
ID: 2
rating:     1400
deviation:  30
volatility: .06
-------------------------
-------------------------
ID: 3
rating:     1550
deviation:  100
volatility: .06
-------------------------
-------------------------
ID: 4
rating:     1700
deviation:  300
volatility: .06
-------------------------
After
-------------------------
ID: 1
rating:     1464.050670539089251000090331474725619233
deviation:  151.516524124304315076736848217911789433
volatility: .0599959844006778356377413158384369581414
-------------------------
-------------------------
ID: 2
rating:     1395.575300667365091681790311891889886509
deviation:  31.52226732290745258692198451742165957159
volatility: .0600018359077613166409605623174229410253
-------------------------
-------------------------
ID: 3
rating:     1570.661236457502866661616824411132327558
deviation:  93.02707854276383549253944675925066799718
volatility: .0599959033294384056527124236262498706405
-------------------------
-------------------------
ID: 4
rating:     1846.840970178800326149436527884413374357
deviation:  194.563175845294448506167881660623266854
volatility: .0599984601432078028346829965616648799165
-------------------------

Note, in the UPDATE_ALL_PLAYERS procedure a new collection of player records is created rather than updating in place. This is to compute each player against the "before" ratings of every other player and once all of them have their new ratings, then update them with the "after" ratings.

I wanted to do something fun and interesting for Joel Kallman Day this year and I enjoyed this little project quite a bit. While the functions are useful in themselves for their intended purposes, those looking for the Oracle lesson to be gained. I used some of the newer record constructor and loop syntax introduced in 21c. I really appreciated the WHEN-clause on the FOR loops when iterating through players. For each player I could LOOP through all of them but skip the current player so I was only looping through the opponents. Of course, I could use an IF or CASE inside the LOOP, but I found the WHEN syntax to be a more natural way of declaring my intent. Similarly, when looping through games to find scores, I used this construction to search through all games, but only process the ones that applied to the particular pair of players I was interested in.

FOR i IN 1 .. p_games.COUNT
WHEN (p_games(i).player1 IN (p_player, p_opponent) AND p_games(i).player2 IN (p_player, p_opponent))

Again, I could have checked for that condition inside the loop, but I found this to be more self-describing as to the intent of the loop.

I hope you found this useful and entertaining. Thank you for reading. Questions and comments, as always, are welcome.

Leave a Reply