Data anonymizer
200 made-up patient records released with names, emails and phones removed: postcode, birth date and sex still single out every one, and a log mask of one asterisk per letter publishes each name's length.
Every screen below was recorded under CPython. When this page was built, the EML interpreter replayed each session from the same input and printed the same bytes.
About
A study release of 200 made-up patient records with every direct identifier removed, and what the fields that were kept still identify.
main.eml- the menu, the release, how many records are alone on what was kept, the masked order log, and one record looked upanon.eml- the records, drawn from a fixed seed; what each release keeps of a record; what a masked log line shows; and counting groups
How each part works:
- Name, email and phone are removed and a check of every column proves it. Postcode, birth date and sex are kept, because the study needs them.
- As in the corpus case
the-numbers-were-anonymised-and-the-combination-was-unique, all 200 records are the only one with their postcode, birth date and sex: a voter roll that lists the same three against a name puts a name on each. Kept as postcode and birth year, 196 are alone; as district and year, 165; as district and decade, still 23. No level here makes the smallest group bigger than 1. - As in
the-mask-removed-the-value-and-kept-the-length, the order log masks a name and an email name one asterisk per letter, and keeps the city: 38 of the 200 customers are alone on the two lengths and the city. Eight asterisks for every name leave the city alone to tell lines apart, and no customer alone on it. The build's check that no line holds a name passes on both logs.
What is checked: menu choices 1 to 5; a record from 1 to 200. An empty answer cancels.
Sessions: sessions/basic.in gives: - the release, the counts of records alone and the masked log; - records 17, 1 and 200.
sessions/bad-input.in gives: - menu choices 0 and x; - records: a cancel, 0, 201, x and 1.5; - record 99.
Built on the verified corpus cases the-numbers-were-anonymised-and-the-combination-was-unique (a release with direct identifiers removed and postcode, birth date and sex kept) and the-mask-removed-the-value-and-kept-the-length (a log mask of one asterisk per character).
Recorded sessions
What the screen shows while someone uses the program. Each typed line appears after its prompt, the way a terminal shows it.
bad-input
interpreter: byte-equal== Data anonymizer ==
A study release with every direct identifier removed, and what the rest still identifies.
1) the release 2) alone on what was kept 3) the masked log 4) one record 5) quit
choice> 0
Pick a number from 1 to 5.
1) the release 2) alone on what was kept 3) the masked log 4) one record 5) quit
choice> x
Pick a number from 1 to 5.
1) the release 2) alone on what was kept 3) the masked log 4) one record 5) quit
choice> 4
record (1 to 200)>
Cancelled.
1) the release 2) alone on what was kept 3) the masked log 4) one record 5) quit
choice> 4
record (1 to 200)> 0
Type a whole number from 1 to 200.
1) the release 2) alone on what was kept 3) the masked log 4) one record 5) quit
choice> 4
record (1 to 200)> 201
Type a whole number from 1 to 200.
1) the release 2) alone on what was kept 3) the masked log 4) one record 5) quit
choice> 4
record (1 to 200)> x
Type a whole number from 1 to 200.
1) the release 2) alone on what was kept 3) the masked log 4) one record 5) quit
choice> 4
record (1 to 200)> 1.5
Type a whole number from 1 to 200.
1) the release 2) alone on what was kept 3) the masked log 4) one record 5) quit
choice> 4
record (1 to 200)> 99
Record 99: LS7 7, born 1965-02-10, F. Other records that share what each release keeps:
postcode, birth date, sex 0
postcode, birth year, sex 0
district, birth year, sex 0
district, birth decade, sex 2
In the masked log it shares its two lengths and city with 6 others.
1) the release 2) alone on what was kept 3) the masked log 4) one record 5) quit
choice> 5
Bye.
What was typed (15 lines)
0
x
4
4
0
4
201
4
x
4
1.5
4
99
5
basic
interpreter: byte-equal== Data anonymizer ==
A study release with every direct identifier removed, and what the rest still identifies.
1) the release 2) alone on what was kept 3) the masked log 4) one record 5) quit
choice> 1
A study gets 200 patient records. Name, email and phone were removed, and a check of every column proved them gone.
Kept for the study: postcode, birth date and sex, with the measurements. The first six records as released:
1 LS11 5 1974-11-12 M
2 LS2 5 1940-07-03 F
3 LS2 8 1996-10-12 M
4 LS11 2 1996-03-11 F
5 LS7 8 1966-07-04 F
6 LS6 9 1957-07-02 F
1) the release 2) alone on what was kept 3) the masked log 4) one record 5) quit
choice> 2
What each release keeps, and how many records are alone on it:
kept alone smallest group groups
postcode, birth date, sex 200 1 200
postcode, birth year, sex 196 1 198
district, birth year, sex 165 1 182
district, birth decade, sex 23 1 76
200 of 200 records are the only one with their postcode, birth date and sex. A voter roll lists the same three against a name, and the join puts a name on each of them.
Kept as the district and the decade of birth, 23 are still alone. A release hides a person only where the smallest group is more than 1, and no level here gets there.
1) the release 2) alone on what was kept 3) the masked log 4) one record 5) quit
choice> 3
The order log masks a customer's name and email name one asterisk per letter; the city is not personal data:
order 1001: ***** (email *******) in Wakefield
order 1002: ********* (email ***********) in Bradford
order 1003: ****** (email *********) in York
One asterisk per letter: 38 of 200 customers are alone on their two lengths and city.
Eight asterisks for every name: 0 alone; the city is all a line tells apart.
The build's check that no line holds a customer's name passes on both logs. It asks about the letters, not about the length.
1) the release 2) alone on what was kept 3) the masked log 4) one record 5) quit
choice> 4
record (1 to 200)> 17
Record 17: LS11 6, born 1997-12-24, F. Other records that share what each release keeps:
postcode, birth date, sex 0
postcode, birth year, sex 1
district, birth year, sex 1
district, birth decade, sex 4
In the masked log it shares its two lengths and city with 1 other.
1) the release 2) alone on what was kept 3) the masked log 4) one record 5) quit
choice> 4
record (1 to 200)> 1
Record 1: LS11 5, born 1974-11-12, M. Other records that share what each release keeps:
postcode, birth date, sex 0
postcode, birth year, sex 0
district, birth year, sex 0
district, birth decade, sex 3
In the masked log it shares its two lengths and city with 5 others.
1) the release 2) alone on what was kept 3) the masked log 4) one record 5) quit
choice> 4
record (1 to 200)> 200
Record 200: LS2 4, born 1949-10-20, F. Other records that share what each release keeps:
postcode, birth date, sex 0
postcode, birth year, sex 0
district, birth year, sex 0
district, birth decade, sex 4
In the masked log it shares its two lengths and city with 1 other.
1) the release 2) alone on what was kept 3) the masked log 4) one record 5) quit
choice> 5
Bye.
What was typed (10 lines)
1
2
3
4
17
4
1
4
200
5
Modules
The program as written, entry module first. Each module transpiles to its own Python file, which is what eml project run executes.
main.eml(entry)
eml# P168 data anonymizer: a study release with the direct identifiers
# removed - the release, how many records are alone on what was kept, the
# masked order log, and one record looked up.
import anon
def plain(s):
0 => i
len(s) => j
while i < j and s[i] == " ":
i + 1 => i
while j > i and s[j - 1] == " ":
j - 1 => j
return s[i:j]
def pad(s, n):
s => out
while len(out) < n:
out + " " => out
return out
def lpad(s, n):
s => out
while len(out) < n:
" " + out => out
return out
def digits(t):
for c in t:
if c < "0" or c > "9":
return False
return len(t) > 0
def stars(n):
"" => out
while len(out) < n:
out + "*" => out
return out
def levels():
return ["postcode, birth date, sex", "postcode, birth year, sex", "district, birth year, sex", "district, birth decade, sex"]
def keys(rs, level):
[] => out
for r in rs:
out + [anon.key(r, level)] => out
return out
def log_keys(rs, mask):
[] => out
for r in rs:
out + [anon.log_key(r, mask)] => out
return out
def row(r):
return pad(r[2] + " " + str(r[3]), 8) + str(r[4]) + "-" + anon.two(r[5]) + "-" + anon.two(r[6]) + " " + r[7]
def show_release():
anon.records() => rs
("A study gets " + str(len(rs)) + " patient records. Name, email and phone were removed, and a check of every column proved them gone.") ^0
"Kept for the study: postcode, birth date and sex, with the measurements. The first six records as released:" ^0
0 => i
while i < 6:
(" " + lpad(str(i + 1), 3) + " " + row(rs[i])) ^0
i + 1 => i
def show_alone():
anon.records() => rs
"What each release keeps, and how many records are alone on it:" ^0
(" " + pad("kept", 30) + lpad("alone", 6) + lpad("smallest group", 16) + lpad("groups", 8)) ^0
[] => st
0 => level
while level < 4:
anon.stats(keys(rs, level)) => s
st + [s] => st
(" " + pad(levels()[level], 30) + lpad(str(s[0]), 6) + lpad(str(s[1]), 16) + lpad(str(s[2]), 8)) ^0
level + 1 => level
(str(st[0][0]) + " of " + str(len(rs)) + " records are the only one with their postcode, birth date and sex. A voter roll lists the same three against a name, and the join puts a name on each of them.") ^0
("Kept as the district and the decade of birth, " + str(st[3][0]) + " are still alone. A release hides a person only where the smallest group is more than 1, and no level here gets there.") ^0
def show_log():
anon.records() => rs
"The order log masks a customer's name and email name one asterisk per letter; the city is not personal data:" ^0
0 => i
while i < 3:
(" order " + str(1001 + i) + ": " + stars(len(rs[i][0])) + " (email " + stars(rs[i][1]) + ") in " + rs[i][8]) ^0
i + 1 => i
anon.stats(log_keys(rs, "per letter")) => a
anon.stats(log_keys(rs, "fixed")) => b
("One asterisk per letter: " + str(a[0]) + " of " + str(len(rs)) + " customers are alone on their two lengths and city.") ^0
("Eight asterisks for every name: " + str(b[0]) + " alone; the city is all a line tells apart.") ^0
"The build's check that no line holds a customer's name passes on both logs. It asks about the letters, not about the length." ^0
def show_record():
anon.records() => rs
plain(input("record (1 to 200)> ")) => t
if t == "":
"Cancelled." ^0
return
if len(t) > 3 or not digits(t) or int(t) < 1 or int(t) > 200:
"Type a whole number from 1 to 200." ^0
return
rs[int(t) - 1] => r
("Record " + t + ": " + r[2] + " " + str(r[3]) + ", born " + str(r[4]) + "-" + anon.two(r[5]) + "-" + anon.two(r[6]) + ", " + r[7] + ". Other records that share what each release keeps:") ^0
0 => level
while level < 4:
anon.key(r, level) => k
anon.groups(keys(rs, level))[k] - 1 => n
(" " + pad(levels()[level], 30) + lpad(str(n), 4)) ^0
level + 1 => level
anon.groups(log_keys(rs, "per letter"))[anon.log_key(r, "per letter")] - 1 => m
" others" => word
if m == 1:
" other" => word
("In the masked log it shares its two lengths and city with " + str(m) + word + ".") ^0
"== Data anonymizer ==" ^0
"A study release with every direct identifier removed, and what the rest still identifies." ^0
True => running
while running:
"" ^0
"1) the release 2) alone on what was kept 3) the masked log 4) one record 5) quit" ^0
plain(input("choice> ")) => choice
if choice == "1":
show_release()
elif choice == "2":
show_alone()
elif choice == "3":
show_log()
elif choice == "4":
show_record()
elif choice == "5":
False => running
else:
"Pick a number from 1 to 5." ^0
"Bye." ^0
Python projection (main.py)
import anon
def plain(s):
i = 0
j = len(s)
while i < j and s[i] == " ":
i = i + 1
while j > i and s[j - 1] == " ":
j = j - 1
return s[i:j]
def pad(s, n):
out = s
while len(out) < n:
out = out + " "
return out
def lpad(s, n):
out = s
while len(out) < n:
out = " " + out
return out
def digits(t):
for c in t:
if c < "0" or c > "9":
return False
return len(t) > 0
def stars(n):
out = ""
while len(out) < n:
out = out + "*"
return out
def levels():
return ["postcode, birth date, sex", "postcode, birth year, sex", "district, birth year, sex", "district, birth decade, sex"]
def keys(rs, level):
out = []
for r in rs:
out = out + [anon.key(r, level)]
return out
def log_keys(rs, mask):
out = []
for r in rs:
out = out + [anon.log_key(r, mask)]
return out
def row(r):
return pad(r[2] + " " + str(r[3]), 8) + str(r[4]) + "-" + anon.two(r[5]) + "-" + anon.two(r[6]) + " " + r[7]
def show_release():
rs = anon.records()
print("A study gets " + str(len(rs)) + " patient records. Name, email and phone were removed, and a check of every column proved them gone.")
print("Kept for the study: postcode, birth date and sex, with the measurements. The first six records as released:")
i = 0
while i < 6:
print(" " + lpad(str(i + 1), 3) + " " + row(rs[i]))
i = i + 1
def show_alone():
rs = anon.records()
print("What each release keeps, and how many records are alone on it:")
print(" " + pad("kept", 30) + lpad("alone", 6) + lpad("smallest group", 16) + lpad("groups", 8))
st = []
level = 0
while level < 4:
s = anon.stats(keys(rs, level))
st = st + [s]
print(" " + pad(levels()[level], 30) + lpad(str(s[0]), 6) + lpad(str(s[1]), 16) + lpad(str(s[2]), 8))
level = level + 1
print(str(st[0][0]) + " of " + str(len(rs)) + " records are the only one with their postcode, birth date and sex. A voter roll lists the same three against a name, and the join puts a name on each of them.")
print("Kept as the district and the decade of birth, " + str(st[3][0]) + " are still alone. A release hides a person only where the smallest group is more than 1, and no level here gets there.")
def show_log():
rs = anon.records()
print("The order log masks a customer's name and email name one asterisk per letter; the city is not personal data:")
i = 0
while i < 3:
print(" order " + str(1001 + i) + ": " + stars(len(rs[i][0])) + " (email " + stars(rs[i][1]) + ") in " + rs[i][8])
i = i + 1
a = anon.stats(log_keys(rs, "per letter"))
b = anon.stats(log_keys(rs, "fixed"))
print("One asterisk per letter: " + str(a[0]) + " of " + str(len(rs)) + " customers are alone on their two lengths and city.")
print("Eight asterisks for every name: " + str(b[0]) + " alone; the city is all a line tells apart.")
print("The build's check that no line holds a customer's name passes on both logs. It asks about the letters, not about the length.")
def show_record():
rs = anon.records()
t = plain(input("record (1 to 200)> "))
if t == "":
print("Cancelled.")
return
if len(t) > 3 or not digits(t) or int(t) < 1 or int(t) > 200:
print("Type a whole number from 1 to 200.")
return
r = rs[int(t) - 1]
print("Record " + t + ": " + r[2] + " " + str(r[3]) + ", born " + str(r[4]) + "-" + anon.two(r[5]) + "-" + anon.two(r[6]) + ", " + r[7] + ". Other records that share what each release keeps:")
level = 0
while level < 4:
k = anon.key(r, level)
n = anon.groups(keys(rs, level))[k] - 1
print(" " + pad(levels()[level], 30) + lpad(str(n), 4))
level = level + 1
m = anon.groups(log_keys(rs, "per letter"))[anon.log_key(r, "per letter")] - 1
word = " others"
if m == 1:
word = " other"
print("In the masked log it shares its two lengths and city with " + str(m) + word + ".")
print("== Data anonymizer ==")
print("A study release with every direct identifier removed, and what the rest still identifies.")
running = True
while running:
print("")
print("1) the release 2) alone on what was kept 3) the masked log 4) one record 5) quit")
choice = plain(input("choice> "))
if choice == "1":
show_release()
elif choice == "2":
show_alone()
elif choice == "3":
show_log()
elif choice == "4":
show_record()
elif choice == "5":
running = False
else:
print("Pick a number from 1 to 5.")
print("Bye.")
anon.eml
eml# P168 data anonymizer - 200 made-up patient records released for a study
# with the names, emails and phone numbers removed. As in the corpus case
# the-numbers-were-anonymised-and-the-combination-was-unique, what was kept,
# postcode, birth date and sex, is worthless alone and together names one
# record nearly every time. And as in
# the-mask-removed-the-value-and-kept-the-length, a log that masks a name
# with one asterisk per letter publishes its length.
def surnames():
return ["Ng", "Li", "Wu", "Shah", "Khan", "Ross", "Hall", "Wood", "Patel", "Evans",
"Ahmed", "Moore", "Clark", "Lewis", "Hughes", "Turner", "Begum", "Foster", "Morgan", "Kowalski",
"Fernandez", "Oyelaran", "Bennett", "Robinson", "Thompson", "Lindqvist", "Fitzgerald", "Okonkwo", "Hargreaves", "Abernathy"]
def districts():
return ["LS1", "LS2", "LS6", "LS7", "LS8", "LS11"]
def cities():
return ["Leeds", "York", "Hull", "Bradford", "Wakefield"]
def step(x):
# the next number from a seed, 1 to 2147483646
return (x * 48271) % 2147483647
def records():
# [surname, email name length, district, sector, year, month, day, sex, city]
[0] * 200 => out
1019202711 => x
0 => k
while k < 200:
step(x) => x
surnames()[x % 30] => name
step(x) => x
len(name) + 2 + x % 3 => email
step(x) => x
districts()[x % 6] => d
step(x) => x
1 + x % 9 => sector
step(x) => x
1940 + x % 66 => year
step(x) => x
1 + x % 12 => month
step(x) => x
1 + x % 28 => day
step(x) => x
"F" => sex
if x % 2 == 1:
"M" => sex
step(x) => x
cities()[x % 5] => city
[name, email, d, sector, year, month, day, sex, city] => out[k]
k + 1 => k
return out
def two(n):
if n < 10:
return "0" + str(n)
return str(n)
def key(r, level):
# what a release keeps of a record, at a level of coarsening:
# 0 postcode, birth date, sex; 1 postcode, birth year, sex;
# 2 district, birth year, sex; 3 district, birth decade, sex
if level == 0:
return r[2] + " " + str(r[3]) + "|" + str(r[4]) + "-" + two(r[5]) + "-" + two(r[6]) + "|" + r[7]
if level == 1:
return r[2] + " " + str(r[3]) + "|" + str(r[4]) + "|" + r[7]
if level == 2:
return r[2] + "|" + str(r[4]) + "|" + r[7]
return r[2] + "|" + str(r[4] - r[4] % 10) + "s|" + r[7]
def log_key(r, mask):
# what a log line shows of a customer: per letter, each name's length
# and the city; fixed, eight asterisks and the city
if mask == "per letter":
return str(len(r[0])) + "|" + str(r[1]) + "|" + r[8]
return r[8]
def groups(keys):
# how many records share each key
{} => counts
for k in keys:
if k in counts:
counts[k] + 1 => counts[k]
else:
1 => counts[k]
return counts
def stats(keys):
# [records alone on their key, the smallest group, the number of groups]
groups(keys) => counts
0 => alone
0 => smallest
0 => n
for k in counts:
n + 1 => n
if counts[k] == 1:
alone + 1 => alone
if smallest == 0 or counts[k] < smallest:
counts[k] => smallest
return [alone, smallest, n]
Python projection (anon.py)
def surnames():
return ["Ng", "Li", "Wu", "Shah", "Khan", "Ross", "Hall", "Wood", "Patel", "Evans", "Ahmed", "Moore", "Clark", "Lewis", "Hughes", "Turner", "Begum", "Foster", "Morgan", "Kowalski", "Fernandez", "Oyelaran", "Bennett", "Robinson", "Thompson", "Lindqvist", "Fitzgerald", "Okonkwo", "Hargreaves", "Abernathy"]
def districts():
return ["LS1", "LS2", "LS6", "LS7", "LS8", "LS11"]
def cities():
return ["Leeds", "York", "Hull", "Bradford", "Wakefield"]
def step(x):
return x * 48271 % 2147483647
def records():
out = [0] * 200
x = 1019202711
k = 0
while k < 200:
x = step(x)
name = surnames()[x % 30]
x = step(x)
email = len(name) + 2 + x % 3
x = step(x)
d = districts()[x % 6]
x = step(x)
sector = 1 + x % 9
x = step(x)
year = 1940 + x % 66
x = step(x)
month = 1 + x % 12
x = step(x)
day = 1 + x % 28
x = step(x)
sex = "F"
if x % 2 == 1:
sex = "M"
x = step(x)
city = cities()[x % 5]
out[k] = [name, email, d, sector, year, month, day, sex, city]
k = k + 1
return out
def two(n):
if n < 10:
return "0" + str(n)
return str(n)
def key(r, level):
if level == 0:
return r[2] + " " + str(r[3]) + "|" + str(r[4]) + "-" + two(r[5]) + "-" + two(r[6]) + "|" + r[7]
if level == 1:
return r[2] + " " + str(r[3]) + "|" + str(r[4]) + "|" + r[7]
if level == 2:
return r[2] + "|" + str(r[4]) + "|" + r[7]
return r[2] + "|" + str(r[4] - r[4] % 10) + "s|" + r[7]
def log_key(r, mask):
if mask == "per letter":
return str(len(r[0])) + "|" + str(r[1]) + "|" + r[8]
return r[8]
def groups(keys):
counts = {}
for k in keys:
if k in counts:
counts[k] = counts[k] + 1
else:
counts[k] = 1
return counts
def stats(keys):
counts = groups(keys)
alone = 0
smallest = 0
n = 0
for k in counts:
n = n + 1
if counts[k] == 1:
alone = alone + 1
if smallest == 0 or counts[k] < smallest:
smallest = counts[k]
return [alone, smallest, n]