yardsale on GitHub

Let’s clean up this code just a bit and then draw some conclusions about programming a thing like this. Clickbait: The technical conclusions are not what I thought they would be.

Full Disclosure
There is a serious defect in the code, which I’ll discover down below just a bit. The effect of the defect is that certain players are more likely to win than others: the game isn’t fair. As we’ll see, all that really does is speed up the rich getting richer. They still win all the chips even with the defect fixed.

What’s written below is contemporaneous. You’ll see me, all confident, then suddenly seeing a significant defect, then recovering.

Let’s review the code and improve it before we muse about what the little simulation toy tells us about reality.

The Code

I wrote this little program because I was thinking about the inequities of our economic system, and I had been looking for an article that I had seen, wanting to write about it. I couldn’t find the article, so I decided to write the program myself. The purpose was to get some pictures of the growing inequity in a simple economic system, to give us all something to think about.

So there was no need for the code to attain particularly high publication value, although it is published on GitHub in case someone wants to play with it.

Furthermore, almost all the changes made to the program were based on how it looked on screen: its actual calculations are nearly trivial. With that in mind, let’s browse.

I do have a few tests:

class TestPerson:
    def test_hookup(self):
        assert 2 + 2 == 4

    def test_person(self):
        p = Person()
        q = Person()
        p.transact(q, 1)
        assert p.wealth == 1100
        assert q.wealth == 900

    def test_person_q_wins(self):
        p = Person()
        q = Person()
        p.transact(q, 0)
        assert p.wealth == 900
        assert q.wealth == 1100

    def test_radius(self):
        p = Person()
        v = PersonView(p, 0, 0)
        assert v.radius == pytest.approx(10.0)

    def test_slice(self):
        wealths = [i for i in range(1000)]
        assert len(wealths) == 1000
        sliced = wealths[250:1000]
        assert len(sliced) == 750

    def test_scale(self):
        assert scale_max(500) == 1000
        assert scale_max(1000) == 2000
        assert scale_max(2000) == 5000
        assert scale_max(5000) == 10000
        assert scale_max(10000) == 20000
        assert scale_max(20000) == 50000
        assert scale_max(50000) == 100000

We test transactions, to be sure that they do what they should. I wrote a test for the radius of the dot, though not a very good test: I think I was testing just to drive out the radius code. There’s a test of slicing that I wrote because the histogram wasn’t coming out right and I thought, mistakenly, that I had misunderstood slicing. Grasping at straws. And I tested the scaling. If you look at the histograms in the pictures above, you can see that the scale of the ‘gram changes as the value of the richest person’s holdings increase.

Everything else was by eyeball. Mostly, that worked out OK: I don’t recall too many holdups where better tests would have helped. That’s due to the fact that almost everything about this program is in the display.

The Person class is as close to a model as we have:

class Person:
    def __init__(self):
        self.wealth = 1000

    def bet(self):
        return 0.1*self.wealth

    def transact(self, other, prob=None):
        prob = prob if prob is not None else random()
        bet = min(self.bet(), other.bet())
        if prob:
            self.wealth += bet
            other.wealth -= bet
        else:
            other.wealth += bet
            self.wealth -= bet

We do have a couple of tests for the transact, as you’ve seen. We allow a test to pass in a probability, so that it can control the win-loss for testing. Otherwise …

Whoa!
I stopped typing above because that code is WRONG! random() returns a value between zero and one and therefore self will almost invariably win. (I suppose it could return a zero.)

This program has never worked!

Code should say:

    def transact(self, other, prob=None):
        prob = prob if prob is not None else random()
        bet = min(self.bet(), other.bet())
        if prob > 0.5:
            self.wealth += bet
            other.wealth -= bet
        else:
            other.wealth += bet
            self.wealth -= bet

My excuses for this aren’t very good. First of all the code was so simple that I couldn’t possibly get it wrong, and second, it’s too hard to test random behavior. The first excuse is clearly wrong and here’s a test disproving the second:

    def test_random(self):
        p = Person()
        q = Person()
        cycles = 10000
        wins = 0
        for i in range(cycles):
            p.wealth = 1000
            q.wealth = 1000
            p.transact(q)
            if p.wealth > q.wealth:
                wins += 1
        assert wins == pytest.approx(cycles/2, abs=cycles/20)

With the defect still in, the test fails:

Expected :5000.0 ± 5.0e+02
Actual   :10000

Rather obviously wrong. Are you telling me I couldn’t have written that test right away? I thought not. If I had written it, I wouldn’t be so embarrassed now. With the code correct, the test runs. Let’s commit that: fix probability defect making same person win every time.

What does this change in the results? Not very much, because we only transact when a pair is colliding, and collisions are random. However, running the program after this change, I do notice that it seems to take longer to get to the same horrible situation.

With the defect in place, a player who comes early in the pairs set will always win. With the defect fixed, the bet is fair and he may win or may lose.

With this defect removed, and before we wrap, we’ll look at the results of a run. First, let’s continue with the code.

PersonView

We’re using pygame to do our display work, so the shape of the Game class is largely driven by the way one writes pygame. We only have one kind of object in our game, the Person, and I wrote a simple view class to bounce people around, and to draw a person as a disc based on the person’s wealth:

class PersonView:
    def __init__(self, person, x, y):
        self.vel = None
        self.person = person
        self.pos = Vector2(x, y)
        self.set_random_velocity()
        self.adjust_radius = 10 / math.sqrt(1000/math.pi)

    def set_random_velocity(self):
        v = random.uniform(0.8, 1.2)
        theta = random.uniform(0, 2 * math.pi)
        self.vel = 4 * Vector2(v * math.cos(theta), v * math.sin(theta))

    @property
    def radius(self):
        return math.sqrt(self.person.wealth / math.pi) * self.adjust_radius

    def colliding(self, aPersonView):
        dist = self.pos.distance_to(aPersonView.pos)
        return dist <= self.radius + aPersonView.radius

    def move(self):
        if random.random() < 0.005:
            self.set_random_velocity()
        self.pos += self.vel
        if self.pos.x < self.radius or self.pos.x > screen_size - self.radius:
            self.vel = Vector2(-self.vel.x, self.vel.y)
        if self.pos.y < self.radius or self.pos.y > screen_size - self.radius:
            self.vel = Vector2(self.vel.x, -self.vel.y)

    def draw(self, screen):
        wealth = self.person.wealth
        if wealth < 250:
            color = "red"
        elif wealth < 500:
            color = "yellow"
        else:
            color = "cyan"
        pygame.draw.circle(screen, color, self.pos, max(4, int(self.radius)), 0)

Nothing to see here, really. We occasionally randomize the person’s velocity, so that they don’t just go back and forth at the same angle all the time. It just makes the game look better as it runs.

The move code reverses x or y travel if you hit the x or y boundaries, so that a person will bounce off the walls. This isn’t perfectly robust: if a person is moving just right, they can overlap a wall a bit, and get stuck there for a while, because this code will just keep them bouncing back and forth. We might be able to fix this easily, or it might take a bit more cleverness. In the actual case, since this is a throw-away script, it’s good enough: they do eventually get unstuck.

Game

Again, not much to see here. One mildly interesting thin is how we populate the screen. We don’t want people to start out colliding, so we have this:

    def populate(self):
        for i in range(100):
            self.add_person(self.people)

    def add_person(self, person_views):
        safe = False
        margin = 20
        person = Person()
        while not safe:
            safe = True
            x = random.uniform(0 + margin, screen_size - margin)
            y = random.uniform(0 + margin, screen_size - margin)
            trial = PersonView(person, x, y)
            for view in person_views:
                if view.colliding(trial):
                    safe = False
        person_views.append(trial)

That code just keeps trying a random x, y location for the next person until it doesn’t overlap any other player. Works fine for our purposes, although if we were to try to add a lot more people instead of 100, it would turn out that there isn’t enough room and this code would loop forever.

Probably the only other interesting bit in Game is the code that draws the histogram. It is quite ad-hoc:

    def statistics(self, views, screen):
        pygame.draw.line(screen, "green", (0, screen_size), (screen_size - 55, screen_size))
        wealths = sorted([view.person.wealth for view in views])
        richest = wealths[-1]
        poorest = wealths[0]
        for v in views:
            w = v.person.wealth
            richest = max(richest, w)
            poorest = min(poorest, w)
        text = f'Min: {poorest:.0f} Max: {richest:.0f} ({richest/1000:.0f}%)'
        score_surface = self.score_font.render(text, True, "green")
        screen.blit(score_surface, (20, screen_size))
        scale = stats_space / scale_max(richest)
        x = 0
        min_height = 2
        top = screen_size
        for w in wealths:
            x_pos = x
            x += 7
            height = w * scale
            if height < min_height: height = min_height
            y = screen_size + stats_space - height
            pygame.draw.rect(screen, "white", (x_pos, y, 5, height))
        scale_text = f'{scale_max(richest)}'
        scale_surface = self.axis_font.render(scale_text, True, "green")
        screen.blit(scale_surface,(700, screen_size - 8))

Let’s improve that a bit:

    def statistics(self, views, screen):
        pygame.draw.line(screen, "green", (0, screen_size), (screen_size - 55, screen_size))
        wealths = sorted([view.person.wealth for view in views])
        poorest, richest = self.get_loser_and_winner(views, wealths)
        self.display_loser_and_winner(poorest, richest, screen)
        self.draw_histogram(richest, wealths, screen)

That’s just three Extract Method calls. Two of the resulting new methods are probably OK:

    def get_loser_and_winner(self, views, wealths):
        richest = wealths[-1]
        poorest = wealths[0]
        for v in views:
            w = v.person.wealth
            richest = max(richest, w)
            poorest = min(poorest, w)
        return poorest, richest

    def display_loser_and_winner(self, poorest, richest, screen):
        text = f'Min: {poorest:.0f} Max: {richest:.0f} ({richest / 1000:.0f}%)'
        score_surface = self.score_font.render(text, True, "green")
        screen.blit(score_surface, (20, screen_size))

The histogram is still a bit hard to grok:

    def draw_histogram(self, richest, wealths, screen):
        scale = stats_space / scale_max(richest)
        x = 0
        min_height = 2
        top = screen_size
        for w in wealths:
            x_pos = x
            x += 7
            height = w * scale
            if height < min_height: height = min_height
            y = screen_size + stats_space - height
            pygame.draw.rect(screen, "white", (x_pos, y, 5, height))
        scale_text = f'{scale_max(richest)}'
        scale_surface = self.axis_font.render(scale_text, True, "green")
        screen.blit(scale_surface, (700, screen_size - 8))

How about this:

    def draw_histogram(self, richest, wealths, screen):
        self.draw_bars(wealths, richest, screen)
        scale_text = f'{scale_max(richest)}'
        scale_surface = self.axis_font.render(scale_text, True, "green")
        screen.blit(scale_surface, (700, screen_size - 8))

    def draw_bars(self, wealths, richest, screen):
        scale = stats_space / scale_max(richest)
        x_pos = 0
        min_height = 2
        for w in wealths:
            x_pos += 7
            height = w * scale
            if height < min_height: height = min_height
            y = screen_size + stats_space - height
            pygame.draw.rect(screen, "white", (x_pos, y, 5, height))

First, let’s deal with the x_pos in a more pythonic way:

    def draw_bars(self, wealths, richest, screen):
        scale = stats_space / scale_max(richest)
        min_height = 2
        for w, x_pos in zip(wealths, itertools.count(7, 7)):
            height = w * scale
            if height < min_height: height = min_height
            y = screen_size + stats_space - height
            pygame.draw.rect(screen, "white", (x_pos, y, 5, height))

Here, itertools.count just provides 7, 14, 21, …, just what we wanted. We can improve a name:

    def draw_bars(self, wealths, richest, screen):
        scale = stats_space / scale_max(richest)
        min_height = 2
        for wealth, x_pos in zip(wealths, itertools.count(7, 7)):
            height = wealth * scale
            if height < min_height: height = min_height
            y = screen_size + stats_space - height
            pygame.draw.rect(screen, "white", (x_pos, y, 5, height))

And extract an explaining method:

    def draw_bars(self, wealths, richest, screen):
        scale = stats_space / scale_max(richest)
        min_height = 2
        for wealth, x_pos in zip(wealths, itertools.count(7, 7)):
            self.draw_one_bar(wealth, x_pos, scale, min_height, screen)

    def draw_one_bar(self, wealth, x_pos, scale, min_height, screen):
        height = wealth * scale
        if height < min_height: height = min_height
        y = screen_size + stats_space - height
        pygame.draw.rect(screen, "white", (x_pos, y, 5, height))

To me, the only thing that is not obvious now is the y calculation. What if we made these changes:

    def draw_one_bar(self, wealth, x_pos, scale, min_height, screen):
        height_of_bar = max(wealth * scale, min_height)
        bottom_of_graph = screen_size + stats_space
        top_of_bar = bottom_of_graph - height_of_bar
        pygame.draw.rect(screen, "white", (x_pos, top_of_bar, 5, height_of_bar))

I think that’s enough hinting. Commit: refactoring, tidying.

One more change that I think improves things. From this:

    def draw_histogram(self, richest, wealths, screen):
        self.draw_bars(wealths, richest, screen)
        scale_text = f'{scale_max(richest)}'
        scale_surface = self.axis_font.render(scale_text, True, "green")
        screen.blit(scale_surface, (700, screen_size - 8))

To this:

    def draw_histogram(self, richest, wealths, screen):
        self.draw_bars(wealths, richest, screen)
        self.draw_scale(richest, screen)

    def draw_scale(self, richest, screen):
        scale_text = f'{scale_max(richest)}'
        scale_surface = self.axis_font.render(scale_text, True, "green")
        screen.blit(scale_surface, (700, screen_size - 8))

I meant to do that back when I extracted the other method and just now noticed that I had not done it. That change makes the code better according to the notion that a method should either do things, or call things, but not both. We are not fanatics here about that rule, more of a guideline actually, but I am no paragon of virtue over here.

Now let’s run it and observe.

The Simulation Displays

The progression of the simulation is this: Everyone starts out with an equal supply of money, 1000 units.



They engage in fair transactions, the amount of each based on how much money the lesser of the two has. They flip a coin, and that amount goes to the winner, from the loser.

Very quickly, we see that some people have lost almost everything. Yellow dots have between 250 and 500 left. Red dots are down to less than 250 units. The richest person has over 100 times the money of the poorest.



A bit later, almost everyone is flat broke, and a few people are very well off. One individual holds over a tenth of all the money in the world!



And just a little bit later, almost everyone in the world is destitute, eight people have all the money and one individual has almost 40 percent of the total world wealth. And, though it is not shown clearly in the simulation, I happen to know that that person is a total jerk.

There are still a handful of people with between a quarter and a half of the money they started with. Most everyone is at or near zero wealth.

Summary

I’ve decided to put “technical” conclusions here and do a separate article about any larger-scale notions that the simulation itself might teach us.

The first lesson, unlike what I thought it would be is No matter how simple the script is, there is a decent chance there will be a problem in it and the problem may not be obvious from the results.

One simple test would have discovered the problem. I have no recollection of how it got in there: I suspect that since I did the 1-0 test, I was thinking that the probability would be passed in and then decided to calculate it inside, and somehow forgot to check for 0.5. Simple mistake, fatal to the results … but the results looked enough like what I expected that I accepted them.

Had I not written this article, I would never have looked at that code.

Even in throwaway code … some simple testing is worth doing. Ron without tests is not as smart as Ron with tests.

Another lesson is that even a simple script often gets enhanced, and keeping the code reasonably clear will pay off. Even in this program, I read the code more often than I wrote it, and making it look more like it does today makes that easier to do. PyCharm will do a simple method extraction or renaming with just a few keystrokes, and they are worth doing.

I really didn’t expect to have to write anything like this. I felt sure that I could just crack out this simple little example and then move on to drawing social lessons, and then get back to set theory.

I was wrong. Reality slapped me in the face again. I am used to it … but I don’t like it, and I don’t like having published mistaken code for even a few days.

Still: I’m much more confident that the program is doing what I set out to do. And next time, we’ll see what it makes me think about the world.

See you then!