flickpick 0.0.2__py3-none-any.whl
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.
- flickpick/__init__.py +108 -0
- flickpick/__main__.py +15 -0
- flickpick/artifacts.py +264 -0
- flickpick/build.py +180 -0
- flickpick/data/examples/movie_ratings_various.csv +117 -0
- flickpick/data.py +129 -0
- flickpick/defaults.py +67 -0
- flickpick/ease.py +99 -0
- flickpick/embedding.py +82 -0
- flickpick/importers.py +188 -0
- flickpick/ratings.py +166 -0
- flickpick/science.py +351 -0
- flickpick/score.py +367 -0
- flickpick/tools.py +207 -0
- flickpick-0.0.2.dist-info/METADATA +75 -0
- flickpick-0.0.2.dist-info/RECORD +19 -0
- flickpick-0.0.2.dist-info/WHEEL +4 -0
- flickpick-0.0.2.dist-info/entry_points.txt +2 -0
- flickpick-0.0.2.dist-info/licenses/LICENSE +21 -0
flickpick/data.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Download (with a cache) and parse the MovieLens datasets the build uses.
|
|
2
|
+
|
|
3
|
+
Zips are cached under ``<data home>/raw/`` -- ``~/.local/share/flickpick/raw/`` unless
|
|
4
|
+
the ``FLICKPICK_DATA_HOME`` environment variable says otherwise -- through the ``graze``
|
|
5
|
+
URL cache when it is installed, else plain ``urllib``. CSVs are streamed out of the
|
|
6
|
+
zip with ``zipfile``, so the 900 MB ``ratings.csv`` of ml-32m is never held as bytes.
|
|
7
|
+
|
|
8
|
+
MovieLens licence: research and non-commercial use, and redistributed transformations
|
|
9
|
+
carry the same conditions (https://grouplens.org/datasets/movielens/).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
import urllib.request
|
|
15
|
+
import zipfile
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import pandas as pd
|
|
20
|
+
|
|
21
|
+
DATA_HOME_ENVVAR = "FLICKPICK_DATA_HOME"
|
|
22
|
+
DFLT_DATA_HOME = "~/.local/share/flickpick"
|
|
23
|
+
MOVIELENS_BASE_URL = "https://files.grouplens.org/datasets/movielens/"
|
|
24
|
+
MOVIELENS_HOME = "https://grouplens.org/datasets/movielens/"
|
|
25
|
+
MOVIELENS_LICENCE = "research, non-commercial, share-alike on transformations"
|
|
26
|
+
|
|
27
|
+
SAMPLE_DATASET = "ml-latest-small"
|
|
28
|
+
FULL_DATASET = "ml-32m"
|
|
29
|
+
DATASETS = {
|
|
30
|
+
name: f"{MOVIELENS_BASE_URL}{name}.zip" for name in (SAMPLE_DATASET, FULL_DATASET)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
_DTYPES = {
|
|
34
|
+
"ratings": {
|
|
35
|
+
"userId": "int32",
|
|
36
|
+
"movieId": "int32",
|
|
37
|
+
"rating": "float32",
|
|
38
|
+
"timestamp": "int64",
|
|
39
|
+
},
|
|
40
|
+
"movies": {"movieId": "int32", "title": "string", "genres": "string"},
|
|
41
|
+
"links": {"movieId": "int32", "imdbId": "Int64", "tmdbId": "Int64"},
|
|
42
|
+
"tags": {
|
|
43
|
+
"userId": "int32",
|
|
44
|
+
"movieId": "int32",
|
|
45
|
+
"tag": "string",
|
|
46
|
+
"timestamp": "int64",
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def data_home() -> Path:
|
|
52
|
+
"""The package's data folder (``$FLICKPICK_DATA_HOME`` or ``~/.local/share/flickpick``)."""
|
|
53
|
+
return Path(os.environ.get(DATA_HOME_ENVVAR, DFLT_DATA_HOME)).expanduser()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def raw_dir() -> Path:
|
|
57
|
+
"""Where downloaded source archives are cached."""
|
|
58
|
+
return data_home() / "raw"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _download(url: str, path: Path) -> Path:
|
|
62
|
+
try:
|
|
63
|
+
import graze
|
|
64
|
+
except ImportError:
|
|
65
|
+
graze = None
|
|
66
|
+
print(f"Downloading {url} -> {path}", file=sys.stderr)
|
|
67
|
+
if graze is not None:
|
|
68
|
+
return Path(graze.graze(url, cache_key=str(path), return_key=True))
|
|
69
|
+
tmp = path.with_suffix(path.suffix + ".part")
|
|
70
|
+
urllib.request.urlretrieve(url, tmp)
|
|
71
|
+
tmp.replace(path)
|
|
72
|
+
return path
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def fetch_dataset(name: str = SAMPLE_DATASET, *, cache_dir=None) -> Path:
|
|
76
|
+
"""Path to the cached zip of MovieLens dataset ``name``, downloading it if needed."""
|
|
77
|
+
if name not in DATASETS:
|
|
78
|
+
raise ValueError(f"Unknown dataset {name!r}; choose one of {sorted(DATASETS)}")
|
|
79
|
+
folder = Path(cache_dir).expanduser() if cache_dir else raw_dir()
|
|
80
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
path = folder / f"{name}.zip"
|
|
82
|
+
if path.is_file() and zipfile.is_zipfile(path):
|
|
83
|
+
return path
|
|
84
|
+
return _download(DATASETS[name], path)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class MovieLens:
|
|
89
|
+
"""The four MovieLens tables used by the build, as DataFrames."""
|
|
90
|
+
|
|
91
|
+
name: str
|
|
92
|
+
ratings: pd.DataFrame
|
|
93
|
+
movies: pd.DataFrame
|
|
94
|
+
links: pd.DataFrame
|
|
95
|
+
tags: pd.DataFrame
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _read_member(zf: zipfile.ZipFile, table: str) -> pd.DataFrame:
|
|
99
|
+
members = [
|
|
100
|
+
n for n in zf.namelist() if n.endswith(f"/{table}.csv") or n == f"{table}.csv"
|
|
101
|
+
]
|
|
102
|
+
if not members:
|
|
103
|
+
raise FileNotFoundError(f"{table}.csv not found in {zf.filename}")
|
|
104
|
+
dtypes = _DTYPES[table]
|
|
105
|
+
with zf.open(members[0]) as f:
|
|
106
|
+
return pd.read_csv(f, dtype=dtypes, usecols=list(dtypes))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def read_movielens(zip_path, *, name: str | None = None) -> MovieLens:
|
|
110
|
+
"""Parse ratings, movies, links and tags out of a MovieLens zip."""
|
|
111
|
+
zip_path = Path(zip_path)
|
|
112
|
+
with zipfile.ZipFile(zip_path) as zf:
|
|
113
|
+
tables = {t: _read_member(zf, t) for t in _DTYPES}
|
|
114
|
+
return MovieLens(name=name or zip_path.stem, **tables)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def load_movielens(name: str = SAMPLE_DATASET, *, cache_dir=None) -> MovieLens:
|
|
118
|
+
"""Fetch (cached) and parse MovieLens dataset ``name``."""
|
|
119
|
+
return read_movielens(fetch_dataset(name, cache_dir=cache_dir), name=name)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def movielens_source(name: str) -> dict:
|
|
123
|
+
"""The manifest ``sources`` entry for a MovieLens dataset."""
|
|
124
|
+
return {
|
|
125
|
+
"name": f"MovieLens {name}",
|
|
126
|
+
"version": name,
|
|
127
|
+
"licence": MOVIELENS_LICENCE,
|
|
128
|
+
"url": MOVIELENS_HOME,
|
|
129
|
+
}
|
flickpick/defaults.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Named defaults shared by the build pipeline, the scorer and the CLI.
|
|
2
|
+
|
|
3
|
+
One frozen dataclass, ``DFLT``, is the single source of truth for every number the
|
|
4
|
+
core contract (``docs/core-contract.md``) and the artifact format
|
|
5
|
+
(``docs/artifact-format.md``) fix. Functions take these as keyword-only defaults, so a
|
|
6
|
+
caller can override any of them without touching this module.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
#: The canonical ratings scale is 0-100; these convert from each source's scale.
|
|
12
|
+
STARS_TO_100 = 20 # Letterboxd and MovieLens: 0.5-5 stars
|
|
13
|
+
TEN_TO_100 = 10 # IMDb: 1-10
|
|
14
|
+
SCALE_MIN, SCALE_MAX = 0, 100 # bounds every imported score must lie in
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class Defaults:
|
|
19
|
+
"""Every tunable number in one place (see the docs cited on each field)."""
|
|
20
|
+
|
|
21
|
+
# Collaborative filtering (EASE, Steck 2019)
|
|
22
|
+
#: L2 regularisation; the recsys report's probe found 200, 500 and 1000 tie on
|
|
23
|
+
#: NDCG@100 for MovieLens 32M (docs/research/recsys-methods-libraries-evaluation.md §3)
|
|
24
|
+
ease_lambda: float = 500.0
|
|
25
|
+
#: kept neighbours per item row (98% of dense NDCG@100 in the same probe)
|
|
26
|
+
topk: int = 100
|
|
27
|
+
#: number of most-rated items in the CF artifact (they carry ~97% of ratings)
|
|
28
|
+
n_items: int = 10_000
|
|
29
|
+
#: a population rating counts as a "like" at or above this, on the 0-100 scale
|
|
30
|
+
#: (3.5 stars)
|
|
31
|
+
like_threshold: float = 70.0
|
|
32
|
+
|
|
33
|
+
# The user's liked set (core contract: "Defaults that are not seams")
|
|
34
|
+
#: with fewer ratings than this, the fixed like_threshold replaces the median
|
|
35
|
+
min_ratings_for_median: int = 5
|
|
36
|
+
|
|
37
|
+
# Fusion
|
|
38
|
+
#: how each component is normalised over the candidate set before the weighted
|
|
39
|
+
#: sum (core contract: "Defaults that are not seams"); the only value is
|
|
40
|
+
#: "percentile_rank" (average ranks scaled to [0, 1])
|
|
41
|
+
normalisation: str = "percentile_rank"
|
|
42
|
+
w_cf: float = 1.0
|
|
43
|
+
w_semantic: float = 1.0
|
|
44
|
+
w_popularity: float = 0.1
|
|
45
|
+
k: int = 10
|
|
46
|
+
#: number of liked items named in "because you liked ..."
|
|
47
|
+
n_because: int = 3
|
|
48
|
+
|
|
49
|
+
# Embeddings
|
|
50
|
+
embedding_model: str = "BAAI/bge-small-en-v1.5"
|
|
51
|
+
embedding_dim: int = 384
|
|
52
|
+
query_prefix: str = ""
|
|
53
|
+
embed_batch_size: int = 64
|
|
54
|
+
#: cap on the distinct user tags appended to semantic_text (most frequent first)
|
|
55
|
+
max_tags: int = 50
|
|
56
|
+
|
|
57
|
+
# Evaluation
|
|
58
|
+
folds: int = 5
|
|
59
|
+
seed: int = 0
|
|
60
|
+
n_bootstrap: int = 1000
|
|
61
|
+
ci_level: float = 0.95
|
|
62
|
+
#: hit_rate is a per-fold quantity; below this many folds its bootstrap interval
|
|
63
|
+
#: (over folds) is too coarse to report (core contract, Science)
|
|
64
|
+
min_folds_for_fold_ci: int = 10
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
DFLT = Defaults()
|
flickpick/ease.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""EASE (Steck 2019): a closed-form linear item-item model, sparsified to top-k.
|
|
2
|
+
|
|
3
|
+
Given a binary user x item matrix X:
|
|
4
|
+
|
|
5
|
+
G = X^T X + lambda I; P = G^-1; B = -P / diag(P) (column-wise); diag(B) = 0
|
|
6
|
+
|
|
7
|
+
A user's score vector is then ``x B``. ``B`` is dense, so each source row keeps only its
|
|
8
|
+
``topk`` largest positive weights (the recsys report, section 3, measured 98% of dense
|
|
9
|
+
NDCG@100 at top-100 on MovieLens 32M). Rows of the result are source items, columns
|
|
10
|
+
target items, and each row's entries are sorted by descending weight, which is the
|
|
11
|
+
order ``docs/artifact-format.md`` requires.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
from scipy import sparse
|
|
16
|
+
|
|
17
|
+
from flickpick.artifacts import CF
|
|
18
|
+
from flickpick.defaults import DFLT
|
|
19
|
+
|
|
20
|
+
#: rows processed at a time when sparsifying a dense B (bounds peak memory)
|
|
21
|
+
SPARSIFY_CHUNK_ROWS = 1024
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def most_rated_items(X, *, n_items: int = DFLT.n_items) -> np.ndarray:
|
|
25
|
+
"""Column indices of the ``n_items`` columns of ``X`` with most nonzeros.
|
|
26
|
+
|
|
27
|
+
Ties are broken by column index, so the result is deterministic.
|
|
28
|
+
"""
|
|
29
|
+
counts = np.asarray((sparse.csc_matrix(X) != 0).sum(axis=0)).ravel()
|
|
30
|
+
order = np.lexsort((np.arange(len(counts)), -counts))
|
|
31
|
+
return order[:n_items]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def ease_dense(X, *, lam: float = DFLT.ease_lambda, dtype=np.float64) -> np.ndarray:
|
|
35
|
+
"""The dense EASE weight matrix B for a (sparse or dense) binary matrix X."""
|
|
36
|
+
if lam <= 0:
|
|
37
|
+
raise ValueError(f"lam must be positive (it makes G invertible), got {lam}")
|
|
38
|
+
X = sparse.csr_matrix(X, dtype=dtype)
|
|
39
|
+
G = (X.T @ X).toarray()
|
|
40
|
+
G[np.diag_indices_from(G)] += lam
|
|
41
|
+
del X
|
|
42
|
+
B = np.linalg.inv(G) # P; turned into B in place to keep one n x n array alive
|
|
43
|
+
del G
|
|
44
|
+
B /= -np.diag(B).copy()
|
|
45
|
+
B[np.diag_indices_from(B)] = 0.0
|
|
46
|
+
return B
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def sparsify_topk(B: np.ndarray, *, topk: int = DFLT.topk) -> CF:
|
|
50
|
+
"""Keep each row's ``topk`` largest positive weights, sorted descending, as CSR."""
|
|
51
|
+
n_rows, n_cols = B.shape
|
|
52
|
+
kk = min(topk, n_cols)
|
|
53
|
+
indptr = np.zeros(n_rows + 1, dtype=np.int64)
|
|
54
|
+
rows_idx, rows_val = [], []
|
|
55
|
+
for start in range(0, n_rows, SPARSIFY_CHUNK_ROWS):
|
|
56
|
+
chunk = B[start : start + SPARSIFY_CHUNK_ROWS]
|
|
57
|
+
top = (
|
|
58
|
+
np.argpartition(-chunk, kk - 1, axis=1)[:, :kk]
|
|
59
|
+
if kk < n_cols
|
|
60
|
+
else (np.tile(np.arange(n_cols), (len(chunk), 1)))
|
|
61
|
+
)
|
|
62
|
+
vals = np.take_along_axis(chunk, top, axis=1)
|
|
63
|
+
order = np.lexsort((top, -vals), axis=1)
|
|
64
|
+
top = np.take_along_axis(top, order, axis=1)
|
|
65
|
+
vals = np.take_along_axis(vals, order, axis=1)
|
|
66
|
+
for r in range(len(chunk)):
|
|
67
|
+
keep = vals[r] > 0
|
|
68
|
+
rows_idx.append(top[r][keep])
|
|
69
|
+
rows_val.append(vals[r][keep])
|
|
70
|
+
indptr[start + r + 1] = keep.sum()
|
|
71
|
+
np.cumsum(indptr, out=indptr)
|
|
72
|
+
concat = np.concatenate if rows_idx else (lambda _: np.zeros(0))
|
|
73
|
+
return CF(
|
|
74
|
+
indptr=indptr.astype(np.int32),
|
|
75
|
+
indices=concat(rows_idx).astype(np.int32),
|
|
76
|
+
values=concat(rows_val).astype(np.float32),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def fit_ease(
|
|
81
|
+
X,
|
|
82
|
+
*,
|
|
83
|
+
lam: float = DFLT.ease_lambda,
|
|
84
|
+
topk: int = DFLT.topk,
|
|
85
|
+
n_items: int | None = DFLT.n_items,
|
|
86
|
+
) -> tuple[CF, np.ndarray]:
|
|
87
|
+
"""Fit sparse EASE on X restricted to its ``n_items`` most-rated columns (None: all).
|
|
88
|
+
|
|
89
|
+
Returns ``(cf, item_cols)``: the CSR matrix over the kept items, and the original
|
|
90
|
+
column index of each kept item (row ``r`` of ``cf`` is column ``item_cols[r]``).
|
|
91
|
+
"""
|
|
92
|
+
X = sparse.csr_matrix(X)
|
|
93
|
+
cols = (
|
|
94
|
+
np.arange(X.shape[1])
|
|
95
|
+
if n_items is None
|
|
96
|
+
else most_rated_items(X, n_items=n_items)
|
|
97
|
+
)
|
|
98
|
+
B = ease_dense(X[:, cols], lam=lam)
|
|
99
|
+
return sparsify_topk(B, topk=topk), cols
|
flickpick/embedding.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Sentence embeddings of catalogue text and of mood queries.
|
|
2
|
+
|
|
3
|
+
The model is fixed per artifact set (``manifest["embedding"]["model"]``, by default
|
|
4
|
+
``BAAI/bge-small-en-v1.5``, 384-d). Encoding goes through the in-house ``ef`` package's
|
|
5
|
+
sentence-transformers adapter when ``ef`` is importable, else through
|
|
6
|
+
``sentence-transformers`` directly; either way it needs the optional ``[embed]`` extra
|
|
7
|
+
(``pip install 'flickpick[embed]'``). Vectors are always returned L2-normalised float32.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable, Sequence
|
|
11
|
+
from functools import lru_cache
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
from flickpick.defaults import DFLT
|
|
16
|
+
|
|
17
|
+
INSTALL_HINT = (
|
|
18
|
+
"Embeddings need sentence-transformers: pip install 'flickpick[embed]' "
|
|
19
|
+
"(or build with --no-embeddings)."
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def l2_normalise(x: np.ndarray) -> np.ndarray:
|
|
24
|
+
"""Rows of ``x`` scaled to unit L2 norm (zero rows stay zero), as float32."""
|
|
25
|
+
x = np.asarray(x, dtype=np.float32)
|
|
26
|
+
norms = np.linalg.norm(x, axis=-1, keepdims=True)
|
|
27
|
+
return (x / np.where(norms == 0, 1, norms)).astype(np.float32)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@lru_cache(maxsize=4)
|
|
31
|
+
def get_encoder(
|
|
32
|
+
model: str = DFLT.embedding_model, *, batch_size: int = DFLT.embed_batch_size
|
|
33
|
+
) -> Callable[[Sequence[str]], np.ndarray]:
|
|
34
|
+
"""A batch text encoder ``texts -> array(n, dim)`` for ``model`` (cached)."""
|
|
35
|
+
try:
|
|
36
|
+
import ef
|
|
37
|
+
|
|
38
|
+
return ef.sentence_transformers_embedder(
|
|
39
|
+
model, normalize=True, batch_size=batch_size
|
|
40
|
+
)
|
|
41
|
+
except ImportError:
|
|
42
|
+
pass
|
|
43
|
+
try:
|
|
44
|
+
from sentence_transformers import SentenceTransformer
|
|
45
|
+
except ImportError as exc:
|
|
46
|
+
raise ImportError(INSTALL_HINT) from exc
|
|
47
|
+
st = SentenceTransformer(model)
|
|
48
|
+
|
|
49
|
+
def encode(texts):
|
|
50
|
+
return st.encode(
|
|
51
|
+
list(texts),
|
|
52
|
+
batch_size=batch_size,
|
|
53
|
+
normalize_embeddings=True,
|
|
54
|
+
convert_to_numpy=True,
|
|
55
|
+
show_progress_bar=False,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
return encode
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def embed_texts(
|
|
62
|
+
texts: Sequence[str], *, model: str = DFLT.embedding_model, prefix: str = ""
|
|
63
|
+
) -> np.ndarray:
|
|
64
|
+
"""L2-normalised float32 embeddings of ``prefix + text`` for each text."""
|
|
65
|
+
encoder = get_encoder(model)
|
|
66
|
+
return l2_normalise(encoder([prefix + t for t in texts]))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def embedding_spec(
|
|
70
|
+
*,
|
|
71
|
+
model: str = DFLT.embedding_model,
|
|
72
|
+
dim: int = DFLT.embedding_dim,
|
|
73
|
+
query_prefix: str = DFLT.query_prefix,
|
|
74
|
+
) -> dict:
|
|
75
|
+
"""The manifest ``embedding`` entry for vectors of ``semantic_text``."""
|
|
76
|
+
return {
|
|
77
|
+
"model": model,
|
|
78
|
+
"dim": int(dim),
|
|
79
|
+
"dtype": "float32",
|
|
80
|
+
"query_prefix": query_prefix,
|
|
81
|
+
"text_field": "semantic_text",
|
|
82
|
+
}
|
flickpick/importers.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Parse ratings exports into ``Rating`` lists on the canonical 0-100 scale.
|
|
2
|
+
|
|
3
|
+
Formats (detected from the header by :func:`detect_format`; header cells are trimmed
|
|
4
|
+
and matched case-insensitively, and the first format whose required columns are all
|
|
5
|
+
present wins, in this order -- the rules of ``docs/core-contract.md``, Importers):
|
|
6
|
+
|
|
7
|
+
- **imdb** -- IMDb's ratings export (requires ``Const``, ``Your Rating``; 1-10, x10).
|
|
8
|
+
- **flickpick** -- ``movie_id,imdb_id,tmdb_id,rating,average_rating,title`` (requires
|
|
9
|
+
``movie_id``, ``imdb_id``, ``rating``) with ``rating`` already 0-100 and ``imdb_id``
|
|
10
|
+
numeric or ``tt``-prefixed.
|
|
11
|
+
- **letterboxd** -- ``ratings.csv`` or ``diary.csv`` from Letterboxd's data export
|
|
12
|
+
(requires ``Name``, ``Year``, ``Rating``; ``Date`` and ``Letterboxd URI`` optional;
|
|
13
|
+
0.5-5 stars, x20). No IMDb id: the rating carries ``"Name (Year)"`` as its title and
|
|
14
|
+
is matched by title.
|
|
15
|
+
- **movielens** -- a MovieLens ``ratings.csv`` (requires ``movieId``, ``rating``;
|
|
16
|
+
``userId`` and ``timestamp`` optional; x20). Items are ``ml:<movieId>``; a file
|
|
17
|
+
holding several users needs ``user_id=``.
|
|
18
|
+
|
|
19
|
+
>>> parse_ratings("Const,Your Rating,Title,Year\\ntt0114369,9,Se7en,1995\\n")
|
|
20
|
+
[Rating(item_id='tt0114369', score=90.0, rated_at=None, title='Se7en (1995)')]
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import csv
|
|
24
|
+
import io
|
|
25
|
+
from collections.abc import Callable
|
|
26
|
+
from datetime import datetime, timezone
|
|
27
|
+
|
|
28
|
+
from flickpick.defaults import SCALE_MAX, SCALE_MIN, STARS_TO_100, TEN_TO_100
|
|
29
|
+
from flickpick.ratings import ML_PREFIX, TMDB_PREFIX, Rating, imdb_id
|
|
30
|
+
|
|
31
|
+
#: format -> lower-cased columns whose presence identifies it (checked in this order;
|
|
32
|
+
#: the same table as the TypeScript ``SIGNATURES``)
|
|
33
|
+
SIGNATURES = {
|
|
34
|
+
"imdb": {"const", "your rating"},
|
|
35
|
+
"flickpick": {"movie_id", "imdb_id", "rating"},
|
|
36
|
+
"letterboxd": {"name", "year", "rating"},
|
|
37
|
+
"movielens": {"movieid", "rating"},
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _rows(csv_text: str) -> list[dict]:
|
|
42
|
+
reader = csv.DictReader(io.StringIO(csv_text.lstrip("")))
|
|
43
|
+
# header cells are matched stripped and lower-cased (as in ``detect_format``), so
|
|
44
|
+
# "Const, Your Rating" or "RATING" does not silently yield no rows
|
|
45
|
+
reader.fieldnames = [c.strip().lower() for c in reader.fieldnames or []]
|
|
46
|
+
return list(reader)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _header(csv_text: str) -> set[str]:
|
|
50
|
+
first = next(csv.reader(io.StringIO(csv_text.lstrip(""))), [])
|
|
51
|
+
return {c.strip().lower() for c in first}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def detect_format(csv_text: str) -> str:
|
|
55
|
+
"""The name of the export format of ``csv_text`` (a key of ``SIGNATURES``)."""
|
|
56
|
+
header = _header(csv_text)
|
|
57
|
+
for name, required in SIGNATURES.items():
|
|
58
|
+
if required <= header:
|
|
59
|
+
return name
|
|
60
|
+
raise ValueError(
|
|
61
|
+
f"Unrecognised ratings CSV header {sorted(header)}. Expected one of: "
|
|
62
|
+
+ "; ".join(f"{n} ({', '.join(sorted(c))})" for n, c in SIGNATURES.items())
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _blank(value) -> bool:
|
|
67
|
+
return value is None or not str(value).strip()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _score(value, factor: float) -> float:
|
|
71
|
+
"""``value`` converted to the 0-100 scale; raises when the result falls outside it."""
|
|
72
|
+
score = float(value) * factor
|
|
73
|
+
if not SCALE_MIN <= score <= SCALE_MAX:
|
|
74
|
+
raise ValueError(
|
|
75
|
+
f"Rating {value!r} (x{factor} = {score}) is outside the "
|
|
76
|
+
f"{SCALE_MIN}-{SCALE_MAX} scale"
|
|
77
|
+
)
|
|
78
|
+
return score
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _with_year(title: str, year) -> str:
|
|
82
|
+
return f"{title} ({year})" if not _blank(year) else title
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def parse_letterboxd(csv_text: str) -> list[Rating]:
|
|
86
|
+
"""Letterboxd ``ratings.csv`` -> ratings (unrated rows are skipped)."""
|
|
87
|
+
return [
|
|
88
|
+
Rating(
|
|
89
|
+
item_id="",
|
|
90
|
+
score=_score(r["rating"], STARS_TO_100),
|
|
91
|
+
rated_at=r.get("date") or None,
|
|
92
|
+
title=_with_year(r["name"], r["year"]),
|
|
93
|
+
)
|
|
94
|
+
for r in _rows(csv_text)
|
|
95
|
+
if not _blank(r.get("rating"))
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def parse_imdb(csv_text: str) -> list[Rating]:
|
|
100
|
+
"""IMDb ratings export -> ratings."""
|
|
101
|
+
return [
|
|
102
|
+
Rating(
|
|
103
|
+
item_id=imdb_id(r["const"]),
|
|
104
|
+
score=_score(r["your rating"], TEN_TO_100),
|
|
105
|
+
rated_at=r.get("date rated") or None,
|
|
106
|
+
title=_with_year(r.get("title") or "", r.get("year"))
|
|
107
|
+
if r.get("title")
|
|
108
|
+
else None,
|
|
109
|
+
)
|
|
110
|
+
for r in _rows(csv_text)
|
|
111
|
+
if not _blank(r.get("your rating"))
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _iso(timestamp) -> str | None:
|
|
116
|
+
if _blank(timestamp):
|
|
117
|
+
return None
|
|
118
|
+
dt = datetime.fromtimestamp(int(timestamp), tz=timezone.utc)
|
|
119
|
+
return dt.isoformat(timespec="seconds")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def parse_movielens(csv_text: str, *, user_id=None) -> list[Rating]:
|
|
123
|
+
"""MovieLens ``ratings.csv`` -> ratings of one user (``ml:<movieId>`` ids).
|
|
124
|
+
|
|
125
|
+
``userId`` is optional; a file holding several users needs ``user_id``.
|
|
126
|
+
"""
|
|
127
|
+
rows = _rows(csv_text)
|
|
128
|
+
users = {r["userid"].strip() for r in rows if not _blank(r.get("userid"))}
|
|
129
|
+
if user_id is None and len(users) > 1:
|
|
130
|
+
raise ValueError(
|
|
131
|
+
f"The file holds {len(users)} users; pass user_id= to pick one."
|
|
132
|
+
)
|
|
133
|
+
if user_id is not None:
|
|
134
|
+
rows = [r for r in rows if (r.get("userid") or "").strip() == str(user_id)]
|
|
135
|
+
return [
|
|
136
|
+
Rating(
|
|
137
|
+
item_id=f"{ML_PREFIX}{int(r['movieid'])}",
|
|
138
|
+
score=_score(r["rating"], STARS_TO_100),
|
|
139
|
+
rated_at=_iso(r.get("timestamp")),
|
|
140
|
+
)
|
|
141
|
+
for r in rows
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _flickpick_id(row: dict) -> str:
|
|
146
|
+
if not _blank(row.get("imdb_id")):
|
|
147
|
+
return imdb_id(row["imdb_id"])
|
|
148
|
+
if not _blank(row.get("movie_id")):
|
|
149
|
+
return f"{ML_PREFIX}{int(row['movie_id'])}"
|
|
150
|
+
if not _blank(row.get("tmdb_id")):
|
|
151
|
+
return f"{TMDB_PREFIX}{int(row['tmdb_id'])}"
|
|
152
|
+
return ""
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def parse_flickpick(csv_text: str) -> list[Rating]:
|
|
156
|
+
"""The flickpick CSV (the example file's columns) -> ratings (already 0-100)."""
|
|
157
|
+
return [
|
|
158
|
+
Rating(
|
|
159
|
+
item_id=_flickpick_id(r),
|
|
160
|
+
score=_score(r["rating"], 1),
|
|
161
|
+
rated_at=r.get("rated_at") or None,
|
|
162
|
+
title=r.get("title") or None,
|
|
163
|
+
)
|
|
164
|
+
for r in _rows(csv_text)
|
|
165
|
+
if not _blank(r.get("rating"))
|
|
166
|
+
]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
PARSERS: dict[str, Callable[[str], list[Rating]]] = {
|
|
170
|
+
"letterboxd": parse_letterboxd,
|
|
171
|
+
"imdb": parse_imdb,
|
|
172
|
+
"movielens": parse_movielens,
|
|
173
|
+
"flickpick": parse_flickpick,
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def parse_ratings(csv_text: str, *, format: str | None = None) -> list[Rating]:
|
|
178
|
+
"""Parse ``csv_text`` with the parser for ``format`` (detected when None)."""
|
|
179
|
+
fmt = format or detect_format(csv_text)
|
|
180
|
+
if fmt not in PARSERS:
|
|
181
|
+
raise ValueError(f"Unknown format {fmt!r}; choose one of {sorted(PARSERS)}")
|
|
182
|
+
return PARSERS[fmt](csv_text)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def read_ratings(path, *, format: str | None = None, encoding="utf-8") -> list[Rating]:
|
|
186
|
+
"""Read and parse a ratings CSV file."""
|
|
187
|
+
with open(path, encoding=encoding) as f:
|
|
188
|
+
return parse_ratings(f.read(), format=format)
|