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

p = Person()
v = PersonView(p, 0, 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()

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 colliding(self, aPersonView):
dist = self.pos.distance_to(aPersonView.pos)

def move(self):
if random.random() < 0.005:
self.set_random_velocity()
self.pos += self.vel
self.vel = Vector2(-self.vel.x, self.vel.y)
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):

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))
``````

``````    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.