Reproducing Ocagli et al. (2024) from Figshare item 26877211¶
Paper. Ocagli H, Lanera C, Franzoi M, Monachesi C, Zgheib R, Belluco S, Dorne JLCM, Gregori D, Baldi I (2024). Unveiling insights from the Joint FAO/WHO Expert Committee on Food Additives (JECFA) portal. Scientific Data. doi:10.1038/s41597-024-04294-w. The claims file was written from the version of record in Europe PMC (PMC11682307, full-text XML), a Data Descriptor with no supplement.
Dataset. Figshare 26877211,
version 1, doi:10.6084/m9.figshare.26877211.v1.
The paper cites the item without a version (10.6084/m9.figshare.26877211), which resolves to the
latest; the item has one published version, 1 (posted 2024-12-29), so that is the version pinned.
It holds two files, read here by streaming into memory: jecfa.rds (file id 48922360, MD5
5bfd89459ec77a152e5ee1f55e124f3d) and JECFA.csv (file id 48922363, MD5
dee04993aaa56ca1f3844ded5c472764). The MD5 of the bytes read is checked against the archive's
computed_md5 in the data check.
Code. Method details the claims file takes from the paper's code refer to
https://github.com/UBESP-DCTV/jecfa at commit 2d8a5340d5322cbae28eb3095d948313932f6c7e; the FAS
derivations in jecfa.py follow R/process_df.R and R/add_metadata.R there. Nothing here is
random, so no seed is needed.
| Claim | Tested |
|---|---|
| data-records-rows (link): 6552 rows with the fields of Table 1 | tested |
| unique-pages: 6549 unique pages after removing a duplicate and a test page | tested |
| table1-availability: share of pages with each field | tested |
| table2-evaluation-year: evaluations by year bin | tested |
| table2-functional-class: substances by functional class | tested |
| unknowns-name-only: unknowns have only a JECFA name | tested |
| fas-codes: FAS 40 to 52 (even) cover 50% of records | tested |
| validation-failures: discrepancies in the 700-page manual check | blocked (data_missing) |
| portal-search-count: portal count 6547 in line with pages with a JECFA name | tested (dataset's part) |
import time
import pandas as pd
import jecfa
pd.set_option("display.width", 220)
pd.set_option("display.max_columns", 30)
pd.set_option("display.max_rows", 200)
pd.set_option("display.max_colwidth", 90)
started = time.time()
data = jecfa.load()
df = data["rds"]
print(f"Figshare item {jecfa.ARTICLE_ID} version {jecfa.VERSION} ({jecfa.DOI}), "
f"read in {time.time() - started:.1f} s")
Figshare item 26877211 version 1 (10.6084/m9.figshare.26877211.v1), read in 6.8 s
Data check¶
Each check of the metadata against the values is stated under its output.
meta = data["meta"]
print("Title:", meta["title"])
print("Published:", meta["published_date"], " version:", meta["version"], " DOI:", meta["doi"])
print("Related:", [(r["relation"], r["identifier"]) for r in meta["related_materials"]])
print("\nFiles (name, id, size in bytes, MD5 of the bytes read, archive computed_md5, equal):")
for f in meta["files"]:
got, expected = data["md5"][f["name"]]
print(f" {f['name']:10s} {f['id']} {f['size']:>8d} {got} {expected} {got == expected}")
versions = pd.read_json(jecfa.API_URL.rsplit("/", 1)[0])
print("\nPublished versions of the item:", versions["version"].tolist())
print("\nThe item's description, which documents each field:\n")
print(meta["description"])
Title: Unveiling Insights from the Joint FAO/WHO Expert Committee on Food Additives (JECFA) Portal
Published: 2024-12-29T08:01:38Z version: 1 DOI: 10.6084/m9.figshare.26877211.v1
Related: [('IsSupplementTo', '10.1038/s41597-024-04294-w')]
Files (name, id, size in bytes, MD5 of the bytes read, archive computed_md5, equal):
jecfa.rds 48922360 360432 5bfd89459ec77a152e5ee1f55e124f3d 5bfd89459ec77a152e5ee1f55e124f3d True
JECFA.csv 48922363 3484607 dee04993aaa56ca1f3844ded5c472764 dee04993aaa56ca1f3844ded5c472764 True
Published versions of the item: [1] The item's description, which documents each field: This database is a result of web scraping techniques applied to the Joint FAO/WHO Expert Committee on Food Additives (JECFA) database. It contains detailed toxicological and evaluation information on various chemicals assessed by JECFA. Below is a description of each field in the database: Report: Identifier for the report number and session in which the chemical was evaluated. Report_sourcelink: URL link to the full text of the report. Tox.Monograph: Identifier for the toxicological monograph related to the chemical. Tox.Monograph_sourcelink: URL link to the toxicological monograph. Specification: Identifier for the specification document related to the chemical. Specification_sourcelink: URL link to the specification document. Synonyms: Alternative names for the chemical. CAS.number: The Chemical Abstracts Service (CAS) registry number, a unique numerical identifier for the chemical. Functional.Class: The functional classification of the chemical (e.g., Flavouring Agent, Food Contaminant). Evaluation.year: The year in which the chemical was evaluated by JECFA. Chemical.Names: The chemical names as listed in the JECFA evaluation. JECFA.number: The unique identifier assigned to the chemical by JECFA. COE.number: The Council of Europe (COE) number for the chemical, if applicable. FEMA.number: The Flavor and Extract Manufacturers Association (FEMA) number, if applicable. JECFA_name: The name used by JECFA for the chemical. URL: URL link to the JECFA database entry for the chemical. FAS: FAO/WHO Food Additive Specification identifier.
The item has one published version, and the bytes read match the archive's MD5 for both files.
The description documents the 17 fields. It calls FAS a "FAO/WHO Food Additive Specification
identifier"; the paper (Table 1) and the code (R/process_df.R) make it the number of the Food
Additives Series monograph, taken from the Tox monograph. The check below shows which the data
hold.
csv = data["csv"]
print("jecfa.rds:", df.shape, " JECFA.csv:", csv.shape)
print("Same column names in the same order:", list(df.columns) == list(csv.columns))
same = (df.fillna("<NA>").to_numpy() == csv.fillna("<NA>").to_numpy())
print("Cells that differ between the two files:", int((~same).sum()))
print("\nColumns of jecfa.rds, next to the field of Table 1 and the name in R/process_df.R:")
cols = pd.DataFrame({"table1_field": list(jecfa.TABLE1_FIELDS),
"deposit_column": list(jecfa.TABLE1_FIELDS.values())})
code_names = {c.lower().replace(".", "_"): c for c in jecfa.CODE_COLUMNS}
cols["process_df_name"] = cols.deposit_column.map(code_names)
cols["in_deposit"] = cols.deposit_column.isin(df.columns)
print(cols.to_string(index=False))
print("Deposit columns not in Table 1:", sorted(set(df.columns) - set(cols.deposit_column)))
jecfa.rds: (6552, 17) JECFA.csv: (6552, 17)
Same column names in the same order: True
Cells that differ between the two files: 0
Columns of jecfa.rds, next to the field of Table 1 and the name in R/process_df.R:
table1_field deposit_column process_df_name in_deposit
URL url URL True
JECFA name jecfa_name JECFA_name True
Chemical names chemical_names Chemical.Names True
Synonyms synonyms Synonyms True
CAS number cas_number CAS.number True
JECFA number jecfa_number JECFA.number True
FEMA number fema_number FEMA.number True
COE number coe_number COE.number True
Functional class functional_class Functional.Class True
Evaluation year evaluation_year Evaluation.year True
Report report Report True
Report sourcelink report_sourcelink Report_sourcelink True
Tox monograph tox_monograph Tox.Monograph True
Tox monograph sourcelink tox_monograph_sourcelink Tox.Monograph_sourcelink True
Specification specification Specification True
Specification sourcelink specification_sourcelink Specification_sourcelink True
FAS fas FAS True
Deposit columns not in Table 1: []
The two files hold the same table: the CSV is a copy of the RDS, cell for cell. The deposit's
column names are those of R/process_df.R in lower case with "." replaced by "_", and its columns
are in the order that function selects them. The columns that R/add_metadata.R adds (type, host,
Tox_monograph_abbr, Report_clean, ref_id) are not in the deposit, so the deposited table is the
output of process_df.R.
present = df.apply(jecfa.present)
summary = pd.DataFrame({
"NA": df.isna().sum(),
"empty_string": (df.notna() & (df.fillna("").apply(lambda s: s.str.strip()) == "")).sum(),
"present": present.sum(),
"distinct": df.nunique(),
"example": [df[c].dropna().iloc[0][:60] for c in df.columns],
})
print(summary.to_string())
NA empty_string present distinct example report 156 0 6396 664 TRS 776-JECFA 33/22 report_sourcelink 156 0 6396 231 http://apps.who.int/iris/bitstream/10665/39252/1/WHO_TRS_776 tox_monograph 468 0 6084 625 FAS 24-JECFA 33/97 tox_monograph_sourcelink 468 0 6084 583 http://www.inchem.org/documents/jecfa/jecmono/v024je06.htm specification 536 0 6016 429 FAO Combined Compendium of Food Additive Specifications specification_sourcelink 536 0 6016 71 https://www.fao.org/food/food-safety-quality/scientific-advi synonyms 1422 0 5130 1907 Karaya; Gum sterculia; Sterculia; Kadaya; Katilo; Kullo; Kut cas_number 4235 0 2317 1295 9000-36-6 functional_class 110 0 6442 215 Food AdditivesEMULSIFIERSTABILIZERTHICKENER evaluation_year 118 0 6434 62 1988 chemical_names 1142 0 5410 2467 1-(4-Methylphenyl)ethanol jecfa_number 1913 0 4639 2285 805 coe_number 3521 0 3031 1255 10197 fema_number 2294 0 4258 1939 3139 jecfa_name 0 0 6552 6551 KARAYA GUM url 0 0 6552 6552 https://apps.who.int/food-additives-contaminants-jecfa-datab fas 468 752 5332 82 24
Every column is text. Missing values are NA, except in fas, which also holds 752 empty strings.
jecfa_name and url are present in every row.
pid = jecfa.page_id(df)
print("URLs of the form", jecfa.URL_PREFIX + "<n>:", int(pid.notna().sum()), "of", len(df))
print("Page numbers from", int(pid.min()), "to", int(pid.max()), "; duplicated URLs:", int(df.url.duplicated().sum()),
"; rows sorted by page number:", bool(pid.is_monotonic_increasing))
missing_ids = sorted(set(range(1, int(pid.max()) + 1)) - set(pid.astype(int)))
print(f"Page numbers between 1 and {int(pid.max())} with no row: {len(missing_ids)}:", missing_ids)
year = pd.to_numeric(df.evaluation_year, errors="coerce")
print("\nevaluation_year: all present values are integers:", bool(year[df.evaluation_year.notna()].notna().all()),
"; range", int(year.min()), "to", int(year.max()))
for c in ["jecfa_number", "fema_number", "coe_number"]:
s = df[c].dropna()
odd = s[~s.str.fullmatch(r"\d+")]
print(f"{c}: {len(s)} present, {len(odd)} not a plain integer, e.g. {odd.head(4).tolist()}")
print(f" of these {len(odd)}, {int(odd.str.strip().str.fullmatch(r'\d+').sum())} are plain integers once leading "
f"and trailing spaces are removed")
cas = df.cas_number.dropna()
plain = cas[cas.str.fullmatch(r"\d{2,7}-\d{2}-\d")]
print(f"cas_number: {len(cas)} present, {len(plain)} of the plain form NNNNNNN-NN-N, of which "
f"{int(plain.map(jecfa.cas_check_digit_ok).sum())} have a valid check digit; "
f"{len(cas) - len(plain)} carry more than one number or a note, e.g. {cas[~cas.isin(plain)].head(3).tolist()}")
print("Plain CAS numbers with a wrong check digit:", plain[~plain.map(jecfa.cas_check_digit_ok)].tolist())
print("\nValues starting with the characters '\">' (Usage Notes): report",
int(df.report.str.startswith('">').sum()), ", tox_monograph", int(df.tox_monograph.str.startswith('">').sum()),
", specification", int(df.specification.str.startswith('">').sum()))
URLs of the form https://apps.who.int/food-additives-contaminants-jecfa-database/Home/Chemical/<n>: 6552 of 6552 Page numbers from 1 to 6593 ; duplicated URLs: 0 ; rows sorted by page number: True Page numbers between 1 and 6593 with no row: 41: [1798, 4905, 5959, 5960, 6003, 6183, 6202, 6207, 6211, 6217, 6222, 6229, 6286, 6294, 6295, 6302, 6303, 6304, 6305, 6309, 6317, 6318, 6320, 6321, 6322, 6323, 6324, 6325, 6327, 6328, 6362, 6381, 6382, 6397, 6459, 6464, 6527, 6528, 6530, 6548, 6553] evaluation_year: all present values are integers: True ; range 1958 to 2023 jecfa_number: 4639 present, 31 not a plain integer, e.g. ['506a,b', '506a,b', '632.1', '631.1'] of these 31, 1 are plain integers once leading and trailing spaces are removed fema_number: 4258 present, 33 not a plain integer, e.g. ['4126 ', '4161 ', '4209 ', '3957 '] of these 33, 33 are plain integers once leading and trailing spaces are removed coe_number: 3031 present, 1 not a plain integer, e.g. ['2983-38-2'] of these 1, 0 are plain integers once leading and trailing spaces are removed cas_number: 2317 present, 2127 of the plain form NNNNNNN-NN-N, of which 2081 have a valid check digit; 190 carry more than one number or a note, e.g. ['38966-29-9; 3387-37-9 (1:1 SALT)', "860-22-0 (5,5' ISOMER)", '13270-61-6 (Delphinidin)'] Plain CAS numbers with a wrong check digit: ['22389-47-0', '76-36-3', '5284-04-9', '2353-43-9', '7783-54-0', '7783-54-0', '2412-24-1', '5284-04-9', '61597-96-6', '2353-43-9', '7783-54-0', '1229-55-9', '5284-04-9', '2353-43-9', '3943-58-3', '22389-47-0', '1229-55-9', '1229-55-9', '2412-24-1', '5284-04-9', '5284-04-9', '58-92-1', '5284-04-9', '6833-79-9', '5284-04-9', '45706-73-7', '5807-68-8', '76-36-3', '3943-58-3', '1005-86-2', '3943-58-3', '7796-47-2', '23696-28-2', '141-40-0', '76-36-3', '76-36-3', '76-36-3', '76-36-3', '76-36-3', '5284-04-9', '15514-30-0', '71437-64-0', '0079-19-5', '79797-16-1', '1440-67-0', '86366-64-3'] Values starting with the characters '">' (Usage Notes): report 602 , tox_monograph 349 , specification 398
The URLs are the portal pages of Table 1, one row per page, with page numbers from 1 to 6593, inside the 1 to 7000 of the Methods; 41 numbers in that range have no row. No URL is repeated, so the claims file's two units, U1 (every row) and U2 (one row per distinct URL), are the same here, and each result below holds for both. Years run from 1958 to 2023, as in Table 2. The identifiers are mostly plain numbers. A few plain CAS numbers fail the registry's check digit (printed above), and some report, Tox monograph and specification values start with the stray characters '">' that the Usage Notes describe.
fas_rederived = jecfa.fas_process_df(df.tox_monograph)
print("fas equals the derivation of R/process_df.R from tox_monograph in every row:",
bool((fas_rederived.fillna("<NA>") == df.fas.fillna("<NA>")).all()))
empty_fas = df.fas.fillna("x").str.strip() == ""
print("\nTox monographs of the rows with an empty fas, by the code's abbreviation (R/extract_jecfa_tox_abbr.R):")
print(df.tox_monograph[empty_fas].map(jecfa.tox_abbr).value_counts().to_string())
print("Of these, Tox monographs starting with '\">':", int(df.tox_monograph[empty_fas].str.startswith('">').sum()))
print("\nDistinct fas values:", sorted(df.fas.dropna().unique(), key=lambda s: (len(s), s)))
fas equals the derivation of R/process_df.R from tox_monograph in every row: True Tox monographs of the rows with an empty fas, by the code's abbreviation (R/extract_jecfa_tox_abbr.R): tox_monograph FAS 60 339 NOT PREPARED 273 NOT A FAS 120 FAS 74 10 FAS79 4 NOT PREPARED. See FAS 17 3 FAS 27 2 NOT PREPARED (SEE ENVIRONMENTAL HEALTH CRITERIA 140) 1 Of these, Tox monographs starting with '">': 349 Distinct fas values: ['', '1', '5', '6', '8', '10', '12', '13', '14', '15', '16', '17', '18', '19', '20', '21', '22', '23', '24', '25', '26', '27', '28', '29', '30', '31', '32', '33', '34', '35', '36', '37', '38', '39', '40', '41', '42', '43', '44', '45', '46', '47', '48', '49', '50', '51', '52', '53', '54', '55', '56', '57', '58', '59', '60', '62', '64', '65', '69', '70', '71', '72', '74', '78', '80', '82', '83', '86', '617', '24.65', '67.29', '68.33', '70.36', '70.39', '65 JECFA 74', '67 JECFA 76', '68 JECFA 77', '71 JECFA 80', '73 JECFA 82', '75 JECFA 84', '77 JECFA 86', '80 JECFA 89']
The fas column is the derivation of R/process_df.R from the Tox monograph, in every row. It is
the Food Additives Series number, not a specification identifier as the item's description says.
Of the 752 empty strings, 355 are FAS monographs that the "^FAS " pattern misses (339 FAS 60,
10 FAS 74, 2 FAS 27, and 4 written "FAS79"); 349 of the 752 Tox monographs start with '">', which
the pattern does not allow. 277 are
monographs marked "NOT PREPARED", and 120 are not a FAS (such as a TRS reference). Some values keep
the session ("77 JECFA 86") or a page ("67.29"), and one is "617".
import inspect
import re as _re
def r_function(path, name):
"""The definition of one R function in a file of the paper's code, from its name to the closing brace."""
text = jecfa.r_source(path)
start = text.index(f"{name} <- function")
end = _re.search(r"^}", text[start:], _re.M).end()
return text[start:start + end]
print(f"Paper's code at commit {jecfa.CODE_COMMIT}, next to the Python ports of jecfa.py\n")
for path, name, port in [("R/process_df.R", "process_df", jecfa.fas_process_df),
("R/extract_jecfa_tox_abbr.R", "extract_jecfa_tox_abbr", jecfa.tox_abbr),
("R/extract_jecfa_tox_abbr.R", "extract_initial_part", None),
("R/extract_jecfa_tox_abbr.R", "extract_initial_part_bis", None),
("R/extract_jecfa_tox_abbr.R", "extract_initial_part_ter", None),
("R/add_metadata.R", "add_metadata", jecfa.fas_add_metadata)]:
r = r_function(path, name)
if name == "process_df":
r = r[:r.index("dplyr::rename")] # the FAS step; the rest renames and selects columns
print(f"----- {path}: {name}\n{r}")
if port is not None:
print(f"----- jecfa.py: {port.__name__}\n{inspect.getsource(port)}")
Paper's code at commit 2d8a5340d5322cbae28eb3095d948313932f6c7e, next to the Python ports of jecfa.py
----- R/process_df.R: process_df
process_df <- function(df_clean) {
df_clean |>
dplyr::mutate(
FAS = dplyr::if_else(
stringr::str_detect(`Tox Monograph1`, "^FAS .*$"),
stringr::str_sub(`Tox Monograph1`, 5),
""
),
FAS = stringr::str_split(FAS, "/", simplify = TRUE)[, 1],
FAS = stringr::str_split(FAS, "-", simplify = TRUE)[, 1]
) |>
----- jecfa.py: fas_process_df
def fas_process_df(tox_monograph: pd.Series) -> pd.Series:
"""FAS as R/process_df.R derives it from the Tox monograph (NA stays NA)."""
def one(t):
if pd.isna(t):
return pd.NA
if not re.match(r"^FAS .*$", t):
return ""
return t[4:].split("/")[0].split("-")[0]
return tox_monograph.map(one).astype("string")
----- R/extract_jecfa_tox_abbr.R: extract_jecfa_tox_abbr
extract_jecfa_tox_abbr <- function(tox_monograph) {
aux <- tox_monograph |>
sapply(extract_initial_part) |>
sapply(extract_initial_part_bis) |>
sapply(extract_initial_part_ter)
aux <- ifelse(
substr(aux, 1, 3) == "See",
substr(aux, 5, nchar(aux)),
aux
)
aux <- ifelse(
substr(aux, 1, 2) == '">',
substr(aux, 3, nchar(aux)),
aux
)
aux <- ifelse(
substr(aux, 1, 3) != "FAS" &
substr(aux, 1, 12) != "NOT PREPARED",
"NOT A FAS",
aux
)
aux
}
----- jecfa.py: tox_abbr
def tox_abbr(t) -> object:
"""R/extract_jecfa_tox_abbr.R for one Tox monograph value."""
if pd.isna(t):
return pd.NA
a = t.split("-")[0].split("/")[0].split("JECFA")[0]
if a[:3] == "See":
a = a[4:]
if a[:2] == '">':
a = a[2:]
if a[:3] != "FAS" and a[:12] != "NOT PREPARED":
a = "NOT A FAS"
return a
----- R/extract_jecfa_tox_abbr.R: extract_initial_part
extract_initial_part <- function(input_string) {
unlist(strsplit(input_string, "-"))[1]
}
----- R/extract_jecfa_tox_abbr.R: extract_initial_part_bis
extract_initial_part_bis <- function(input_string) {
unlist(strsplit(input_string, "/"))[1]
}
----- R/extract_jecfa_tox_abbr.R: extract_initial_part_ter
extract_initial_part_ter <- function(input_string) {
unlist(strsplit(input_string, "JECFA"))[1]
}
----- R/add_metadata.R: add_metadata
add_metadata <- function(jecfa) {
jecfa |>
dplyr::mutate(
type = get_jecfa_type(Tox.Monograph_sourcelink),
host = get_jecfa_url_parts(Tox.Monograph_sourcelink)[, 2],
Tox_monograph_abbr = extract_jecfa_tox_abbr(Tox.Monograph),
Report_clean = extract_jecfa_report_clean(Report),
FAS = dplyr::case_when(
nchar(FAS) > 3 ~ substr(FAS, 1, 2),
FAS != "" ~ FAS,
# From here FAS == ""
substr(Tox_monograph_abbr, 1, 6) == "FAS 74" ~ "74",
substr(Tox_monograph_abbr, 1, 6) == "FAS 60" ~ "60",
substr(Tox_monograph_abbr, 1, 6) == "FAS 27" ~ "27",
TRUE ~ FAS
),
ref_id = dplyr::row_number()
)
}
----- jecfa.py: fas_add_metadata
def fas_add_metadata(fas: pd.Series, tox_monograph: pd.Series) -> pd.Series:
"""FAS after the case_when of R/add_metadata.R."""
def one(f, t):
if pd.isna(f):
return pd.NA
if len(f) > 3:
return f[:2]
if f != "":
return f
abbr = tox_abbr(t)
if not pd.isna(abbr):
for code in ("74", "60", "27"):
if abbr[:6] == f"FAS {code}":
return code
return f
return pd.Series([one(f, t) for f, t in zip(fas, tox_monograph)], index=fas.index,
dtype="string")
The three ports follow the R line by line. process_df keeps the text from the fifth character
(str_sub(x, 5), Python t[4:]) when the Tox monograph starts with "FAS ", else "", and cuts it at
the first "/" and then the first "-". extract_jecfa_tox_abbr takes the part before the first "-",
"/" and "JECFA", drops a leading "See " (substr(aux, 5, ...), Python a[4:]) and a leading '">',
and gives "NOT A FAS" unless the result starts with "FAS" or "NOT PREPARED". The case_when of
add_metadata is taken in its order: a value over three characters is cut to two, a non-empty value
is kept, an empty one is filled from the abbreviation. NA stays NA in each, as in R, where an NA
condition of if_else gives NA and of case_when falls through to TRUE ~ FAS. One difference is
that R's strsplit("") gives character(0), so an empty Tox monograph would become NA in R and ""
here; no Tox monograph in the deposit is an empty string (data check above), so it does not arise.
The check above the R source shows that the port of process_df gives the deposited fas in every row.
cls = jecfa.leading_class(df.functional_class)
print("Functional class strings, by leading class (the rest of the string is the sub-class):")
print(cls.value_counts(dropna=False).to_string())
print("Strings that start with none of the four class names:",
df.functional_class[cls.isna() & df.functional_class.notna()].tolist())
print("Spellings of the flavouring class:",
df.functional_class.str.extract(r"^(Flavou?ring Agent)")[0].value_counts().to_dict())
print("\nCOE number present, by class:")
print(pd.crosstab(cls.fillna("unknown"), present.coe_number).to_string())
print("\nFEMA number present, by class:")
print(pd.crosstab(cls.fillna("unknown"), present.fema_number).to_string())
Functional class strings, by leading class (the rest of the string is the sub-class):
leading_class
Flavoring Agent 4635
Food Additives 1453
Veterinary Drug 277
<NA> 110
Food Contaminant 77
Strings that start with none of the four class names: []
Spellings of the flavouring class: {'Flavouring Agent': 4635}
COE number present, by class:
coe_number False True
leading_class
Flavoring Agent 1613 3022
Food Additives 1444 9
Food Contaminant 77 0
Veterinary Drug 277 0
unknown 110 0
FEMA number present, by class:
fema_number False True
leading_class
Flavoring Agent 400 4235
Food Additives 1443 10
Food Contaminant 77 0
Veterinary Drug 277 0
unknown 97 13
Every functional class string starts with one of the four classes of Table 1; the data spell "Flavouring Agent", the paper "Flavoring Agent". Table 1 describes the COE number as assigned by the Codex Alimentarius Commission, the item's description as the Council of Europe number. COE numbers are present for 3022 flavouring agents and 9 food additives and no other class, which is consistent with the Council of Europe numbering of flavourings, not with Codex.
by_url = df.drop(columns="url")
print("Rows identical to another row in every field but the URL:", int(by_url.duplicated(keep=False).sum()))
dup_names = df.jecfa_name[df.jecfa_name.duplicated(keep=False)]
print("JECFA names used by more than one row:", dup_names.unique().tolist(), "at pages",
pid[dup_names.index].astype(int).tolist())
print("Rows named TEST:", pid[df.jecfa_name.str.strip().str.upper() == "TEST"].astype(int).tolist())
print("\nFields present in each of these rows:")
show = df.index[pid.isin([5883, 6316, 6319])]
print(present.loc[show].assign(page=pid[show].astype(int)).set_index("page").T.to_string())
print(f"\nData check done in {time.time() - started:.1f} s")
Rows identical to another row in every field but the URL: 0 JECFA names used by more than one row: ['(E)-N-[2-(1,3-BENZODIOXOL-5-YL)ETHYL]-3- (3,4-DIMETHOXYPHENYL)PROP-2-ENAMIDE'] at pages [6316, 6319] Rows named TEST: [5883] Fields present in each of these rows: page 5883 6316 6319 report False False True report_sourcelink False False True tox_monograph False False True tox_monograph_sourcelink False False True specification False False True specification_sourcelink False False True synonyms False False False cas_number False False True functional_class False False True evaluation_year False False True chemical_names False False True jecfa_number False False True coe_number False False False fema_number False False True jecfa_name True True True url True True True fas False False True Data check done in 8.3 s
No two rows are copies apart from their URL. The chemical of page 6316 has a second page, 6319: page 6316 carries only its name, page 6319 the full record. Page 5883 is named TEST and carries nothing else. These are the duplicate and the test page of the Methods, and both are in the deposit, which therefore has the 6552 scraped rows, not the 6549 cleaned pages.
The table has no time series, so there is no clock to check against a trial table. Each row is described by its URL, which ties it to one portal page; the portal has changed since May 2024, so the rows cannot be checked against the pages as they were scraped.
data-records-rows (link)¶
The deposited dataframe is the JECFA index of May 2024, with 6552 rows and the fields of Table 1.
Criterion: 6552 rows within 3, and a column for each of the 17 fields of Table 1 (by the code's
names in process_df.R, or an evident equivalent); JECFA.csv compared with jecfa.rds.
n_rows = len(df)
fields_found = cols.in_deposit.sum()
print(f"Rows of jecfa.rds: {n_rows} (expected 6552, tolerance 3; within: {abs(n_rows - 6552) <= 3})")
print(f"Table 1 fields with a column: {fields_found} of {len(cols)}; missing: {cols.table1_field[~cols.in_deposit].tolist()}")
print(f"JECFA.csv: {csv.shape[0]} rows and {csv.shape[1]} columns; cells that differ from jecfa.rds: {int((~same).sum())}")
Rows of jecfa.rds: 6552 (expected 6552, tolerance 3; within: True) Table 1 fields with a column: 17 of 17; missing: [] JECFA.csv: 6552 rows and 17 columns; cells that differ from jecfa.rds: 0
Result: reproduced. jecfa.rds has 6552 rows, and all 17 fields of Table 1 have a column, named
as in process_df.R in lower case. JECFA.csv holds the same 6552 rows and 17 columns with no
differing cell.
unique-pages¶
The 6552 scraped entries, cleaned of the duplicates of page 6316 and of test pages, give 6549 unique pages.
Criterion: under one of three readings of the cleaning, 6549 within 2, and page 6316's chemical appears more than once. (D1) rows with distinct URL; (D2) rows with distinct JECFA name, less the row of page 5883; (D3) the rows less page 5883 and less all but one row with the JECFA name of page 6316.
name_6316 = df.jecfa_name[pid == 6316].iloc[0]
d1 = df.url.nunique()
d2 = df.loc[pid != 5883, "jecfa_name"].nunique()
d3 = len(df) - int((pid == 5883).sum()) - (int((df.jecfa_name == name_6316).sum()) - 1)
readings = pd.DataFrame({"reading": ["D1", "D2", "D3"], "unique_pages": [d1, d2, d3]})
readings["within_2_of_6549"] = (readings.unique_pages - 6549).abs() <= 2
print(readings.to_string(index=False))
print(f"Rows with page 6316's chemical ({name_6316}): {int((df.jecfa_name == name_6316).sum())}, "
f"at pages {pid[df.jecfa_name == name_6316].astype(int).tolist()}")
print("Variant, removing both rows of that chemical and page 5883:",
len(df) - int((pid == 5883).sum()) - int((df.jecfa_name == name_6316).sum()))
reading unique_pages within_2_of_6549
D1 6552 False
D2 6550 True
D3 6550 True
Rows with page 6316's chemical ((E)-N-[2-(1,3-BENZODIOXOL-5-YL)ETHYL]-3- (3,4-DIMETHOXYPHENYL)PROP-2-ENAMIDE): 2, at pages [6316, 6319]
Variant, removing both rows of that chemical and page 5883: 6549
Result: reproduced. Readings D2 and D3 give 6550 unique pages, one more than the paper's 6549 and within the tolerance of 2; D1 gives 6552, as no URL repeats. Page 6316's chemical appears twice, at pages 6316 and 6319. The paper's 6549 is reached, for example, by removing both rows of that chemical with the test page (the one variant tested).
table1-availability¶
The share of pages carrying each Table 1 field is 100% for URL and JECFA name, 82% chemical names, 79% synonyms, 35% CAS number, 71% JECFA number, 65% FEMA number, 46% COE number, 98% functional class, 98% evaluation year, 97% report and its link, 93% Tox monograph and its link, 92% specification and its link, and 93% FAS.
Criterion: the share of rows where each field is present (reading E), under U1 or U2 (the same
here); at least 15 of 17 within tolerance under one reading for reproduced, 10 to 14 for partially
reproduced. FAS is the column the code derives (process_df.R, add_metadata.R). The deposit
holds the process_df.R column; both are run: (A) the deposited fas, (B) fas after the step of
add_metadata.R. Denominators 6552 rows and the 6550 rows left after cleaning (reading D2 of
unique-pages) are both shown.
expected = pd.Series({"URL": 100, "JECFA name": 100, "Chemical names": 82, "Synonyms": 79, "CAS number": 35,
"JECFA number": 71, "FEMA number": 65, "COE number": 46, "Functional class": 98,
"Evaluation year": 98, "Report": 97, "Report sourcelink": 97, "Tox monograph": 93,
"Tox monograph sourcelink": 93, "Specification": 92, "Specification sourcelink": 92,
"FAS": 93}, dtype=float)
fas_b = jecfa.fas_add_metadata(df.fas, df.tox_monograph)
cleaned = (pid != 5883) & ~((df.jecfa_name == name_6316) & df.jecfa_name.duplicated(keep="last"))
table = pd.DataFrame({"expected": expected, "tolerance": expected * 0.10})
for label, rows in [("all_6552", pd.Series(True, index=df.index)), ("cleaned_6550", cleaned)]:
pa = present.loc[rows].mean() * 100
table[f"A_{label}"] = [pa[jecfa.TABLE1_FIELDS[f]] for f in table.index]
table[f"B_{label}"] = table[f"A_{label}"]
table.loc["FAS", f"B_{label}"] = jecfa.present(fas_b[rows]).mean() * 100
table = table.round(2)
print(f"Rows used: all {len(df)}; cleaned {int(cleaned.sum())}")
print(table.to_string())
for c in [c for c in table.columns if c[:2] in ("A_", "B_")]:
within = ((table[c] - table.expected).abs() <= table.tolerance + 1e-9)
exact = (table[c].round(0) == table.expected)
print(f"{c}: within tolerance {int(within.sum())} of 17 (outside: {table.index[~within].tolist()}); "
f"equal to Table 1 when rounded to whole numbers {int(exact.sum())} of 17")
print("\nVariant: FAS counted as present wherever it is not NA (the 752 empty strings counted):",
round(df.fas.notna().mean() * 100, 2), "%")
Rows used: all 6552; cleaned 6550
expected tolerance A_all_6552 B_all_6552 A_cleaned_6550 B_cleaned_6550
URL 100.0 10.0 100.00 100.00 100.00 100.00
JECFA name 100.0 10.0 100.00 100.00 100.00 100.00
Chemical names 82.0 8.2 82.57 82.57 82.60 82.60
Synonyms 79.0 7.9 78.30 78.30 78.32 78.32
CAS number 35.0 3.5 35.36 35.36 35.37 35.37
JECFA number 71.0 7.1 70.80 70.80 70.82 70.82
FEMA number 65.0 6.5 64.99 64.99 65.01 65.01
COE number 46.0 4.6 46.26 46.26 46.27 46.27
Functional class 98.0 9.8 98.32 98.32 98.35 98.35
Evaluation year 98.0 9.8 98.20 98.20 98.23 98.23
Report 97.0 9.7 97.62 97.62 97.65 97.65
Report sourcelink 97.0 9.7 97.62 97.62 97.65 97.65
Tox monograph 93.0 9.3 92.86 92.86 92.89 92.89
Tox monograph sourcelink 93.0 9.3 92.86 92.86 92.89 92.89
Specification 92.0 9.2 91.82 91.82 91.85 91.85
Specification sourcelink 92.0 9.2 91.82 91.82 91.85 91.85
FAS 93.0 9.3 81.38 86.74 81.40 86.76
A_all_6552: within tolerance 16 of 17 (outside: ['FAS']); equal to Table 1 when rounded to whole numbers 12 of 17
B_all_6552: within tolerance 17 of 17 (outside: []); equal to Table 1 when rounded to whole numbers 12 of 17
A_cleaned_6550: within tolerance 16 of 17 (outside: ['FAS']); equal to Table 1 when rounded to whole numbers 12 of 17
B_cleaned_6550: within tolerance 17 of 17 (outside: []); equal to Table 1 when rounded to whole numbers 12 of 17
Variant: FAS counted as present wherever it is not NA (the 752 empty strings counted): 92.86 %
Result: reproduced. Over all 6552 rows, 16 of 17 shares are within tolerance under reading A
(the deposited FAS column, 81.38% against 93%) and all 17 under reading B (FAS after
add_metadata.R, 86.74%); removing the duplicate and the test page changes no share by more than
0.05 points. Rounded to whole numbers, 12 of 17 shares equal Table 1. Under reading A the FAS
share is 11.6 points below Table 1; counting the 752 empty strings as present gives 92.86%, which
suggests that Table 1 counted every non-NA value.
The claims file's reading E counts the empty strings as absent, and under it FAS is the one field
outside tolerance under reading A, while under reading B no field is outside; the outcome rests
on 16 of 17 under A, or 17 under B, and does not depend on
how the empty strings are counted. The non-NA share of fas equals that of the Tox monograph
(92.86%), so the paper's 93% is likely the share of rows with any Tox monograph value.
table2-evaluation-year¶
By evaluation year, 2357 evaluations fall in 1958 to 2000 (37%), 3099 in 2000 to 2010 (48%), 778 in 2010 to 2020 (12%) and 200 in 2020 to 2023 (3.1%), with 118 unknown.
Criterion: the evaluation_year column, Table 2's bins (closed left, open right, the last
closed), percentages over rows with a known year, U1 or U2; all nine values within tolerance.
bins = jecfa.year_bin(df.evaluation_year)
counts = bins.value_counts().reindex(jecfa.YEAR_BINS)
known = int(bins.notna().sum())
res = pd.DataFrame({"count": counts, "share_of_known_pct": (counts / known * 100).round(2),
"expected_count": [2357, 3099, 778, 200], "expected_pct": [37, 48, 12, 3.1]})
res["count_tolerance"] = [236, 310, 78, 20] # the claims file's tolerances
res["count_within"] = (res["count"] - res.expected_count).abs() <= res.count_tolerance
res["pct_within"] = (res.share_of_known_pct - res.expected_pct).abs() <= res.expected_pct * 0.1 + 1e-9
print(res.to_string())
unknown_y = int(df.evaluation_year.isna().sum())
print(f"Unknown year: {unknown_y} (expected 118, tolerance 12; within: {abs(unknown_y - 118) <= 12}); known {known}; "
f"years outside 1958 to 2023: {int((bins.isna() & df.evaluation_year.notna()).sum())}")
print("Shares over all 6552 rows:", (counts / len(df) * 100).round(2).tolist())
count share_of_known_pct expected_count expected_pct count_tolerance count_within pct_within [1958-2000) 2357 36.63 2357 37.0 236 True True [2000-2010) 3099 48.17 3099 48.0 310 True True [2010-2020) 778 12.09 778 12.0 78 True True [2020-2023] 200 3.11 200 3.1 20 True True Unknown year: 118 (expected 118, tolerance 12; within: True); known 6434; years outside 1958 to 2023: 0 Shares over all 6552 rows: [35.97, 47.3, 11.87, 3.05]
Result: reproduced. The four bins hold 2357, 3099, 778 and 200 rows and 118 years are missing, equal to Table 2 and so within the claims file's tolerances (236, 310, 78, 20 and 12); the shares of known years are 36.63, 48.17, 12.09 and 3.11%, all within tolerance.
table2-functional-class¶
Most substances are flavouring agents, 4635 (72%), followed by food additives 1453 (23%), veterinary drugs 277 (4.3%) and food contaminants 77 (1.2%), with 110 unknown.
Criterion: the leading class name of functional_class, case-insensitive, empty values unknown;
percentages over rows with a known class; all nine values within tolerance and flavouring agents
the largest class.
cc = cls.value_counts().reindex(jecfa.CLASSES)
known_c = int(cls.notna().sum())
res = pd.DataFrame({"count": cc, "share_of_known_pct": (cc / known_c * 100).round(3),
"expected_count": [4635, 1453, 77, 277], "expected_pct": [72, 23, 1.2, 4.3]})
res["count_tolerance"] = [464, 145, 7.7, 28] # the claims file's tolerances
res["count_within"] = (res["count"] - res.expected_count).abs() <= res.count_tolerance
res["pct_within"] = (res.share_of_known_pct - res.expected_pct).abs() <= res.expected_pct * 0.1 + 1e-9
print(res.to_string())
unknown_c = int(cls.isna().sum())
print(f"Unknown class: {unknown_c} (expected 110, tolerance 11; within: {abs(unknown_c - 110) <= 11}); known {known_c}; "
f"strings matching none of the four names: {int((cls.isna() & df.functional_class.notna()).sum())}")
print("Largest class:", cc.idxmax())
count share_of_known_pct expected_count expected_pct count_tolerance count_within pct_within leading_class Flavoring Agent 4635 71.95 4635 72.0 464.0 True True Food Additives 1453 22.555 1453 23.0 145.0 True True Food Contaminant 77 1.195 77 1.2 7.7 True True Veterinary Drug 277 4.3 277 4.3 28.0 True True Unknown class: 110 (expected 110, tolerance 11; within: True); known 6442; strings matching none of the four names: 0 Largest class: Flavoring Agent
Result: reproduced. 4635 flavouring agents, 1453 food additives, 77 food contaminants, 277 veterinary drugs and 110 unknown, equal to Table 2 and so within the claims file's tolerances; the shares of known classes are 71.95, 22.56, 1.20 and 4.30%. Flavouring agents are the largest class, and every string starts with one of the four class names.
unknowns-name-only¶
The records with unknown class or year have only the JECFA name, without chemical names or other information.
Criterion: among the rows with unknown functional class, and among those with unknown evaluation year, the share with no Table 1 field present besides URL and JECFA name; reproduced when it is at least 90% of each set.
others = [c for c in df.columns if c not in ("url", "jecfa_name")]
name_only = ~present[others].any(axis=1)
for label, mask in [("unknown functional class", cls.isna()), ("unknown evaluation year", df.evaluation_year.isna())]:
n, k = int(mask.sum()), int((mask & name_only).sum())
print(f"{label}: {n} rows, {k} with only URL and JECFA name ({k / n * 100:.1f}%)")
print(" fields present in the other", n - k, "rows:",
{str(c): int(v) for c, v in present.loc[mask & ~name_only, others].sum().items() if v > 0})
rest = df.index[mask & ~name_only]
print(" their pages:", pid[rest].astype(int).tolist())
print(" their evaluation years:", {("NA" if pd.isna(y) else int(y)): int(v) for y, v in year[rest].value_counts(dropna=False).sort_index().items()})
print(" their functional classes:", {("NA" if pd.isna(c) else c): int(v) for c, v in cls[rest].value_counts(dropna=False).items()})
print("\nUnknown class and unknown year together:", int((cls.isna() & df.evaluation_year.isna()).sum()),
"; rows with only URL and JECFA name in the whole table:", int(name_only.sum()))
print("Pages of the rows with only URL and JECFA name, from", int(pid[name_only].min()), "to", int(pid[name_only].max()))
unknown functional class: 110 rows, 95 with only URL and JECFA name (86.4%)
fields present in the other 15 rows: {'report': 15, 'report_sourcelink': 15, 'tox_monograph': 14, 'tox_monograph_sourcelink': 14, 'specification': 13, 'specification_sourcelink': 13, 'synonyms': 1, 'cas_number': 15, 'evaluation_year': 15, 'chemical_names': 14, 'jecfa_number': 15, 'fema_number': 13, 'fas': 1}
their pages: [6025, 6385, 6392, 6417, 6426, 6439, 6440, 6441, 6442, 6445, 6446, 6447, 6448, 6450, 6554]
their evaluation years: {2008: 13, 2010: 1, 2023: 1}
their functional classes: {'NA': 15}
unknown evaluation year: 118 rows, 95 with only URL and JECFA name (80.5%)
fields present in the other 23 rows: {'synonyms': 14, 'cas_number': 6, 'functional_class': 23, 'chemical_names': 15, 'jecfa_number': 1, 'fema_number': 1}
their pages: [699, 864, 1233, 1630, 1797, 1861, 2691, 2692, 2693, 2739, 2851, 5575, 6476, 6496, 6497, 6498, 6499, 6500, 6501, 6502, 6503, 6506, 6533]
their evaluation years: {'NA': 23}
their functional classes: {'Food Additives': 13, 'Food Contaminant': 9, 'Flavoring Agent': 1}
Unknown class and unknown year together: 95 ; rows with only URL and JECFA name in the whole table: 95
Pages of the rows with only URL and JECFA name, from 219 to 6316
Result: not reproduced. 95 rows carry only a URL and a JECFA name, and all of them have both an unknown class and an unknown year. They are 86.4% of the 110 rows with an unknown class and 80.5% of the 118 with an unknown year, below the 90% of the criterion, which states only when the claim is reproduced. The paper's sentence holds exactly for the 95 rows unknown on both.
Most of the rows that are not name-only are among the last pages. Of the other 15 rows with an unknown class, 13 lie between pages 6385 and 6450, and 13 were evaluated in 2008 (one each in 2010 and 2023); all 15 carry a year, a report and identifiers (15 have a JECFA number, 13 a FEMA number). Of the other 23 rows with an unknown year, 11 are pages 6476 to 6533; all 23 carry a functional class (13 food additives, 9 food contaminants, 1 flavouring agent), 15 of them chemical names and 14 synonyms. The 95 name-only rows lie between pages 219 and 6316.
fas-codes¶
Almost all 85 FAS codes are referenced, and FAS 40, 42, 44, 46, 48, 50 and 52 together account for 50% of the records.
Criterion: FAS codes from the column the code derives; the share of the seven named codes over
(F1) all rows or (F2) rows with a FAS code, within 5 points of 50%; the seven named codes the seven
most frequent for reproduced, partially reproduced when the share holds and they are not. "Almost
all 85" is at least 77 distinct non-empty codes. As for table1-availability, the deposited column
(A) and the column after add_metadata.R (B) are both run.
named = ["40", "42", "44", "46", "48", "50", "52"]
for label, fas in [("A (deposited fas)", df.fas), ("B (after add_metadata.R)", fas_b)]:
has = jecfa.present(fas)
vc = fas[has].value_counts()
in_named = int(fas[has].isin(named).sum())
f1, f2 = in_named / len(df) * 100, in_named / int(has.sum()) * 100
top7 = vc.index[:7].tolist()
print(f"Reading {label}: {int(has.sum())} rows with a FAS code, {vc.size} distinct codes (at least 77: {vc.size >= 77})")
print(f" rows in the seven named codes: {in_named}; F1 {f1:.2f}% (within 45 to 55: {abs(f1 - 50) <= 5}), "
f"F2 {f2:.2f}% (within: {abs(f2 - 50) <= 5})")
print(f" seven most frequent: {top7}; the named seven: {set(top7) == set(named)}; "
f"ranks of the named codes: {[int(vc.index.get_loc(c)) + 1 for c in named]}")
ranked = pd.DataFrame({"code": vc.index[:12], "rows": vc.values[:12],
"cum_share_F2_pct": (vc.cumsum() / has.sum() * 100).round(1).values[:12],
"cum_share_F1_pct": (vc.cumsum() / len(df) * 100).round(1).values[:12]})
print(ranked.to_string(index=False))
integer_codes = sorted({int(c) for c in vc.index if c.isdigit() and 1 <= int(c) <= 85})
print(f" distinct values that are whole numbers from 1 to 85: {len(integer_codes)} (at least 77: "
f"{len(integer_codes) >= 77}); missing: {sorted(set(range(1, 86)) - set(integer_codes))}")
print(f" other values: {sorted(c for c in vc.index if not (c.isdigit() and 1 <= int(c) <= 85))}\n")
Reading A (deposited fas): 5332 rows with a FAS code, 81 distinct codes (at least 77: True)
rows in the seven named codes: 3036; F1 46.34% (within 45 to 55: True), F2 56.94% (within: False)
seven most frequent: ['48', '44', '50', '46', '42', '40', '59']; the named seven: False; ranks of the named codes: [6, 5, 2, 4, 1, 3, 9]
code rows cum_share_F2_pct cum_share_F1_pct
48 607 11.4 9.3
44 490 20.6 16.7
50 477 29.5 24.0
46 407 37.2 30.2
42 399 44.6 36.3
40 370 51.6 42.0
59 351 58.2 47.3
54 287 63.5 51.7
52 286 68.9 56.1
5 172 72.1 58.7
77 JECFA 86 131 74.6 60.7
17 122 76.9 62.6
distinct values that are whole numbers from 1 to 85: 66 (at least 77: False); missing: [2, 3, 4, 7, 9, 11, 61, 63, 66, 67, 68, 73, 75, 76, 77, 79, 81, 84, 85]
other values: ['24.65', '617', '65 JECFA 74', '67 JECFA 76', '67.29', '68 JECFA 77', '68.33', '70.36', '70.39', '71 JECFA 80', '73 JECFA 82', '75 JECFA 84', '77 JECFA 86', '80 JECFA 89', '86']
Reading B (after add_metadata.R): 5683 rows with a FAS code, 73 distinct codes (at least 77: False)
rows in the seven named codes: 3036; F1 46.34% (within 45 to 55: True), F2 53.42% (within: True)
seven most frequent: ['48', '44', '50', '46', '42', '40', '60']; the named seven: False; ranks of the named codes: [6, 5, 2, 4, 1, 3, 10]
code rows cum_share_F2_pct cum_share_F1_pct
48 607 10.7 9.3
44 490 19.3 16.7
50 477 27.7 24.0
46 407 34.9 30.2
42 399 41.9 36.3
40 370 48.4 42.0
60 353 54.6 47.4
59 351 60.8 52.7
54 287 65.8 57.1
52 286 70.9 61.5
5 172 73.9 64.1
67 133 76.2 66.1
distinct values that are whole numbers from 1 to 85: 71 (at least 77: False); missing: [2, 3, 4, 7, 9, 11, 61, 63, 66, 76, 79, 81, 84, 85]
other values: ['617', '86']
Result: partially reproduced. The seven named codes hold 3036 rows, 46.34% of all rows (F1, within 5 points of 50%) under both readings, and 56.94% (A) or 53.42% (B) of the rows with a FAS code (F2). They are not the seven most frequent: FAS 59 (351 rows) and FAS 54 (287) are more frequent than FAS 52 (286), and under reading B so is FAS 60 (353). FAS 52 ranks 9th under A and 10th under B.
"Almost all 85" does not hold once the values are read as codes. The criterion counts distinct non-empty values: 81 under A, which meets the 77, and 73 under B, which does not. But the 81 of A include malformed values (eight of the form "77 JECFA 86", five decimals such as "67.29", "617") and "86", above 85. Counting only whole numbers from 1 to 85 gives 66 codes under A and 71 under B, both below 77; codes 2, 3, 4, 7, 9, 11, 61, 63, 66, 76, 79, 81, 84 and 85 are absent under both. The criterion's count under A is therefore no support for this part of the statement. It does not change the outcome, which the criterion sets by the share and the ranking.
validation-failures¶
In a manual check of 700 random pages, scraped and manually read values disagreed only for Report (0.6%), Report sourcelink (0.9%), Tox monograph sourcelink (5.3%) and Specification sourcelink (0.2%).
Blocked, data_missing. The test needs the manual extraction of the 700 sampled pages, entered in
REDCap at the University of Padova. It is not in the Figshare item (whose two files are the
scraped table, checked above), nor in the paper's GitHub repository at the commit read, and the
paper gives no other place for it. The live portal has changed since May 2024, so the extraction
cannot be repeated on the pages as they were.
portal-search-count¶
A search of the JECFA portal by first character returned 6547 items, in line with the pages carrying a JECFA name (6551 less a duplicate and a test page).
Criterion: the portal count (6547) is outside data that is not public in its May 2024 state and is untestable. For the dataset's part: the rows with a JECFA name present, 6551 within 655, and after the cleaning of unique-pages within 2 of 6547.
with_name = int(present.jecfa_name.sum())
print(f"Rows with a JECFA name present: {with_name} (expected 6551, tolerance 655; within: {abs(with_name - 6551) <= 655})")
for r, v in zip(readings.reading, readings.unique_pages):
print(f"After cleaning, reading {r}: {v}; difference from 6547: {v - 6547} (within 2: {abs(v - 6547) <= 2})")
print("The paper's own numbers: 6551 - 2 = 6549, a difference of 2 from 6547")
print("\nRows with a JECFA name other than TEST:", int((present.jecfa_name & (df.jecfa_name.str.strip().str.upper() != "TEST")).sum()))
Rows with a JECFA name present: 6552 (expected 6551, tolerance 655; within: True) After cleaning, reading D1: 6552; difference from 6547: 5 (within 2: False) After cleaning, reading D2: 6550; difference from 6547: 3 (within 2: False) After cleaning, reading D3: 6550; difference from 6547: 3 (within 2: False) The paper's own numbers: 6551 - 2 = 6549, a difference of 2 from 6547 Rows with a JECFA name other than TEST: 6551
Result: not reproduced (dataset's part; the portal part is untested). All 6552 rows carry a JECFA name, within tolerance of the paper's 6551, but one more. After the cleaning of unique-pages 6550 pages remain (D2, D3), 3 more than the portal's 6547, outside the margin of 2; D1 gives 6552. The portal count itself cannot be tested.
The data do not explain the one-row gap between 6552 and the paper's 6551. Leaving out the test page, whose JECFA name is "TEST", gives 6551, but the paper removes the test page again from 6551, so that reading counts it twice. Removing both rows of page 6316's chemical and the test page gives 6549, within 2 of 6547 (unique-pages, variant), but that is none of the claims file's readings D1 to D3 and is not used.
The criterion states only when the dataset's part is reproduced: the count with a JECFA name within tolerance of 6551, and, after cleaning, within 2 of 6547. The output above shows the first condition met (6552) and the second not met (6550 under D2 and D3, a difference of 3; 6552 under D1, a difference of 5). The dataset's part is therefore not reproduced, and the portal part cannot be tested. This reads the criterion as for unknowns-name-only, whose criterion is written the same way. The outcome was partially reproduced until verification round 3, which agreed with the change. The margin of 2 for "aligns" is the claims file's choice and puts the paper's own figures (6549 against 6547) at its edge, so the one extra row in the deposit is enough to fail the claim.
Summary¶
| Claim | Outcome |
|---|---|
| data-records-rows (link) | reproduced |
| unique-pages | reproduced |
| table1-availability | reproduced |
| table2-evaluation-year | reproduced |
| table2-functional-class | reproduced |
| unknowns-name-only | not reproduced |
| fas-codes | partially reproduced |
| validation-failures | blocked (data_missing) |
| portal-search-count | not reproduced (dataset's part only; the portal count is untested) |
A failure to reproduce here may be a failure of this attempt, not of the paper: see the reasoning and the differences from the paper's method.
How the analysis differs from the paper's. The paper publishes no code for its Table 1 percentages,
Table 2 or the FAS counts, so the measures follow the claims file's criteria. The deposit holds
the FAS column of process_df.R; the further step of add_metadata.R is run as a second reading.
The manual validation data are not public.
What was found about the dataset.
- The deposit holds the 6552 scraped rows, including the test page (5883) and the stub page of a chemical listed twice (6316, whose record is at 6319); the paper's 6549 unique pages are not what is deposited, and the paper's statement that each entry is a unique chemical does not hold for these two rows.
- JECFA.csv and jecfa.rds hold the same table.
- The item's description calls FAS a "Food Additive Specification identifier"; the column is the Food Additives Series number derived from the Tox monograph. It misses FAS monographs whose text starts with '">' (mostly FAS 60), holds 752 empty strings beside 468 NA, and some values keep a session or page ("77 JECFA 86", "67.29", "617").
- Table 1 describes the COE number as assigned by the Codex Alimentarius Commission, the item's description as the Council of Europe number; the numbers occur for flavouring agents almost only, consistent with the Council of Europe.
- A few plain CAS numbers fail the check digit, and values of report, Tox monograph and specification start with '">', as the Usage Notes say.