open-interpretability 0.0.20__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.
- open_interpretability-0.0.20.dist-info/METADATA +157 -0
- open_interpretability-0.0.20.dist-info/RECORD +256 -0
- open_interpretability-0.0.20.dist-info/WHEEL +4 -0
- open_interpretability-0.0.20.dist-info/entry_points.txt +2 -0
- open_interpretability-0.0.20.dist-info/licenses/LICENSE +21 -0
- openinterp/__init__.py +6 -0
- openinterp/agents/__init__.py +25 -0
- openinterp/agents/base.py +229 -0
- openinterp/agents/judge.py +149 -0
- openinterp/agents/proposer.py +471 -0
- openinterp/agents/proposer_profiles.py +79 -0
- openinterp/agents/review_measurements.py +173 -0
- openinterp/agents/reviewer.py +498 -0
- openinterp/agreement.py +63 -0
- openinterp/audit_options.py +79 -0
- openinterp/baseline.py +254 -0
- openinterp/benchmark.py +450 -0
- openinterp/blends.py +239 -0
- openinterp/builder/__init__.py +6 -0
- openinterp/builder/agents.py +260 -0
- openinterp/builder/aligned.py +41 -0
- openinterp/builder/charts.py +411 -0
- openinterp/builder/circuit_pages.py +37 -0
- openinterp/builder/circuits.py +264 -0
- openinterp/builder/compiler.py +495 -0
- openinterp/builder/corpora.py +98 -0
- openinterp/builder/coverage.py +46 -0
- openinterp/builder/dashboard.py +255 -0
- openinterp/builder/disk_memo.py +65 -0
- openinterp/builder/experiment.py +105 -0
- openinterp/builder/heat.py +121 -0
- openinterp/builder/hrefs.py +71 -0
- openinterp/builder/learned_baseline.py +204 -0
- openinterp/builder/model_facts.py +138 -0
- openinterp/builder/neuronpedia_export.py +107 -0
- openinterp/builder/parallel.py +100 -0
- openinterp/builder/parquet_export.py +458 -0
- openinterp/builder/pieces.py +127 -0
- openinterp/builder/pii.py +211 -0
- openinterp/builder/registry_stats.py +144 -0
- openinterp/builder/research.py +109 -0
- openinterp/builder/sections.py +28 -0
- openinterp/builder/significance.py +103 -0
- openinterp/builder/sister_graph.py +180 -0
- openinterp/builder/standing.py +185 -0
- openinterp/builder/statuses.py +34 -0
- openinterp/builder/stopped.py +91 -0
- openinterp/builder/stops.py +30 -0
- openinterp/builder/superpositions.py +102 -0
- openinterp/builder/templates/_agent_role_cells.html +9 -0
- openinterp/builder/templates/_causal_chart.html +67 -0
- openinterp/builder/templates/_circuit.html +8 -0
- openinterp/builder/templates/_dashboard.html +133 -0
- openinterp/builder/templates/_experiment.html +500 -0
- openinterp/builder/templates/_macros.html +82 -0
- openinterp/builder/templates/_patchscope.html +33 -0
- openinterp/builder/templates/_prior_art.html +24 -0
- openinterp/builder/templates/_specificity.html +21 -0
- openinterp/builder/templates/_steering.html +34 -0
- openinterp/builder/templates/_superposition.html +62 -0
- openinterp/builder/templates/_vocab_chart.html +88 -0
- openinterp/builder/templates/_wiring.html +44 -0
- openinterp/builder/templates/about.html +95 -0
- openinterp/builder/templates/agent.html +69 -0
- openinterp/builder/templates/agents.html +80 -0
- openinterp/builder/templates/base.html +103 -0
- openinterp/builder/templates/circuit.html +64 -0
- openinterp/builder/templates/circuits.html +46 -0
- openinterp/builder/templates/experiment.html +21 -0
- openinterp/builder/templates/hub.html +16 -0
- openinterp/builder/templates/index.html +387 -0
- openinterp/builder/templates/layer.html +69 -0
- openinterp/builder/templates/learned-baseline.html +44 -0
- openinterp/builder/templates/model.html +151 -0
- openinterp/builder/templates/models.html +47 -0
- openinterp/builder/templates/neuron.html +234 -0
- openinterp/builder/templates/paper.html +39 -0
- openinterp/builder/templates/redirect.html +1 -0
- openinterp/builder/templates/research.html +31 -0
- openinterp/builder/templates/user.html +54 -0
- openinterp/builder/templates/users.html +39 -0
- openinterp/builder/templates/wiki/experiment.md +271 -0
- openinterp/builder/templates/wiki/index.md +32 -0
- openinterp/builder/templates/wiki/neuron.md +30 -0
- openinterp/builder/templates/wiki/stop.md +36 -0
- openinterp/builder/tree.py +470 -0
- openinterp/builder/users.py +142 -0
- openinterp/builder/wiki.py +427 -0
- openinterp/checks.py +429 -0
- openinterp/cli.py +494 -0
- openinterp/cli_baseline.py +332 -0
- openinterp/cli_benchmark.py +300 -0
- openinterp/cli_build.py +128 -0
- openinterp/cli_check.py +51 -0
- openinterp/cli_concepts.py +76 -0
- openinterp/cli_corpus.py +252 -0
- openinterp/cli_costs.py +257 -0
- openinterp/cli_coverage.py +76 -0
- openinterp/cli_crossref.py +86 -0
- openinterp/cli_errata.py +142 -0
- openinterp/cli_evidence.py +495 -0
- openinterp/cli_evidence_store.py +93 -0
- openinterp/cli_explain.py +101 -0
- openinterp/cli_loop.py +125 -0
- openinterp/cli_memory.py +28 -0
- openinterp/cli_neuronpedia.py +175 -0
- openinterp/cli_pause.py +45 -0
- openinterp/cli_pole_effects.py +133 -0
- openinterp/cli_probing.py +119 -0
- openinterp/cli_registry.py +348 -0
- openinterp/cli_reproduce.py +446 -0
- openinterp/cli_review.py +302 -0
- openinterp/cli_scan.py +223 -0
- openinterp/cli_score.py +446 -0
- openinterp/cli_sisters.py +86 -0
- openinterp/cli_submission.py +217 -0
- openinterp/cli_superpositions.py +118 -0
- openinterp/cli_wiring.py +54 -0
- openinterp/codeid.py +73 -0
- openinterp/concepts.py +400 -0
- openinterp/core/__init__.py +59 -0
- openinterp/core/agent_names.py +76 -0
- openinterp/core/attribution.py +101 -0
- openinterp/core/cache_dirs.py +15 -0
- openinterp/core/causal.py +82 -0
- openinterp/core/confounds.py +103 -0
- openinterp/core/contributors.py +26 -0
- openinterp/core/corpora.py +126 -0
- openinterp/core/costs.py +430 -0
- openinterp/core/draws.py +109 -0
- openinterp/core/errata.py +244 -0
- openinterp/core/evidence.py +160 -0
- openinterp/core/examples.py +93 -0
- openinterp/core/exceptions.py +29 -0
- openinterp/core/explanation.py +130 -0
- openinterp/core/fence.py +42 -0
- openinterp/core/heavy_lock.py +114 -0
- openinterp/core/identifiers.py +198 -0
- openinterp/core/isolation.py +149 -0
- openinterp/core/ledger.py +130 -0
- openinterp/core/licenses.py +28 -0
- openinterp/core/manifold.py +117 -0
- openinterp/core/partners.py +32 -0
- openinterp/core/patchscope.py +59 -0
- openinterp/core/paths.py +40 -0
- openinterp/core/pause.py +96 -0
- openinterp/core/pricing.py +178 -0
- openinterp/core/prior_art.py +44 -0
- openinterp/core/probing.py +146 -0
- openinterp/core/profiles.py +221 -0
- openinterp/core/project.py +28 -0
- openinterp/core/run_environment.py +38 -0
- openinterp/core/schemas.py +484 -0
- openinterp/core/specificity.py +49 -0
- openinterp/core/stats.py +172 -0
- openinterp/core/steering.py +49 -0
- openinterp/core/strict.py +19 -0
- openinterp/core/takedowns.py +136 -0
- openinterp/core/transcripts.py +130 -0
- openinterp/core/wiring.py +80 -0
- openinterp/corpus.py +476 -0
- openinterp/corpus_cache.py +145 -0
- openinterp/corpus_layouts.py +108 -0
- openinterp/corpus_presets.py +168 -0
- openinterp/corpus_rebuild.py +93 -0
- openinterp/coverage.py +155 -0
- openinterp/crossref.py +442 -0
- openinterp/data/__init__.py +9 -0
- openinterp/data/offensive_words.txt +58 -0
- openinterp/data/public_suffix_list.dat +15771 -0
- openinterp/data/sample_corpus.txt +40 -0
- openinterp/draw_steps.py +359 -0
- openinterp/errata_checks.py +129 -0
- openinterp/evidence.py +462 -0
- openinterp/evidence_branch.py +119 -0
- openinterp/evidence_lenses.py +75 -0
- openinterp/experiment_loop.py +357 -0
- openinterp/explain.py +252 -0
- openinterp/half_precision.py +79 -0
- openinterp/harness/__init__.py +37 -0
- openinterp/harness/ane/__init__.py +9 -0
- openinterp/harness/ane/backend.py +324 -0
- openinterp/harness/ane/cache.py +276 -0
- openinterp/harness/ane/chunks.py +163 -0
- openinterp/harness/ane/convert.py +116 -0
- openinterp/harness/ane/forward.py +258 -0
- openinterp/harness/ane/qualified.py +40 -0
- openinterp/harness/annotations.py +329 -0
- openinterp/harness/attribution.py +96 -0
- openinterp/harness/causal.py +264 -0
- openinterp/harness/confounds.py +184 -0
- openinterp/harness/covariance.py +301 -0
- openinterp/harness/crossref.py +195 -0
- openinterp/harness/cuda_devices.py +88 -0
- openinterp/harness/dead.py +124 -0
- openinterp/harness/devices.py +401 -0
- openinterp/harness/dictionaries.py +496 -0
- openinterp/harness/dreaming.py +116 -0
- openinterp/harness/environment.py +64 -0
- openinterp/harness/explanation_scores.py +371 -0
- openinterp/harness/features.py +276 -0
- openinterp/harness/first_position.py +137 -0
- openinterp/harness/heads.py +226 -0
- openinterp/harness/hooks.py +499 -0
- openinterp/harness/input_attribution.py +367 -0
- openinterp/harness/jlens.py +183 -0
- openinterp/harness/layer_cache.py +301 -0
- openinterp/harness/layer_major.py +274 -0
- openinterp/harness/lookahead.py +93 -0
- openinterp/harness/manifold.py +200 -0
- openinterp/harness/memory.py +471 -0
- openinterp/harness/moe.py +134 -0
- openinterp/harness/patcher.py +322 -0
- openinterp/harness/patchscope.py +177 -0
- openinterp/harness/probing.py +383 -0
- openinterp/harness/scan.py +462 -0
- openinterp/harness/scan_backend.py +201 -0
- openinterp/harness/sister_passes.py +463 -0
- openinterp/harness/sisters.py +363 -0
- openinterp/harness/sites.py +101 -0
- openinterp/harness/specificity.py +107 -0
- openinterp/harness/steering.py +159 -0
- openinterp/harness/superpositions.py +116 -0
- openinterp/harness/units.py +203 -0
- openinterp/harness/wiring.py +432 -0
- openinterp/harness/wiring_activations.py +285 -0
- openinterp/langid.py +73 -0
- openinterp/names.py +129 -0
- openinterp/neuronpedia_baseline.py +88 -0
- openinterp/neuronpedia_review.py +148 -0
- openinterp/planner.py +466 -0
- openinterp/planner_effects.py +89 -0
- openinterp/planner_heads.py +92 -0
- openinterp/planner_models.py +174 -0
- openinterp/planner_sisters.py +122 -0
- openinterp/planner_states.py +57 -0
- openinterp/planner_superpositions.py +114 -0
- openinterp/pole_effects.py +102 -0
- openinterp/prior_art.py +143 -0
- openinterp/reproduce.py +450 -0
- openinterp/revision.py +45 -0
- openinterp/runner_cache.py +35 -0
- openinterp/score_controls.py +169 -0
- openinterp/sensitive.py +243 -0
- openinterp/show.py +236 -0
- openinterp/signing.py +209 -0
- openinterp/sisters.py +476 -0
- openinterp/superposition_finders.py +225 -0
- openinterp/sync/__init__.py +5 -0
- openinterp/sync/r2.py +280 -0
- openinterp/syntax.py +399 -0
- openinterp/takedown_checks.py +76 -0
- openinterp/toxicity.py +158 -0
- openinterp/verdict_regression.py +79 -0
- openinterp/wiring_backfill.py +170 -0
- openinterp/wordnet.py +362 -0
openinterp/corpus.py
ADDED
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
"""Build scan corpora from Hugging Face datasets (`open-interpretability corpus`).
|
|
2
|
+
|
|
3
|
+
A corpus is a JSONL file of ``{"text": ...}`` documents, plus a ``<name>.source.json``
|
|
4
|
+
sidecar (:class:`CorpusSource`) that pins where it came from. Sampling does not download
|
|
5
|
+
whole datasets. It reads Parquet row groups over HTTP. So a 500-document sample of
|
|
6
|
+
fineweb-edu's 10B-token sample costs a few dozen small reads. :mod:`openinterp.corpus_cache`
|
|
7
|
+
keeps what it reads.
|
|
8
|
+
|
|
9
|
+
Sampling is deterministic for a given dataset revision and seed. It visits row groups in
|
|
10
|
+
a seeded random order across all files, and takes a seeded handful of documents from
|
|
11
|
+
each. This spreads the corpus over sources and crawl dumps.
|
|
12
|
+
|
|
13
|
+
The registry's scan corpus is not such a sample. It is pile-10k's first 500 documents,
|
|
14
|
+
each cut to 3,000 characters, with non-ASCII characters escaped. Rebuild it
|
|
15
|
+
byte for byte with ``open-interpretability corpus --dataset pile-10k --first --ascii`` (README).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import hashlib
|
|
21
|
+
import json
|
|
22
|
+
import math
|
|
23
|
+
import random
|
|
24
|
+
import re
|
|
25
|
+
from collections.abc import Iterator, Sequence
|
|
26
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
27
|
+
from functools import partial
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
from openinterp.core.confounds import LANGUAGE, PROGRAMMING_LANGUAGE, SOURCE
|
|
31
|
+
from openinterp.core.schemas import CorpusSample, CorpusSource
|
|
32
|
+
from openinterp.corpus_cache import read_row_group, row_groups
|
|
33
|
+
from openinterp.corpus_layouts import ( # noqa: F401 (wikitext_articles is re-exported)
|
|
34
|
+
LAYOUTS,
|
|
35
|
+
chat_formatter,
|
|
36
|
+
wikitext_articles,
|
|
37
|
+
)
|
|
38
|
+
from openinterp.corpus_presets import ( # noqa: F401
|
|
39
|
+
FINEWEB2_LANGUAGES,
|
|
40
|
+
PRESETS,
|
|
41
|
+
UNSTATED_LICENSES,
|
|
42
|
+
WIKIPEDIA_LANGUAGES,
|
|
43
|
+
Preset,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# What `open-interpretability audit` scans by default (protocol 0.7). The seed is fixed, so every
|
|
47
|
+
# audit of a corpus reads the same documents, and their scales are comparable.
|
|
48
|
+
DEFAULT_SAMPLE_TOKENS = 200_000
|
|
49
|
+
DEFAULT_SAMPLE_SEED = 0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# Document fields a corpus may carry besides "text", and the label set each gives the scan
|
|
53
|
+
# (`harness/annotations.py`). Labels from the dataset, or detected where the source
|
|
54
|
+
# sidecar names a detector (`lang_detector`, `code_detector`).
|
|
55
|
+
DOCUMENT_LABELS = {
|
|
56
|
+
"lang": LANGUAGE,
|
|
57
|
+
"source": SOURCE,
|
|
58
|
+
"code_language": PROGRAMMING_LANGUAGE,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def source_path(corpus_path: Path) -> Path:
|
|
63
|
+
"""``corpus.jsonl`` -> ``corpus.source.json``."""
|
|
64
|
+
return corpus_path.with_name(corpus_path.name.split(".")[0] + ".source.json")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def sample_documents(
|
|
68
|
+
fs,
|
|
69
|
+
paths: list[str],
|
|
70
|
+
*,
|
|
71
|
+
text_column: str,
|
|
72
|
+
documents: int,
|
|
73
|
+
seed: int,
|
|
74
|
+
max_chars: int,
|
|
75
|
+
docs_per_row_group: int,
|
|
76
|
+
layout: str | None = None,
|
|
77
|
+
label_column: str | None = None,
|
|
78
|
+
labels: list | None = None,
|
|
79
|
+
chat=None,
|
|
80
|
+
) -> list[str]:
|
|
81
|
+
"""Return up to ``documents`` non-empty texts from Parquet files on ``fs``.
|
|
82
|
+
|
|
83
|
+
Each text is cut to ``max_chars``. Comma-separated ``text_column`` names join into one
|
|
84
|
+
text (:func:`_read_groups`). With ``chat``, a function, each row's conversation in
|
|
85
|
+
``text_column`` becomes the text that ``chat`` returns. With a ``layout``, each row
|
|
86
|
+
group's rows are first joined into its documents. With a ``label_column``, each
|
|
87
|
+
returned text's value in that column joins the list ``labels``.
|
|
88
|
+
"""
|
|
89
|
+
rng = random.Random(seed)
|
|
90
|
+
paths = sorted(paths)
|
|
91
|
+
# One footer read per file: in parallel, since a dataset of 880 files takes minutes serially.
|
|
92
|
+
with ThreadPoolExecutor(16) as pool:
|
|
93
|
+
counts = list(pool.map(lambda path: row_groups(fs, path), paths))
|
|
94
|
+
groups = [(path, i) for path, n in zip(paths, counts, strict=True) for i in range(n)]
|
|
95
|
+
rng.shuffle(groups)
|
|
96
|
+
# Few row groups (a small dataset): take more from each, so the sample still fills.
|
|
97
|
+
take = max(docs_per_row_group, math.ceil(documents / max(len(groups), 1)))
|
|
98
|
+
out: list[str] = []
|
|
99
|
+
for texts, column in _read_groups(fs, groups, text_column, label_column, chat):
|
|
100
|
+
if layout is not None:
|
|
101
|
+
texts = LAYOUTS[layout](texts)
|
|
102
|
+
order = rng.sample(range(len(texts)), len(texts))
|
|
103
|
+
picked = [j for j in order if (texts[j] or "").strip()][:take] # empties don't count
|
|
104
|
+
for j in sorted(picked):
|
|
105
|
+
out.append(texts[j].strip()[:max_chars])
|
|
106
|
+
if label_column:
|
|
107
|
+
labels.append(column[j])
|
|
108
|
+
if len(out) >= documents:
|
|
109
|
+
return out
|
|
110
|
+
return out
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def first_documents(
|
|
114
|
+
fs,
|
|
115
|
+
paths: list[str],
|
|
116
|
+
*,
|
|
117
|
+
text_column: str,
|
|
118
|
+
documents: int,
|
|
119
|
+
max_chars: int,
|
|
120
|
+
layout: str | None = None,
|
|
121
|
+
label_column: str | None = None,
|
|
122
|
+
labels: list | None = None,
|
|
123
|
+
chat=None,
|
|
124
|
+
) -> list[str]:
|
|
125
|
+
"""Return the first ``documents`` non-empty texts of the Parquet files on ``fs``.
|
|
126
|
+
|
|
127
|
+
The files are in sorted order. Each text is cut to ``max_chars`` but not stripped, as
|
|
128
|
+
the registry's corpus was. ``label_column``, ``labels`` and ``chat`` are as in
|
|
129
|
+
:func:`sample_documents`.
|
|
130
|
+
"""
|
|
131
|
+
out: list[str] = []
|
|
132
|
+
for path in sorted(paths):
|
|
133
|
+
groups = [(path, i) for i in range(row_groups(fs, path))]
|
|
134
|
+
for texts, column in _read_groups(fs, groups, text_column, label_column, chat):
|
|
135
|
+
if layout is not None:
|
|
136
|
+
texts = LAYOUTS[layout](texts)
|
|
137
|
+
for j, text in enumerate(texts):
|
|
138
|
+
if (text or "").strip():
|
|
139
|
+
out.append(text[:max_chars])
|
|
140
|
+
if label_column:
|
|
141
|
+
labels.append(column[j])
|
|
142
|
+
if len(out) >= documents:
|
|
143
|
+
return out
|
|
144
|
+
return out
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _read_groups(
|
|
148
|
+
fs, groups, text_column: str, label_column: str | None = None, chat=None
|
|
149
|
+
) -> Iterator[tuple[list, list | None]]:
|
|
150
|
+
"""Yield each row group's texts and, if there is a ``label_column``, that column.
|
|
151
|
+
|
|
152
|
+
Comma-separated ``text_column`` names join into one text per row, with a blank line
|
|
153
|
+
between them. A null value adds nothing, so a row with only nulls gives an empty text.
|
|
154
|
+
With ``chat``, ``text_column`` is one column of conversations, and ``chat`` writes each
|
|
155
|
+
as its row's text. An empty conversation gives an empty text.
|
|
156
|
+
"""
|
|
157
|
+
text_columns = text_column.split(",")
|
|
158
|
+
if chat and len(text_columns) > 1:
|
|
159
|
+
raise ValueError("a chat template writes one column of conversations")
|
|
160
|
+
columns = [*text_columns, label_column] if label_column else text_columns
|
|
161
|
+
for path, i in groups:
|
|
162
|
+
table = read_row_group(fs, path, i, columns)
|
|
163
|
+
parts = [table.column(name).to_pylist() for name in text_columns]
|
|
164
|
+
texts = (
|
|
165
|
+
[chat(row) if row else "" for row in parts[0]]
|
|
166
|
+
if chat
|
|
167
|
+
else ["\n\n".join(filter(None, row)) for row in zip(*parts, strict=True)]
|
|
168
|
+
)
|
|
169
|
+
yield texts, table.column(label_column).to_pylist() if label_column else None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def jsonl_bytes(
|
|
173
|
+
texts: Sequence[str], ascii: bool = False, labels: dict[str, Sequence[str]] | None = None
|
|
174
|
+
) -> bytes:
|
|
175
|
+
"""Return ``texts`` as a JSONL corpus, one ``{"text": ...}`` per line.
|
|
176
|
+
|
|
177
|
+
Each of ``labels`` follows the text. ``labels`` maps a field of
|
|
178
|
+
:data:`DOCUMENT_LABELS` to one value per document. With ``ascii``, non-ASCII
|
|
179
|
+
characters are \\u-escaped.
|
|
180
|
+
"""
|
|
181
|
+
labels = labels or {}
|
|
182
|
+
rows = ({"text": t} | {k: v[i] for k, v in labels.items()} for i, t in enumerate(texts))
|
|
183
|
+
return "".join(json.dumps(row, ensure_ascii=ascii) + "\n" for row in rows).encode()
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def sample_tokens(
|
|
187
|
+
tokenizer, texts: Sequence[str], max_tokens: int, seed: int
|
|
188
|
+
) -> tuple[list[str], CorpusSample]:
|
|
189
|
+
"""Return a seeded sample of whole documents, in corpus order, and its description.
|
|
190
|
+
|
|
191
|
+
The sample has at most ``max_tokens`` tokens (see :class:`CorpusSample`).
|
|
192
|
+
"""
|
|
193
|
+
picked, sample = sample_indices(tokenizer, texts, max_tokens, seed)
|
|
194
|
+
return [texts[i] for i in picked], sample
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def sample_indices(
|
|
198
|
+
tokenizer, texts: Sequence[str], max_tokens: int, seed: int
|
|
199
|
+
) -> tuple[list[int], CorpusSample]:
|
|
200
|
+
""":func:`sample_tokens`, as the sampled documents' indices in corpus order."""
|
|
201
|
+
if not texts:
|
|
202
|
+
raise ValueError("the corpus has no documents to sample")
|
|
203
|
+
order = random.Random(seed).sample(range(len(texts)), len(texts))
|
|
204
|
+
picked: list[int] = []
|
|
205
|
+
total = 0
|
|
206
|
+
for i in order:
|
|
207
|
+
n = len(tokenizer.encode(texts[i], add_special_tokens=False))
|
|
208
|
+
if picked and total + n > max_tokens:
|
|
209
|
+
continue # a shorter document later in the order may still fit
|
|
210
|
+
picked.append(i)
|
|
211
|
+
total += n
|
|
212
|
+
picked.sort()
|
|
213
|
+
sample = [texts[i] for i in picked]
|
|
214
|
+
return picked, CorpusSample(
|
|
215
|
+
seed=seed,
|
|
216
|
+
max_tokens=max_tokens,
|
|
217
|
+
documents=len(sample),
|
|
218
|
+
corpus_documents=len(texts),
|
|
219
|
+
tokens=total,
|
|
220
|
+
sha256=hashlib.sha256(jsonl_bytes(sample)).hexdigest(),
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def write_corpus(
|
|
225
|
+
texts: list[str], out: Path, *, ascii: bool = False, labels=None, **source
|
|
226
|
+
) -> CorpusSource:
|
|
227
|
+
"""Write the JSONL corpus, with each document's ``labels``, and its source sidecar.
|
|
228
|
+
|
|
229
|
+
Returns the source.
|
|
230
|
+
"""
|
|
231
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
232
|
+
data = jsonl_bytes(texts, ascii, labels)
|
|
233
|
+
out.write_bytes(data)
|
|
234
|
+
record = CorpusSource(
|
|
235
|
+
documents=len(texts), sha256=hashlib.sha256(data).hexdigest(), ascii=ascii, **source
|
|
236
|
+
)
|
|
237
|
+
source_path(out).write_text(record.model_dump_json(indent=2) + "\n", encoding="utf-8")
|
|
238
|
+
return record
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def load_source(corpus_path: Path, sha256: str) -> CorpusSource | None:
|
|
242
|
+
"""The corpus's source sidecar, if it exists and describes these exact bytes."""
|
|
243
|
+
path = source_path(corpus_path)
|
|
244
|
+
if not path.exists():
|
|
245
|
+
return None
|
|
246
|
+
source = CorpusSource.model_validate_json(path.read_text(encoding="utf-8"))
|
|
247
|
+
return source if source.sha256 == sha256 else None
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def load_sources(paths: Sequence[Path]) -> dict[str, CorpusSource]:
|
|
251
|
+
"""Committed sources by the SHA-256 they describe; missing paths are skipped.
|
|
252
|
+
|
|
253
|
+
A file holds one sidecar, or a list of sources (``corpus-sources.json``). The build
|
|
254
|
+
uses them to show the source of records that scanned a corpus before its sidecar
|
|
255
|
+
existed. It matches them by ``provenance.corpus_sha256``, and shows the source as
|
|
256
|
+
inferred, not recorded.
|
|
257
|
+
"""
|
|
258
|
+
sources = {}
|
|
259
|
+
for path in paths:
|
|
260
|
+
if Path(path).exists():
|
|
261
|
+
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
262
|
+
for item in data if isinstance(data, list) else [data]:
|
|
263
|
+
source = CorpusSource.model_validate(item)
|
|
264
|
+
sources[source.sha256] = source
|
|
265
|
+
return sources
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def build_from_hub(
|
|
269
|
+
dataset: str,
|
|
270
|
+
out: Path,
|
|
271
|
+
*,
|
|
272
|
+
files: str,
|
|
273
|
+
text_column: str = "text",
|
|
274
|
+
revision: str | None = None,
|
|
275
|
+
documents: int = 500,
|
|
276
|
+
seed: int = 0,
|
|
277
|
+
max_chars: int = 3000,
|
|
278
|
+
docs_per_row_group: int = 20,
|
|
279
|
+
layout: str | None = None,
|
|
280
|
+
first: bool = False,
|
|
281
|
+
ascii: bool = False,
|
|
282
|
+
lang_pattern: str | None = None,
|
|
283
|
+
detect_lang: bool = False,
|
|
284
|
+
code_language_column: str | None = None,
|
|
285
|
+
detect_code: bool = False,
|
|
286
|
+
chat_template: str | None = None,
|
|
287
|
+
chat_template_revision: str | None = None,
|
|
288
|
+
) -> CorpusSource:
|
|
289
|
+
"""Sample a corpus from a Hugging Face dataset at a pinned revision.
|
|
290
|
+
|
|
291
|
+
With ``first``, take its first documents. With ``lang_pattern`` (see :class:`Preset`),
|
|
292
|
+
each document records its glob's language as ``lang``. With ``detect_lang``, ``lang``
|
|
293
|
+
is the language that :mod:`openinterp.langid` detects, where it is sure of one. With
|
|
294
|
+
``code_language_column``, each document records that column's value as ``code_language``.
|
|
295
|
+
With ``detect_code``, ``code_language`` is the programming language that
|
|
296
|
+
:mod:`openinterp.codeid` detects, where it is sure of one.
|
|
297
|
+
With ``chat_template``, a model ID, ``text_column`` holds conversations. That model's
|
|
298
|
+
chat template writes each one as a document. The template comes from the model's
|
|
299
|
+
commit ``chat_template_revision`` (default: its latest commit, pinned on use).
|
|
300
|
+
"""
|
|
301
|
+
texts, labels, source = _sample_hub(
|
|
302
|
+
dataset,
|
|
303
|
+
files=files,
|
|
304
|
+
text_column=text_column,
|
|
305
|
+
revision=revision,
|
|
306
|
+
documents=documents,
|
|
307
|
+
seed=seed,
|
|
308
|
+
max_chars=max_chars,
|
|
309
|
+
docs_per_row_group=docs_per_row_group,
|
|
310
|
+
layout=layout,
|
|
311
|
+
first=first,
|
|
312
|
+
lang_pattern=lang_pattern,
|
|
313
|
+
detect_lang=detect_lang,
|
|
314
|
+
code_language_column=code_language_column,
|
|
315
|
+
detect_code=detect_code,
|
|
316
|
+
chat_template=chat_template,
|
|
317
|
+
chat_template_revision=chat_template_revision,
|
|
318
|
+
)
|
|
319
|
+
return write_corpus(texts, out, ascii=ascii, labels=labels, **source)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _sample_hub(
|
|
323
|
+
dataset: str,
|
|
324
|
+
*,
|
|
325
|
+
files: str,
|
|
326
|
+
text_column: str,
|
|
327
|
+
revision: str | None,
|
|
328
|
+
documents: int,
|
|
329
|
+
seed: int,
|
|
330
|
+
max_chars: int,
|
|
331
|
+
docs_per_row_group: int,
|
|
332
|
+
layout: str | None,
|
|
333
|
+
first: bool = False,
|
|
334
|
+
lang_pattern: str | None = None,
|
|
335
|
+
detect_lang: bool = False,
|
|
336
|
+
code_language_column: str | None = None,
|
|
337
|
+
detect_code: bool = False,
|
|
338
|
+
chat_template: str | None = None,
|
|
339
|
+
chat_template_revision: str | None = None,
|
|
340
|
+
) -> tuple[list[str], dict | None, dict]:
|
|
341
|
+
"""Return the documents that :func:`build_from_hub` samples, and their labels.
|
|
342
|
+
|
|
343
|
+
Also returns the fields of their :class:`CorpusSource`, except those of the written file.
|
|
344
|
+
"""
|
|
345
|
+
_check_options(layout, lang_pattern, detect_lang, code_language_column, detect_code)
|
|
346
|
+
chat, chat_template_revision = _chat(chat_template, chat_template_revision, layout)
|
|
347
|
+
from huggingface_hub import HfApi, HfFileSystem
|
|
348
|
+
|
|
349
|
+
api = HfApi()
|
|
350
|
+
info = api.dataset_info(dataset, revision=revision)
|
|
351
|
+
license_ = _license(api, dataset, info, revision)
|
|
352
|
+
fs = HfFileSystem()
|
|
353
|
+
code_languages: list[str | None] = []
|
|
354
|
+
column = {"label_column": code_language_column, "labels": code_languages, "chat": chat}
|
|
355
|
+
read = {"text_column": text_column, "max_chars": max_chars, "layout": layout, **column}
|
|
356
|
+
sampled = {"seed": seed, "docs_per_row_group": docs_per_row_group}
|
|
357
|
+
reader = first_documents if first else partial(sample_documents, **sampled)
|
|
358
|
+
take = partial(reader, fs, **read)
|
|
359
|
+
texts, globs = _read_globs(fs, f"{dataset}@{info.sha}", files, documents, take)
|
|
360
|
+
labels, detector = _document_labels(texts, globs, lang_pattern, detect_lang)
|
|
361
|
+
code_detector = _code_labels(labels, texts, code_language_column, code_languages, detect_code)
|
|
362
|
+
source = dict(
|
|
363
|
+
dataset=dataset,
|
|
364
|
+
revision=info.sha,
|
|
365
|
+
files=files,
|
|
366
|
+
text_column=text_column,
|
|
367
|
+
seed=seed,
|
|
368
|
+
max_chars=max_chars,
|
|
369
|
+
docs_per_row_group=docs_per_row_group,
|
|
370
|
+
layout=layout,
|
|
371
|
+
first=first,
|
|
372
|
+
lang_pattern=lang_pattern,
|
|
373
|
+
lang_detector=detector,
|
|
374
|
+
code_language_column=code_language_column,
|
|
375
|
+
code_detector=code_detector,
|
|
376
|
+
chat_template=chat_template,
|
|
377
|
+
chat_template_revision=chat_template_revision,
|
|
378
|
+
license=", ".join(license_) if isinstance(license_, list) else license_,
|
|
379
|
+
)
|
|
380
|
+
return texts, labels or None, source
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _check_options(layout, lang_pattern, detect_lang, code_language_column, detect_code):
|
|
384
|
+
"""Raise ``ValueError`` for labelling options of :func:`_sample_hub` that can't go together."""
|
|
385
|
+
if lang_pattern and detect_lang:
|
|
386
|
+
raise ValueError("a corpus's languages come from lang_pattern or detect_lang, not both")
|
|
387
|
+
if code_language_column and detect_code:
|
|
388
|
+
raise ValueError(
|
|
389
|
+
"a corpus's programming languages come from code_language_column or detect_code, "
|
|
390
|
+
"not both"
|
|
391
|
+
)
|
|
392
|
+
if code_language_column and layout:
|
|
393
|
+
raise ValueError("a layout joins rows, so a column can't label its documents")
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _chat(chat_template: str | None, revision: str | None, layout: str | None):
|
|
397
|
+
"""Return the chat formatter of ``chat_template`` and its pinned revision, or ``None``s.
|
|
398
|
+
|
|
399
|
+
Raises ``ValueError`` with a ``layout``, which joins rows of text, not conversations.
|
|
400
|
+
"""
|
|
401
|
+
if not chat_template:
|
|
402
|
+
return None, None
|
|
403
|
+
if layout:
|
|
404
|
+
raise ValueError("a layout joins rows of text, not conversations")
|
|
405
|
+
return chat_formatter(chat_template, revision)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _document_labels(
|
|
409
|
+
texts: list[str], globs: list[str], lang_pattern: str | None, detect_lang: bool
|
|
410
|
+
) -> tuple[dict, str | None]:
|
|
411
|
+
"""Return the ``lang`` labels, if the corpus has them, and the detector that gave them."""
|
|
412
|
+
if detect_lang:
|
|
413
|
+
from openinterp import langid
|
|
414
|
+
|
|
415
|
+
return {"lang": langid.detect(texts)}, langid.DETECTOR
|
|
416
|
+
return ({"lang": [_glob_lang(lang_pattern, g) for g in globs]} if lang_pattern else {}), None
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _code_labels(
|
|
420
|
+
labels: dict, texts: list[str], column: str | None, exact: list, detect_code: bool
|
|
421
|
+
) -> str | None:
|
|
422
|
+
"""Add each document's programming language to ``labels``, if the corpus has them.
|
|
423
|
+
|
|
424
|
+
With a dataset ``column``, ``exact`` holds its values. With ``detect_code``,
|
|
425
|
+
:mod:`openinterp.codeid` detects the languages. Returns the detector, or ``None``.
|
|
426
|
+
"""
|
|
427
|
+
if column:
|
|
428
|
+
labels["code_language"] = exact
|
|
429
|
+
elif detect_code:
|
|
430
|
+
from openinterp import codeid
|
|
431
|
+
|
|
432
|
+
labels["code_language"] = codeid.detect(texts)
|
|
433
|
+
return codeid.detector()
|
|
434
|
+
return None
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _read_globs(fs, repo: str, files: str, documents: int, take):
|
|
438
|
+
"""Return the documents that ``take(paths, documents=n)`` reads from ``files``' globs.
|
|
439
|
+
|
|
440
|
+
Also returns the glob that each came from. Several globs (for example, one per
|
|
441
|
+
language) give an equal share each, and the first globs take the extras.
|
|
442
|
+
"""
|
|
443
|
+
texts, origins = [], []
|
|
444
|
+
globs = files.split(",")
|
|
445
|
+
for i, glob in enumerate(globs):
|
|
446
|
+
paths = fs.glob(f"datasets/{repo}/{glob}")
|
|
447
|
+
if not paths:
|
|
448
|
+
raise FileNotFoundError(f"no files match {glob!r} in {repo}")
|
|
449
|
+
share = documents // len(globs) + (i < documents % len(globs))
|
|
450
|
+
texts += take(paths, documents=share)
|
|
451
|
+
origins += [glob] * (len(texts) - len(origins))
|
|
452
|
+
return texts, origins
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _glob_lang(pattern: str | None, glob: str) -> str | None:
|
|
456
|
+
match = re.search(pattern, glob) if pattern else None
|
|
457
|
+
if pattern and match is None:
|
|
458
|
+
raise ValueError(f"{glob!r} names no language under the pattern {pattern!r}")
|
|
459
|
+
if match is None:
|
|
460
|
+
return None
|
|
461
|
+
return FINEWEB2_LANGUAGES.get(match.group(1), match.group(1))
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _license(api, dataset: str, info, revision: str | None):
|
|
465
|
+
"""The dataset card's licence (a string or a list), else :data:`UNSTATED_LICENSES`'s."""
|
|
466
|
+
if info.card_data is None and revision is not None:
|
|
467
|
+
# Branches like refs/convert/parquet carry no dataset card: use the default branch's.
|
|
468
|
+
card_data = api.dataset_info(dataset).card_data
|
|
469
|
+
else:
|
|
470
|
+
card_data = info.card_data
|
|
471
|
+
card = card_data.to_dict() if card_data else {}
|
|
472
|
+
# OpenWebMath's card puts the licence inside its single `dataset_info`, which is a mapping.
|
|
473
|
+
# Cards with several configs have a list there, and it holds no licence.
|
|
474
|
+
nested = card.get("dataset_info")
|
|
475
|
+
nested_license = nested.get("license") if isinstance(nested, dict) else None
|
|
476
|
+
return card.get("license") or nested_license or UNSTATED_LICENSES.get(dataset)
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""On-disk cache of the Parquet data that ``open-interpretability corpus`` reads from the Hub.
|
|
2
|
+
|
|
3
|
+
A file at a dataset commit never changes, so an entry can't go stale. The cache keeps only
|
|
4
|
+
paths that name a full commit hash, as ``_read_globs`` in :mod:`openinterp.corpus` writes
|
|
5
|
+
them. It stores each file's row-group count, and each row group's columns.
|
|
6
|
+
|
|
7
|
+
A repeat of a build reads almost nothing from row groups on the Hub, for example
|
|
8
|
+
``open-interpretability corpus --same-as``. A build with a new seed reads other row groups, so it
|
|
9
|
+
saves little. A cache failure never fails a build: the code reads the Hub instead. Once
|
|
10
|
+
the cache outgrows its size cap, the least recently used entries go.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import secrets
|
|
19
|
+
from collections.abc import Sequence
|
|
20
|
+
from contextlib import suppress
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
import pyarrow as pa
|
|
24
|
+
import pyarrow.parquet as pq
|
|
25
|
+
|
|
26
|
+
from openinterp.core.cache_dirs import cache_root
|
|
27
|
+
|
|
28
|
+
ENV = "OPENINTERP_CORPUS_CACHE" # cache directory, or "off"
|
|
29
|
+
SIZE_ENV = "OPENINTERP_CORPUS_CACHE_GB"
|
|
30
|
+
DEFAULT_GB = 5.0 # a 500-document build stores about 100 MB at most
|
|
31
|
+
_PINNED = re.compile(r"@[0-9a-f]{40}/")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ParquetCache:
|
|
35
|
+
"""The cache under ``root``, capped at ``max_bytes``."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, root: Path, max_bytes: int | None = None) -> None:
|
|
38
|
+
self.root = Path(root)
|
|
39
|
+
if max_bytes is None:
|
|
40
|
+
max_bytes = int(float(os.environ.get(SIZE_ENV, DEFAULT_GB)) * 1e9)
|
|
41
|
+
self.max_bytes = max_bytes
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def default(cls) -> ParquetCache | None:
|
|
45
|
+
"""The cache at the default location, or ``None`` when caching is off."""
|
|
46
|
+
root = cache_root(ENV, "corpora")
|
|
47
|
+
return None if root is None else cls(root)
|
|
48
|
+
|
|
49
|
+
def row_groups(self, fs, path: str) -> int:
|
|
50
|
+
"""Return how many row groups the file at ``path`` has."""
|
|
51
|
+
entry = self.root / f"{_key(path)}.groups"
|
|
52
|
+
with suppress(OSError, ValueError):
|
|
53
|
+
count = int(entry.read_text(encoding="utf-8"))
|
|
54
|
+
os.utime(entry)
|
|
55
|
+
return count
|
|
56
|
+
count = _remote_row_groups(fs, path)
|
|
57
|
+
self._store(entry, lambda tmp: tmp.write_text(str(count), encoding="utf-8"))
|
|
58
|
+
return count
|
|
59
|
+
|
|
60
|
+
def read(self, fs, path: str, group: int, columns: Sequence[str]) -> pa.Table:
|
|
61
|
+
"""Return ``columns`` of one row group of the file at ``path``."""
|
|
62
|
+
entry = self.root / f"{_key(path, str(group), *columns)}.parquet"
|
|
63
|
+
with suppress(OSError, pa.ArrowInvalid):
|
|
64
|
+
table = pq.read_table(entry)
|
|
65
|
+
os.utime(entry)
|
|
66
|
+
return table
|
|
67
|
+
table = _remote_read(fs, path, group, columns)
|
|
68
|
+
self._store(entry, lambda tmp: pq.write_table(table, tmp, compression="zstd"))
|
|
69
|
+
return table
|
|
70
|
+
|
|
71
|
+
def entries(self) -> list[Path]:
|
|
72
|
+
"""Every entry, least recently used first."""
|
|
73
|
+
found = []
|
|
74
|
+
for path in self.root.glob("*"):
|
|
75
|
+
with suppress(OSError): # another build can evict it during this loop
|
|
76
|
+
found.append((path.stat().st_mtime, path))
|
|
77
|
+
return [path for _, path in sorted(found)]
|
|
78
|
+
|
|
79
|
+
def size(self, entries: Sequence[Path] | None = None) -> int:
|
|
80
|
+
"""Return the bytes that ``entries`` use, by default the whole cache."""
|
|
81
|
+
total = 0
|
|
82
|
+
for path in self.entries() if entries is None else entries:
|
|
83
|
+
with suppress(OSError):
|
|
84
|
+
total += path.stat().st_size
|
|
85
|
+
return total
|
|
86
|
+
|
|
87
|
+
def evict(self, keep: Path | None = None) -> None:
|
|
88
|
+
"""Delete least recently used entries until the cache fits its cap."""
|
|
89
|
+
entries = self.entries()
|
|
90
|
+
total = self.size(entries)
|
|
91
|
+
for path in entries:
|
|
92
|
+
if total <= self.max_bytes:
|
|
93
|
+
return
|
|
94
|
+
if path != keep:
|
|
95
|
+
total -= self.size([path])
|
|
96
|
+
path.unlink(missing_ok=True)
|
|
97
|
+
|
|
98
|
+
def clear(self) -> int:
|
|
99
|
+
"""Delete every entry and return how many there were."""
|
|
100
|
+
entries = self.entries()
|
|
101
|
+
for path in entries:
|
|
102
|
+
path.unlink(missing_ok=True)
|
|
103
|
+
return len(entries)
|
|
104
|
+
|
|
105
|
+
def _store(self, entry: Path, write) -> None:
|
|
106
|
+
"""Write ``entry`` through a temporary file, so a concurrent read never sees part of it."""
|
|
107
|
+
tmp = entry.with_name(f"{entry.name}.{secrets.token_hex(4)}.tmp")
|
|
108
|
+
with suppress(OSError):
|
|
109
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
110
|
+
try:
|
|
111
|
+
write(tmp)
|
|
112
|
+
tmp.replace(entry)
|
|
113
|
+
finally:
|
|
114
|
+
tmp.unlink(missing_ok=True)
|
|
115
|
+
self.evict(keep=entry)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def row_groups(fs, path: str) -> int:
|
|
119
|
+
"""Return how many row groups the file has, through the cache if ``path`` is pinned."""
|
|
120
|
+
cache = _cache_for(path)
|
|
121
|
+
return cache.row_groups(fs, path) if cache else _remote_row_groups(fs, path)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def read_row_group(fs, path: str, group: int, columns: Sequence[str]) -> pa.Table:
|
|
125
|
+
"""Return ``columns`` of one row group, through the cache if ``path`` is pinned."""
|
|
126
|
+
cache = _cache_for(path)
|
|
127
|
+
return cache.read(fs, path, group, columns) if cache else _remote_read(fs, path, group, columns)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _cache_for(path: str) -> ParquetCache | None:
|
|
131
|
+
return ParquetCache.default() if _PINNED.search(path) else None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _key(*parts: str) -> str:
|
|
135
|
+
return hashlib.sha256("\0".join(parts).encode()).hexdigest()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _remote_row_groups(fs, path: str) -> int:
|
|
139
|
+
with fs.open(path, "rb") as fh:
|
|
140
|
+
return pq.ParquetFile(fh).num_row_groups
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _remote_read(fs, path: str, group: int, columns: Sequence[str]) -> pa.Table:
|
|
144
|
+
with fs.open(path, "rb") as fh:
|
|
145
|
+
return pq.ParquetFile(fh).read_row_group(group, columns=list(columns))
|