ahl-npm 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ahl_npm-1.0.0/.envrc +3 -0
- ahl_npm-1.0.0/.github/workflows/publish-pypi.yml +38 -0
- ahl_npm-1.0.0/.github/workflows/tests.yml +35 -0
- ahl_npm-1.0.0/.gitignore +25 -0
- ahl_npm-1.0.0/.pre-commit-config.yaml +32 -0
- ahl_npm-1.0.0/LICENSE +10 -0
- ahl_npm-1.0.0/PKG-INFO +127 -0
- ahl_npm-1.0.0/README.md +102 -0
- ahl_npm-1.0.0/ahl_npm/__init__.py +43 -0
- ahl_npm-1.0.0/ahl_npm/constants.py +19 -0
- ahl_npm-1.0.0/ahl_npm/frames.py +185 -0
- ahl_npm-1.0.0/ahl_npm/schemes/npm_2004.yaml +56 -0
- ahl_npm-1.0.0/ahl_npm/schemes/npm_2018.yaml +50 -0
- ahl_npm-1.0.0/ahl_npm/schemes.py +232 -0
- ahl_npm-1.0.0/ahl_npm/scoring.py +414 -0
- ahl_npm-1.0.0/ahl_npm/transforms.py +83 -0
- ahl_npm-1.0.0/pyproject.toml +112 -0
- ahl_npm-1.0.0/tests/test_frames.py +210 -0
- ahl_npm-1.0.0/tests/test_schemes.py +67 -0
- ahl_npm-1.0.0/tests/test_scoring.py +245 -0
- ahl_npm-1.0.0/tests/test_transforms.py +47 -0
- ahl_npm-1.0.0/uv.lock +2068 -0
ahl_npm-1.0.0/.envrc
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
# Publishes the package to PyPI when a GitHub release is published, building it from the release's tag, which must
|
|
4
|
+
# match the version (`v` + `__version__` in ahl_npm/__init__.py). Uses PyPI trusted publishing from the `pypi`
|
|
5
|
+
# environment, so no API token is needed. Failed runs can be re-run from the Actions tab, but PyPI never accepts the
|
|
6
|
+
# same version twice.
|
|
7
|
+
on:
|
|
8
|
+
release:
|
|
9
|
+
types: [published]
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
publish:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
environment:
|
|
15
|
+
name: pypi
|
|
16
|
+
url: https://pypi.org/project/ahl-npm/
|
|
17
|
+
permissions:
|
|
18
|
+
id-token: write # trusted publishing
|
|
19
|
+
contents: read
|
|
20
|
+
steps:
|
|
21
|
+
- uses: actions/checkout@v5
|
|
22
|
+
|
|
23
|
+
- name: Check the tag is the version
|
|
24
|
+
run: |
|
|
25
|
+
VERSION=$(sed -nE 's/^__version__ = "([^"]+)".*/\1/p' ahl_npm/__init__.py)
|
|
26
|
+
if [ "$GITHUB_REF_NAME" != "v$VERSION" ]; then
|
|
27
|
+
echo "::error::The tag $GITHUB_REF_NAME is not v + __version__ ($VERSION)."
|
|
28
|
+
exit 1
|
|
29
|
+
fi
|
|
30
|
+
|
|
31
|
+
- name: Install uv
|
|
32
|
+
uses: astral-sh/setup-uv@v6
|
|
33
|
+
|
|
34
|
+
- name: Build the package
|
|
35
|
+
run: uv build
|
|
36
|
+
|
|
37
|
+
- name: Publish to PyPI
|
|
38
|
+
run: uv publish --trusted-publishing always
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
name: Tests
|
|
2
|
+
|
|
3
|
+
# Runs the tests under each supported Python version, and with the oldest dependency versions allowed (the tox envs
|
|
4
|
+
# in pyproject.toml). Dependencies are resolved from PyPI each time, so the weekly run catches any new numpy or polars
|
|
5
|
+
# release that breaks ahl-npm.
|
|
6
|
+
on:
|
|
7
|
+
pull_request:
|
|
8
|
+
push:
|
|
9
|
+
branches: [dev, main]
|
|
10
|
+
schedule:
|
|
11
|
+
- cron: "0 6 * * 1"
|
|
12
|
+
workflow_dispatch:
|
|
13
|
+
|
|
14
|
+
permissions:
|
|
15
|
+
contents: read
|
|
16
|
+
|
|
17
|
+
jobs:
|
|
18
|
+
tox:
|
|
19
|
+
name: tox -e ${{ matrix.env }}
|
|
20
|
+
runs-on: ubuntu-latest
|
|
21
|
+
strategy:
|
|
22
|
+
fail-fast: false
|
|
23
|
+
matrix:
|
|
24
|
+
env: [py310, py311, py312, py313, py314, py315, lowest]
|
|
25
|
+
steps:
|
|
26
|
+
- uses: actions/checkout@v5
|
|
27
|
+
|
|
28
|
+
- name: Install uv
|
|
29
|
+
uses: astral-sh/setup-uv@v6
|
|
30
|
+
|
|
31
|
+
- name: Install tox
|
|
32
|
+
run: uv tool install tox --with tox-uv
|
|
33
|
+
|
|
34
|
+
- name: Run the tests
|
|
35
|
+
run: tox run -e ${{ matrix.env }}
|
ahl_npm-1.0.0/.gitignore
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
|
|
5
|
+
# Builds
|
|
6
|
+
build/
|
|
7
|
+
dist/
|
|
8
|
+
*.egg-info/
|
|
9
|
+
|
|
10
|
+
# Environments and tools
|
|
11
|
+
.venv/
|
|
12
|
+
.tox/
|
|
13
|
+
.pytest_cache/
|
|
14
|
+
.ruff_cache/
|
|
15
|
+
.coverage
|
|
16
|
+
htmlcov/
|
|
17
|
+
.ipynb_checkpoints/
|
|
18
|
+
|
|
19
|
+
# Local settings
|
|
20
|
+
.env
|
|
21
|
+
|
|
22
|
+
# Editors and OS
|
|
23
|
+
.idea/
|
|
24
|
+
.vscode/
|
|
25
|
+
.DS_Store
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
repos:
|
|
2
|
+
- repo: https://gitlab.com/vojko.pribudic.foss/pre-commit-update
|
|
3
|
+
rev: v0.10.0
|
|
4
|
+
hooks:
|
|
5
|
+
- id: pre-commit-update
|
|
6
|
+
- repo: https://github.com/pre-commit/pre-commit-hooks
|
|
7
|
+
rev: v6.0.0
|
|
8
|
+
hooks:
|
|
9
|
+
- id: check-added-large-files
|
|
10
|
+
- id: check-merge-conflict
|
|
11
|
+
- id: end-of-file-fixer
|
|
12
|
+
- id: trailing-whitespace
|
|
13
|
+
- id: check-toml
|
|
14
|
+
- id: check-yaml
|
|
15
|
+
- id: no-commit-to-branch
|
|
16
|
+
args: ["-b", dev, "-b", main]
|
|
17
|
+
pass_filenames: false
|
|
18
|
+
- repo: https://github.com/nestauk/pre-commit-hooks
|
|
19
|
+
rev: v1.2.0
|
|
20
|
+
hooks:
|
|
21
|
+
- id: nbstripout-preserve-timestamp
|
|
22
|
+
- id: jupytext-enforce-pairing
|
|
23
|
+
- id: jupytext-smart-sync
|
|
24
|
+
- repo: https://github.com/astral-sh/ruff-pre-commit
|
|
25
|
+
rev: v0.17.3
|
|
26
|
+
hooks:
|
|
27
|
+
- id: ruff-format
|
|
28
|
+
- id: ruff-check
|
|
29
|
+
- repo: https://github.com/prettier/pre-commit
|
|
30
|
+
rev: v2.1.2
|
|
31
|
+
hooks:
|
|
32
|
+
- id: prettier
|
ahl_npm-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
|
|
2
|
+
The MIT License (MIT)
|
|
3
|
+
=====================
|
|
4
|
+
Copyright (c) 2026, Nesta
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
7
|
+
|
|
8
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
9
|
+
|
|
10
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
ahl_npm-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: ahl-npm
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Functions and configuration for calculating Nutrient Profiling Model (NPM) scores
|
|
5
|
+
Project-URL: repository, https://github.com/nestauk/ahl_npm
|
|
6
|
+
Project-URL: issues, https://github.com/nestauk/ahl_npm/issues
|
|
7
|
+
Author-email: Harry Wilde <harry.wilde@nesta.org.uk>
|
|
8
|
+
Maintainer-email: Harry Wilde <harry.wilde@nesta.org.uk>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: HFSS,NPM,numpy,nutrient profiling model,nutrition,polars
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Requires-Dist: numpy>=1.26
|
|
22
|
+
Requires-Dist: polars>=1.25
|
|
23
|
+
Requires-Dist: pyyaml>=6.0
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# AHL NPM Scoring
|
|
27
|
+
|
|
28
|
+
Two definitions of the UK's Nutrient Profiling Model, [NPM 2004/05](https://www.gov.uk/government/publications/the-nutrient-profiling-model) and [NPM 2018](https://www.gov.uk/government/publications/nutrient-profiling-model-2018/nutrient-profiling-model-2018-technical-guidance), scored on `numpy` arrays or `polars` frames.
|
|
29
|
+
|
|
30
|
+
## Installing
|
|
31
|
+
|
|
32
|
+
The package is installable from [PyPI](https://pypi.org/project/ahl-npm/) on Python 3.10+ with `pip`, `uv`, etc.:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
uv add ahl-npm
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Usage
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
import ahl_npm as npm
|
|
42
|
+
|
|
43
|
+
# numpy: numbers or arrays per 100g, which broadcast together (e.g. one panel against multiple possible free sugars values)
|
|
44
|
+
scored = npm.score(
|
|
45
|
+
scheme=2018, energy_kj=1500, satfat=4.5, free_sugars=[2.0, 6.5], salt=1.25, protein=10, fibre=3, fvns_pct=0
|
|
46
|
+
)
|
|
47
|
+
scored["npm"] # array([11., 15.]), with each component's points, the A and C points and whether protein counts
|
|
48
|
+
|
|
49
|
+
# polars: `columns` maps each input to a column or an expression; without it, the frame's columns named as inputs
|
|
50
|
+
scored = npm.score_frame(
|
|
51
|
+
frame=df,
|
|
52
|
+
scheme=2004,
|
|
53
|
+
columns={
|
|
54
|
+
"energy_kcal": "kcal_per_100g",
|
|
55
|
+
"satfat": "satfat_per_100g",
|
|
56
|
+
"sugars": "sugar_per_100g",
|
|
57
|
+
"sodium": "sodium_per_100g",
|
|
58
|
+
"protein": "protein_per_100g",
|
|
59
|
+
"fibre": "fibre_per_100g",
|
|
60
|
+
"fvn_pct": pl.col("fvn_share") * 100,
|
|
61
|
+
},
|
|
62
|
+
) # adds energy_points_2004, ..., a_points_2004, c_points_2004, protein_counted_2004, npm_2004, cnpm_2004
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`outputs` picks which columns to add and, when passed as a mapping, renames them (between `prefix` and `suffix`), e.g. `outputs={"npm": "npm_score", "cnpm": "cnpm_score"}` only adds `npm_score_2004` and `cnpm_score_2004`. Inputs a scheme doesn't use are ignored, so the same mapping can be used for both schemes.
|
|
66
|
+
|
|
67
|
+
`score_expr` returns the same scoring as a struct expression, and `band` and `band_expr` give the points for a single component, e.g. `npm.band(2018, "free_sugars", values)`.
|
|
68
|
+
|
|
69
|
+
### Inputs
|
|
70
|
+
|
|
71
|
+
All inputs are densities (g/100g) as sold. Where a component can be scored from more than one input, each row uses the first one available: the quantity the scheme scores, then a converted stand-in, then the component's points if given.
|
|
72
|
+
|
|
73
|
+
| Input | Unit | Scored by |
|
|
74
|
+
| -------------------------- | ---- | ------------------------------------------------------------------------------------- |
|
|
75
|
+
| `energy_kj`, `energy_kcal` | kJ | both, using kJ if given, else kcal x 4.184 |
|
|
76
|
+
| `satfat` | g | both |
|
|
77
|
+
| `sugars` | g | NPM 2004/05 (total sugars) |
|
|
78
|
+
| `free_sugars` | g | NPM 2018 |
|
|
79
|
+
| `sodium`, `salt` | g | NPM 2004/05 scores sodium (else salt / 2.5), NPM 2018 scores salt (else sodium x 2.5) |
|
|
80
|
+
| `protein` | g | both |
|
|
81
|
+
| `fibre` | g | both (AOAC method) |
|
|
82
|
+
| `fvn_pct` | % | NPM 2004/05 (fruit, vegetables and nuts) |
|
|
83
|
+
| `fvns_pct` | % | NPM 2018 (fruit, vegetables, nuts and seeds) |
|
|
84
|
+
| `<component>_points` | | both, for points already scored, as an alternative to the densities above |
|
|
85
|
+
|
|
86
|
+
Points inputs are named the same as the outputs, so a scoring's points can be passed straight back in, and are checked against the points the component can score under the scheme. NPM 2018 includes seeds, so it only uses `fvns_pct` or `fvns_points`: if you only have the NPM 2004/05 share, pass it as `fvns_pct` too. NPM 2018 also needs free sugars. Scoring total sugars as free sugars isn't supported, as it overestimates NPM 2018 scores, but can be done by passing total sugars as `free_sugars`.
|
|
87
|
+
|
|
88
|
+
Salt is sodium x 2.5, as UK labelling law defines it ([Regulation (EU) No 1169/2011, Annex I](https://www.legislation.gov.uk/eur/2011/1169/annex/I), as retained in UK law).
|
|
89
|
+
|
|
90
|
+
### Outputs
|
|
91
|
+
|
|
92
|
+
Each component's points (`<component>_points`), `a_points`, `c_points`, `protein_counted`, `npm` and `cnpm` (70 - 2 x `npm`). The numpy functions return floats with NaN where unknown (or integers with `strict=True`, which raises on missing inputs), and the polars functions return integers (a boolean for `protein_counted`) with nulls where unknown. Protein stops counting from 11 A points, unless fruit, vegetables and nuts score 5 points.
|
|
93
|
+
|
|
94
|
+
### Checks and warnings
|
|
95
|
+
|
|
96
|
+
The most likely mistake is an input in the wrong units, so each frame scoring logs (at INFO, to the `ahl_npm` logger) which column or expression each component reads, in what units, and with any conversion. `describe_inputs` gives the same description for any inputs:
|
|
97
|
+
|
|
98
|
+
```text
|
|
99
|
+
NPM 2004 reads, per 100 g:
|
|
100
|
+
energy: energy_kcal <- "kcal_per_100g", kcal x 4.184 to kJ
|
|
101
|
+
sodium: sodium <- "sodium_per_100g", g; else salt <- "salt_per_100g", g / 2.5 to sodium
|
|
102
|
+
fvn: fvn_pct <- [(col("fvn_share")) * (dyn int: 100)], %
|
|
103
|
+
...
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
- `ImpossibleInputWarning` warns of implausible density values, i.e. below 0, or above 100g, 100% or the energy of pure fat (3,700 kJ or 900 kcal), which usually means an input is in the wrong units (e.g. sodium in mg read as g).
|
|
107
|
+
- `MissingInputWarning` summarises missing inputs (NaN, or null in polars). A missing input scores NaN points rather than the maximum, so any score it feeds into is missing too.
|
|
108
|
+
- `warn=False` turns both warnings off. A frame only warns once, for the first batch of rows with problems.
|
|
109
|
+
|
|
110
|
+
### Other functions
|
|
111
|
+
|
|
112
|
+
- `to_cnpm` and `from_cnpm` convert between NPM and cNPM (70 - 2 x the score, s.t. higher is healthier) for numbers, arrays, and polars expressions or series. `clip=True` clips cNPM to between 0 and 100, and `change=True` converts changes in scores (without the offset).
|
|
113
|
+
- `less_healthy(scheme, npm, drink)` gives whether each product is less healthy, i.e. a food scoring 4 or more, or a drink scoring 1 or more. `drink` must be booleans with none missing (in polars, a null gives a null). Whether a product is in scope of HFSS legislation is a separate question.
|
|
114
|
+
- `score_range(scheme, low, high, **fixed)` gives the lowest and highest scores for inputs anywhere between their `low` and `high` values. The score never decreases as an A input increases or a C input decreases, so only two scorings are needed, however many inputs are uncertain.
|
|
115
|
+
|
|
116
|
+
## Development Setup
|
|
117
|
+
|
|
118
|
+
This project uses [`uv`](https://docs.astral.sh/uv/) and `direnv`. To set up a development environment, run:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
direnv allow
|
|
122
|
+
uv sync
|
|
123
|
+
uv run pre-commit install --install-hooks
|
|
124
|
+
uv run pytest
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
`uv run tox` runs the tests under each Python version from 3.10 to 3.15, and (`lowest`) with the oldest numpy, polars and PyYAML versions allowed. CI runs these on every pull request and push to `dev` or `main`, and weekly.
|
ahl_npm-1.0.0/README.md
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# AHL NPM Scoring
|
|
2
|
+
|
|
3
|
+
Two definitions of the UK's Nutrient Profiling Model, [NPM 2004/05](https://www.gov.uk/government/publications/the-nutrient-profiling-model) and [NPM 2018](https://www.gov.uk/government/publications/nutrient-profiling-model-2018/nutrient-profiling-model-2018-technical-guidance), scored on `numpy` arrays or `polars` frames.
|
|
4
|
+
|
|
5
|
+
## Installing
|
|
6
|
+
|
|
7
|
+
The package is installable from [PyPI](https://pypi.org/project/ahl-npm/) on Python 3.10+ with `pip`, `uv`, etc.:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
uv add ahl-npm
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
import ahl_npm as npm
|
|
17
|
+
|
|
18
|
+
# numpy: numbers or arrays per 100g, which broadcast together (e.g. one panel against multiple possible free sugars values)
|
|
19
|
+
scored = npm.score(
|
|
20
|
+
scheme=2018, energy_kj=1500, satfat=4.5, free_sugars=[2.0, 6.5], salt=1.25, protein=10, fibre=3, fvns_pct=0
|
|
21
|
+
)
|
|
22
|
+
scored["npm"] # array([11., 15.]), with each component's points, the A and C points and whether protein counts
|
|
23
|
+
|
|
24
|
+
# polars: `columns` maps each input to a column or an expression; without it, the frame's columns named as inputs
|
|
25
|
+
scored = npm.score_frame(
|
|
26
|
+
frame=df,
|
|
27
|
+
scheme=2004,
|
|
28
|
+
columns={
|
|
29
|
+
"energy_kcal": "kcal_per_100g",
|
|
30
|
+
"satfat": "satfat_per_100g",
|
|
31
|
+
"sugars": "sugar_per_100g",
|
|
32
|
+
"sodium": "sodium_per_100g",
|
|
33
|
+
"protein": "protein_per_100g",
|
|
34
|
+
"fibre": "fibre_per_100g",
|
|
35
|
+
"fvn_pct": pl.col("fvn_share") * 100,
|
|
36
|
+
},
|
|
37
|
+
) # adds energy_points_2004, ..., a_points_2004, c_points_2004, protein_counted_2004, npm_2004, cnpm_2004
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`outputs` picks which columns to add and, when passed as a mapping, renames them (between `prefix` and `suffix`), e.g. `outputs={"npm": "npm_score", "cnpm": "cnpm_score"}` only adds `npm_score_2004` and `cnpm_score_2004`. Inputs a scheme doesn't use are ignored, so the same mapping can be used for both schemes.
|
|
41
|
+
|
|
42
|
+
`score_expr` returns the same scoring as a struct expression, and `band` and `band_expr` give the points for a single component, e.g. `npm.band(2018, "free_sugars", values)`.
|
|
43
|
+
|
|
44
|
+
### Inputs
|
|
45
|
+
|
|
46
|
+
All inputs are densities (g/100g) as sold. Where a component can be scored from more than one input, each row uses the first one available: the quantity the scheme scores, then a converted stand-in, then the component's points if given.
|
|
47
|
+
|
|
48
|
+
| Input | Unit | Scored by |
|
|
49
|
+
| -------------------------- | ---- | ------------------------------------------------------------------------------------- |
|
|
50
|
+
| `energy_kj`, `energy_kcal` | kJ | both, using kJ if given, else kcal x 4.184 |
|
|
51
|
+
| `satfat` | g | both |
|
|
52
|
+
| `sugars` | g | NPM 2004/05 (total sugars) |
|
|
53
|
+
| `free_sugars` | g | NPM 2018 |
|
|
54
|
+
| `sodium`, `salt` | g | NPM 2004/05 scores sodium (else salt / 2.5), NPM 2018 scores salt (else sodium x 2.5) |
|
|
55
|
+
| `protein` | g | both |
|
|
56
|
+
| `fibre` | g | both (AOAC method) |
|
|
57
|
+
| `fvn_pct` | % | NPM 2004/05 (fruit, vegetables and nuts) |
|
|
58
|
+
| `fvns_pct` | % | NPM 2018 (fruit, vegetables, nuts and seeds) |
|
|
59
|
+
| `<component>_points` | | both, for points already scored, as an alternative to the densities above |
|
|
60
|
+
|
|
61
|
+
Points inputs are named the same as the outputs, so a scoring's points can be passed straight back in, and are checked against the points the component can score under the scheme. NPM 2018 includes seeds, so it only uses `fvns_pct` or `fvns_points`: if you only have the NPM 2004/05 share, pass it as `fvns_pct` too. NPM 2018 also needs free sugars. Scoring total sugars as free sugars isn't supported, as it overestimates NPM 2018 scores, but can be done by passing total sugars as `free_sugars`.
|
|
62
|
+
|
|
63
|
+
Salt is sodium x 2.5, as UK labelling law defines it ([Regulation (EU) No 1169/2011, Annex I](https://www.legislation.gov.uk/eur/2011/1169/annex/I), as retained in UK law).
|
|
64
|
+
|
|
65
|
+
### Outputs
|
|
66
|
+
|
|
67
|
+
Each component's points (`<component>_points`), `a_points`, `c_points`, `protein_counted`, `npm` and `cnpm` (70 - 2 x `npm`). The numpy functions return floats with NaN where unknown (or integers with `strict=True`, which raises on missing inputs), and the polars functions return integers (a boolean for `protein_counted`) with nulls where unknown. Protein stops counting from 11 A points, unless fruit, vegetables and nuts score 5 points.
|
|
68
|
+
|
|
69
|
+
### Checks and warnings
|
|
70
|
+
|
|
71
|
+
The most likely mistake is an input in the wrong units, so each frame scoring logs (at INFO, to the `ahl_npm` logger) which column or expression each component reads, in what units, and with any conversion. `describe_inputs` gives the same description for any inputs:
|
|
72
|
+
|
|
73
|
+
```text
|
|
74
|
+
NPM 2004 reads, per 100 g:
|
|
75
|
+
energy: energy_kcal <- "kcal_per_100g", kcal x 4.184 to kJ
|
|
76
|
+
sodium: sodium <- "sodium_per_100g", g; else salt <- "salt_per_100g", g / 2.5 to sodium
|
|
77
|
+
fvn: fvn_pct <- [(col("fvn_share")) * (dyn int: 100)], %
|
|
78
|
+
...
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
- `ImpossibleInputWarning` warns of implausible density values, i.e. below 0, or above 100g, 100% or the energy of pure fat (3,700 kJ or 900 kcal), which usually means an input is in the wrong units (e.g. sodium in mg read as g).
|
|
82
|
+
- `MissingInputWarning` summarises missing inputs (NaN, or null in polars). A missing input scores NaN points rather than the maximum, so any score it feeds into is missing too.
|
|
83
|
+
- `warn=False` turns both warnings off. A frame only warns once, for the first batch of rows with problems.
|
|
84
|
+
|
|
85
|
+
### Other functions
|
|
86
|
+
|
|
87
|
+
- `to_cnpm` and `from_cnpm` convert between NPM and cNPM (70 - 2 x the score, s.t. higher is healthier) for numbers, arrays, and polars expressions or series. `clip=True` clips cNPM to between 0 and 100, and `change=True` converts changes in scores (without the offset).
|
|
88
|
+
- `less_healthy(scheme, npm, drink)` gives whether each product is less healthy, i.e. a food scoring 4 or more, or a drink scoring 1 or more. `drink` must be booleans with none missing (in polars, a null gives a null). Whether a product is in scope of HFSS legislation is a separate question.
|
|
89
|
+
- `score_range(scheme, low, high, **fixed)` gives the lowest and highest scores for inputs anywhere between their `low` and `high` values. The score never decreases as an A input increases or a C input decreases, so only two scorings are needed, however many inputs are uncertain.
|
|
90
|
+
|
|
91
|
+
## Development Setup
|
|
92
|
+
|
|
93
|
+
This project uses [`uv`](https://docs.astral.sh/uv/) and `direnv`. To set up a development environment, run:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
direnv allow
|
|
97
|
+
uv sync
|
|
98
|
+
uv run pre-commit install --install-hooks
|
|
99
|
+
uv run pytest
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`uv run tox` runs the tests under each Python version from 3.10 to 3.15, and (`lowest`) with the oldest numpy, polars and PyYAML versions allowed. CI runs these on every pull request and push to `dev` or `main`, and weekly.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""ahl_npm: the UK's Nutrient Profiling Model (NPM 2004/05 and NPM 2018), scored on numpy arrays or polars frames."""
|
|
2
|
+
|
|
3
|
+
__version__ = "1.0.0"
|
|
4
|
+
|
|
5
|
+
from ahl_npm.constants import KJ_PER_KCAL, SALT_PER_SODIUM
|
|
6
|
+
from ahl_npm.frames import band_expr, score_expr, score_frame
|
|
7
|
+
from ahl_npm.schemes import COMPONENTS, SCHEMES, Component, Scheme, get_scheme, load_scheme
|
|
8
|
+
from ahl_npm.scoring import (
|
|
9
|
+
INPUTS,
|
|
10
|
+
ImpossibleInputWarning,
|
|
11
|
+
MissingInputWarning,
|
|
12
|
+
band,
|
|
13
|
+
describe_inputs,
|
|
14
|
+
output_names,
|
|
15
|
+
score,
|
|
16
|
+
score_range,
|
|
17
|
+
)
|
|
18
|
+
from ahl_npm.transforms import from_cnpm, less_healthy, to_cnpm
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"COMPONENTS",
|
|
22
|
+
"INPUTS",
|
|
23
|
+
"ImpossibleInputWarning",
|
|
24
|
+
"KJ_PER_KCAL",
|
|
25
|
+
"SALT_PER_SODIUM",
|
|
26
|
+
"SCHEMES",
|
|
27
|
+
"Component",
|
|
28
|
+
"MissingInputWarning",
|
|
29
|
+
"Scheme",
|
|
30
|
+
"band",
|
|
31
|
+
"band_expr",
|
|
32
|
+
"describe_inputs",
|
|
33
|
+
"from_cnpm",
|
|
34
|
+
"get_scheme",
|
|
35
|
+
"less_healthy",
|
|
36
|
+
"load_scheme",
|
|
37
|
+
"output_names",
|
|
38
|
+
"score",
|
|
39
|
+
"score_expr",
|
|
40
|
+
"score_frame",
|
|
41
|
+
"score_range",
|
|
42
|
+
"to_cnpm",
|
|
43
|
+
]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Constants used in NPM scoring that are not part of a scheme's thresholds."""
|
|
2
|
+
|
|
3
|
+
# Kilojoules per kilocalorie, used to convert energy given in kcal, as both schemes score energy in kJ.
|
|
4
|
+
KJ_PER_KCAL: float = 4.184
|
|
5
|
+
|
|
6
|
+
# Grams of salt per gram of sodium, as UK labelling law defines salt (salt = sodium x 2.5, Regulation (EU) No 1169/2011,
|
|
7
|
+
# Annex I, as retained in UK law).
|
|
8
|
+
SALT_PER_SODIUM: float = 2.5
|
|
9
|
+
|
|
10
|
+
# cNPM is 70 - 2 x the NPM score, s.t. higher is healthier. It runs from -10 to 100 under NPM 2004/05, and up to 110
|
|
11
|
+
# under NPM 2018. CNPM_BOUNDS are the limits used when clipping.
|
|
12
|
+
CNPM_OFFSET: float = 70
|
|
13
|
+
CNPM_SCALE: float = -2
|
|
14
|
+
CNPM_BOUNDS: tuple[float, float] = (0, 100)
|
|
15
|
+
|
|
16
|
+
# A value scores a threshold's point only above the threshold by more than BAND_ATOL + BAND_RTOL x the threshold,
|
|
17
|
+
# this prevents floating point error from causing a value to be scored at a different threshold.
|
|
18
|
+
BAND_ATOL: float = 1e-4
|
|
19
|
+
BAND_RTOL: float = 1e-5
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""NPM scores on polars frames and expressions.
|
|
2
|
+
|
|
3
|
+
The scoring reuses the numpy implementation `ahl_npm.scoring.score`, s.t. scoring a dataframe is consistent with
|
|
4
|
+
scoring an array of its rows, and scoring an expression is consistent with scoring a series of its values.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import threading
|
|
9
|
+
import warnings
|
|
10
|
+
from collections import Counter
|
|
11
|
+
from collections.abc import Mapping, Sequence
|
|
12
|
+
from typing import TypeVar
|
|
13
|
+
|
|
14
|
+
import polars as pl
|
|
15
|
+
|
|
16
|
+
from ahl_npm.schemes import Scheme, get_scheme
|
|
17
|
+
from ahl_npm.scoring import INPUTS, _check_inputs, _inputs_used, _score, band, describe_inputs, output_names
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger("ahl_npm")
|
|
20
|
+
|
|
21
|
+
FrameT = TypeVar("FrameT", pl.DataFrame, pl.LazyFrame)
|
|
22
|
+
|
|
23
|
+
# Temporary column holding a frame's scoring struct before its fields are split into columns.
|
|
24
|
+
_SCORING = "__ahl_npm_scoring"
|
|
25
|
+
|
|
26
|
+
# Recently logged input descriptions, s.t. each is only logged once however often a scoring is rebuilt (e.g. in a
|
|
27
|
+
# sweep or a dashboard). Only the latest `_LOGGED_MOST` are kept, so memory use stays bounded when inputs vary.
|
|
28
|
+
_LOGGED: dict[str, None] = {}
|
|
29
|
+
_LOGGED_MOST = 256
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _log_once(description: str) -> None:
|
|
33
|
+
if description in _LOGGED:
|
|
34
|
+
return
|
|
35
|
+
_LOGGED[description] = None
|
|
36
|
+
if len(_LOGGED) > _LOGGED_MOST:
|
|
37
|
+
del _LOGGED[next(iter(_LOGGED))]
|
|
38
|
+
logger.info(description)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _as_expr(column: str | pl.Expr) -> pl.Expr:
|
|
42
|
+
return pl.col(column) if isinstance(column, str) else column
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _output_dtypes(scheme: str | int | Scheme) -> dict[str, pl.DataType]:
|
|
46
|
+
"""Return the polars dtype of each of `output_names`: integers for points and scores, and a boolean flag."""
|
|
47
|
+
return {name: pl.Boolean if name == "protein_counted" else pl.Int64 for name in output_names(scheme)}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def score_expr(scheme: str | int | Scheme, columns: Mapping[str, str | pl.Expr], *, warn: bool = True) -> pl.Expr:
|
|
51
|
+
"""Return an expression for each row's NPM scoring, as a struct with a field per `output_names`, null if unknown.
|
|
52
|
+
|
|
53
|
+
Inputs the scheme doesn't use are ignored, so the same mapping can be used for both schemes. The expression only
|
|
54
|
+
warns once, with the counts from the first batch of rows with missing or implausible values, which is the whole
|
|
55
|
+
frame unless it is scored in batches (e.g. by a streaming collect).
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
scheme (str | int | Scheme): 2004, 2018 or a `Scheme`.
|
|
59
|
+
columns (Mapping[str, str | pl.Expr]): A column name or expression for each input, by `score`'s names.
|
|
60
|
+
warn (bool): Whether to warn, with a summary, of missing inputs and implausible density values.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
pl.Expr: The scoring as a struct, e.g. to `.struct.unnest()` or `.struct.field()`.
|
|
64
|
+
|
|
65
|
+
Raises:
|
|
66
|
+
TypeError: If an input is not one of `INPUTS`.
|
|
67
|
+
ValueError: If a component has none of the inputs it can be scored from.
|
|
68
|
+
"""
|
|
69
|
+
scheme = get_scheme(scheme)
|
|
70
|
+
_check_inputs(scheme, columns)
|
|
71
|
+
used = _inputs_used(scheme)
|
|
72
|
+
names = [name for name in INPUTS if name in columns and name in used]
|
|
73
|
+
_log_once(describe_inputs(scheme, {name: columns[name] for name in names}))
|
|
74
|
+
dtypes = _output_dtypes(scheme)
|
|
75
|
+
warned = not warn
|
|
76
|
+
claim = threading.Lock()
|
|
77
|
+
|
|
78
|
+
def score_batch(rows: pl.Series) -> pl.Series:
|
|
79
|
+
"""Score a batch of rows (a struct of the inputs) with numpy."""
|
|
80
|
+
nonlocal warned
|
|
81
|
+
inputs = rows.struct.unnest()
|
|
82
|
+
scored, problems = _score(scheme, {name: inputs[name].to_numpy() for name in names}, check=not warned)
|
|
83
|
+
if problems:
|
|
84
|
+
with claim: # A streaming collect scores batches concurrently, so only the first batch with problems warns
|
|
85
|
+
first, warned = not warned, True
|
|
86
|
+
if first:
|
|
87
|
+
for category, message in problems:
|
|
88
|
+
warnings.warn(message, category, stacklevel=2)
|
|
89
|
+
fields = [pl.Series(name, values, nan_to_null=True).cast(dtypes[name]) for name, values in scored.items()]
|
|
90
|
+
return pl.DataFrame(fields).to_struct(rows.name)
|
|
91
|
+
|
|
92
|
+
struct = pl.struct([_as_expr(columns[name]).cast(pl.Float64).alias(name) for name in names])
|
|
93
|
+
return struct.map_batches(score_batch, return_dtype=pl.Struct(dtypes), is_elementwise=True)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def score_frame(
|
|
97
|
+
frame: FrameT,
|
|
98
|
+
scheme: str | int | Scheme,
|
|
99
|
+
columns: Mapping[str, str | pl.Expr] | None = None,
|
|
100
|
+
*,
|
|
101
|
+
prefix: str = "",
|
|
102
|
+
suffix: str | None = None,
|
|
103
|
+
outputs: Sequence[str] | Mapping[str, str] | None = None,
|
|
104
|
+
warn: bool = True,
|
|
105
|
+
) -> FrameT:
|
|
106
|
+
"""Return `frame` with each row's NPM scoring under `scheme` added as columns.
|
|
107
|
+
|
|
108
|
+
The columns added are `outputs` (by default all of `output_names`, i.e. each component's points, `a_points`,
|
|
109
|
+
`c_points`, `protein_counted`, `npm` and `cnpm`), each named between `prefix` and `suffix`, e.g.
|
|
110
|
+
`energy_points_2018`, ..., `cnpm_2018` by default.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
frame (FrameT): The rows to score, as a `pl.DataFrame` or `pl.LazyFrame`.
|
|
114
|
+
scheme (str | int | Scheme): 2004, 2018 or a `Scheme`.
|
|
115
|
+
columns (Mapping[str, str | pl.Expr] | None): A column name or expression for each input, by `score`'s names.
|
|
116
|
+
If None, the frame's columns named as inputs are used.
|
|
117
|
+
prefix (str): Prefix for each new column's name.
|
|
118
|
+
suffix (str | None): Suffix for each new column's name, `_<scheme>` if None.
|
|
119
|
+
outputs (Sequence[str] | Mapping[str, str] | None): Which of `output_names` to add: a single name, a sequence
|
|
120
|
+
of names, or a mapping to new names (placed between `prefix` and `suffix`). None adds all of them.
|
|
121
|
+
warn (bool): Whether to warn, with a summary, of missing inputs and of implausible density values.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
FrameT: `frame` with the new columns, of the same type.
|
|
125
|
+
|
|
126
|
+
Raises:
|
|
127
|
+
ValueError: If an output is not one of `output_names`, two outputs would have the same name, a new column
|
|
128
|
+
would replace an input column, or the frame already has the temporary column the scoring uses.
|
|
129
|
+
"""
|
|
130
|
+
scheme = get_scheme(scheme)
|
|
131
|
+
suffix = f"_{scheme.name}" if suffix is None else suffix
|
|
132
|
+
present = frame.collect_schema().names()
|
|
133
|
+
if _SCORING in present:
|
|
134
|
+
raise ValueError(f"The frame already has a column called {_SCORING}, which the scoring uses internally")
|
|
135
|
+
if columns is None:
|
|
136
|
+
columns = {name: name for name in INPUTS if name in present}
|
|
137
|
+
if isinstance(outputs, str):
|
|
138
|
+
outputs = (outputs,)
|
|
139
|
+
known = output_names(scheme)
|
|
140
|
+
chosen = (
|
|
141
|
+
dict.fromkeys(known)
|
|
142
|
+
if outputs is None
|
|
143
|
+
else dict(outputs)
|
|
144
|
+
if isinstance(outputs, Mapping)
|
|
145
|
+
else dict.fromkeys(outputs)
|
|
146
|
+
)
|
|
147
|
+
unknown = sorted(set(chosen) - set(known))
|
|
148
|
+
if unknown:
|
|
149
|
+
raise ValueError(f"No NPM {scheme.name} outputs {unknown}: the outputs are {known}")
|
|
150
|
+
named = {output: f"{prefix}{name or output}{suffix}" for output, name in chosen.items()}
|
|
151
|
+
shared = sorted(name for name, count in Counter(named.values()).items() if count > 1)
|
|
152
|
+
if shared:
|
|
153
|
+
raise ValueError(f"Outputs would share the names {shared}")
|
|
154
|
+
|
|
155
|
+
used = {column for value in columns.values() for column in _as_expr(value).meta.root_names()}
|
|
156
|
+
clashes = sorted(used & set(named.values()))
|
|
157
|
+
if clashes:
|
|
158
|
+
raise ValueError(f"The scoring would replace input columns {clashes}: give a prefix, suffix or other outputs")
|
|
159
|
+
|
|
160
|
+
scoring = pl.col(_SCORING)
|
|
161
|
+
fields = [scoring.struct.field(output).alias(name) for output, name in named.items()]
|
|
162
|
+
return (
|
|
163
|
+
frame.with_columns(score_expr(scheme, columns, warn=warn).alias(_SCORING)).with_columns(fields).drop(_SCORING)
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def band_expr(scheme: str | int | Scheme, component: str, values: str | pl.Expr) -> pl.Expr:
|
|
168
|
+
"""Return an expression for the points one component gives each value per 100g, null where a value is missing.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
scheme (str | int | Scheme): 2004, 2018 or a `Scheme`.
|
|
172
|
+
component (str): The component, e.g. "free_sugars", with values in its main input's units (e.g. kJ for energy).
|
|
173
|
+
values (str | pl.Expr): The values per 100g, as a column name or an expression.
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
pl.Expr: The points as integers.
|
|
177
|
+
"""
|
|
178
|
+
scheme = get_scheme(scheme)
|
|
179
|
+
scheme.component(component) # Raise here, rather than at collect, if the scheme doesn't have the component
|
|
180
|
+
|
|
181
|
+
def band_batch(batch: pl.Series) -> pl.Series:
|
|
182
|
+
"""Band a batch of values with numpy."""
|
|
183
|
+
return pl.Series(batch.name, band(scheme, component, batch.to_numpy()), nan_to_null=True).cast(pl.Int64)
|
|
184
|
+
|
|
185
|
+
return _as_expr(values).cast(pl.Float64).map_batches(band_batch, return_dtype=pl.Int64, is_elementwise=True)
|