File size: 9,954 Bytes
5952424 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 | #!/usr/bin/env python3
"""Stage 1: normalize + sentence-tokenize every record of every tier.
Outputs work/sentences/<tier>/shard_NNNN.parquet with columns:
rid : unique record id (inscriptions: "phi<ID>:<field>")
source : provenance tag
zone : int8 preliminary zone (-1 = literary pristine, assigned in stage 3;
0-9 unused here; 10=PTEST, 11=PVAL, 12=TRAIN)
text : original text of the unit
starts, ends : list<int32> sentence spans (concatenation of spans == text)
skels : list<str> per-sentence normalized skeletons
Zone rule (fixed across folds, Ithaca-compatible): PHI/TM number ending in
3 -> PTEST, 4 -> PVAL, anything else -> TRAIN. Applied to source ddbdp/dclp in
ANY tier and to all Inscriptions_2 records. ddbdp ids carry no TM; they are
joined to TM via the original extraction files (99.97% coverage);
the remainder falls back to xxhash64(id) last digit (counted in stats).
"""
import argparse
import glob
import json
import os
import sys
from concurrent.futures import ProcessPoolExecutor
import orjson
import pyarrow as pa
import pyarrow.parquet as pq
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from common import skeleton, sentence_spans, ZONE_PTEST, ZONE_PVAL, ZONE_TRAIN
import xxhash
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
RAW = os.path.join(ROOT, "raw")
OUT = os.path.join(ROOT, "work", "sentences")
DDBDP_JSONL = os.path.expandvars("$STOICHEIA_DATA/clean/ddbdp.jsonl")
PAPYRI_TM_JSONL = os.path.expandvars("$STOICHEIA_DATA/data/papyri_clean.jsonl")
INSCR_JSONL = os.path.join(RAW, "Inscriptions_2", "synthetic_editions_with_ithaca_text_fix.jsonl")
INSCR_FIELDS = ["edition", "with_diacritics", "without_diacritics",
"synthetic", "synthetic_2", "ithaca_text"]
SCHEMA = pa.schema([
("rid", pa.string()), ("source", pa.string()), ("zone", pa.int8()),
("text", pa.string()),
("starts", pa.list_(pa.int32())), ("ends", pa.list_(pa.int32())),
("skels", pa.list_(pa.string())),
])
ROWS_PER_SHARD = 25000
def build_tm_map():
tm_by_base = {}
with open(PAPYRI_TM_JSONL, "rb") as f:
for line in f:
r = orjson.loads(line)
tm_by_base[r["file"]] = str(r["TM"])
id2tm = {}
with open(DDBDP_JSONL, "rb") as f:
for line in f:
r = orjson.loads(line)
base = r["file"].rsplit("/", 1)[-1]
tm = tm_by_base.get(base)
if tm is not None:
id2tm[r["id"]] = tm
return id2tm
def digit_zone(numstr):
d = numstr.rstrip()[-1]
if d == "3":
return ZONE_PTEST
if d == "4":
return ZONE_PVAL
return ZONE_TRAIN
def zone_for(source, rid, tier, id2tm, stats):
if source == "dclp":
return digit_zone(rid.split("_")[0])
if source == "ddbdp":
tm = id2tm.get(rid) or id2tm.get(rid.split("#")[0])
if tm is None:
stats["ddbdp_tm_fallback"] = stats.get("ddbdp_tm_fallback", 0) + 1
return digit_zone(str(xxhash.xxh64_intdigest(rid) % 10))
return digit_zone(tm)
if source == "phi":
return digit_zone(rid.split(":")[0].replace("phi", ""))
if tier == "pristine":
return -1 # literary pristine: bucket assigned in stage 3
return ZONE_TRAIN
def tokenize_row(rid, source, zone, text, cols):
spans = sentence_spans(text)
starts, ends, skels = [], [], []
for s, e in spans:
sk = skeleton(text[s:e])
if not sk:
# keep coverage: merge into previous span
if ends:
ends[-1] = e
continue
starts.append(s)
ends.append(e)
skels.append(sk)
cols["rid"].append(rid)
cols["source"].append(source)
cols["zone"].append(zone)
cols["text"].append(text)
cols["starts"].append(starts)
cols["ends"].append(ends)
cols["skels"].append(skels)
def new_cols():
return {k: [] for k in ("rid", "source", "zone", "text", "starts", "ends", "skels")}
def flush(cols, tier, shard_idx):
if not cols["rid"]:
return 0
t = pa.table(cols, schema=SCHEMA)
os.makedirs(os.path.join(OUT, tier), exist_ok=True)
pq.write_table(t, os.path.join(OUT, tier, "shard_%05d.parquet" % shard_idx),
compression="zstd")
return len(cols["rid"])
# ------------------------------------------------------------ task workers
def do_parquet_task(args):
tier, path, rg, offset, length, shard_idx, id2tm = args
stats = {"records": 0, "sentences": 0, "zones": {}}
pf = pq.ParquetFile(path)
t = pf.read_row_group(rg, columns=["source", "id", "text"])
t = t.slice(offset, length)
cols = new_cols()
for source, rid, text in zip(t["source"].to_pylist(), t["id"].to_pylist(),
t["text"].to_pylist()):
z = zone_for(source, rid, tier, id2tm, stats)
tokenize_row(rid, source, z, text, cols)
stats["records"] += 1
stats["sentences"] += len(cols["skels"][-1])
stats["zones"][z] = stats["zones"].get(z, 0) + 1
n_written = 0
# split into multiple shards if the row group is large
n = len(cols["rid"])
for off in range(0, n, ROWS_PER_SHARD):
part = {k: v[off:off + ROWS_PER_SHARD] for k, v in cols.items()}
flush(part, tier, shard_idx + off // ROWS_PER_SHARD)
n_written += 1
return stats
def loads_lenient(line):
"""orjson rejects bare NaN (pandas-written rows); stdlib json accepts it."""
try:
return orjson.loads(line)
except orjson.JSONDecodeError:
return json.loads(line)
def do_jsonl_task(args):
tier, path, byte_start, byte_end, shard_idx = args
stats = {"records": 0, "sentences": 0, "zones": {}}
cols = new_cols()
shard_off = 0
with open(path, "rb") as f:
f.seek(byte_start)
if byte_start > 0:
f.readline() # skip partial line (owned by previous chunk)
while f.tell() <= byte_end:
line = f.readline()
if not line:
break
r = loads_lenient(line)
if tier == "bronze":
units = [(r["id"], "bronze", r["text"])]
else: # inscriptions
phi = str(r["PHI_ID"])
units = [("phi%s:%s" % (phi, fld), "phi", r[fld])
for fld in INSCR_FIELDS
if isinstance(r.get(fld), str) and r[fld].strip()]
for rid, source, text in units:
z = zone_for(source, rid, tier, None, stats)
tokenize_row(rid, source, z, str(text), cols)
stats["records"] += 1
stats["sentences"] += len(cols["skels"][-1])
stats["zones"][z] = stats["zones"].get(z, 0) + 1
if len(cols["rid"]) >= ROWS_PER_SHARD:
flush(cols, tier, shard_idx + shard_off)
shard_off += 1
cols = new_cols()
flush(cols, tier, shard_idx + shard_off)
return stats
def jsonl_tasks(tier, path, n_chunks, shard_base, shards_per_chunk=400):
size = os.path.getsize(path)
step = size // n_chunks + 1
tasks = []
for i in range(n_chunks):
tasks.append((tier, path, i * step, min((i + 1) * step, size) - 1,
shard_base + i * shards_per_chunk))
return tasks
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--workers", type=int, default=max(4, os.cpu_count() - 8))
ap.add_argument("--tiers", default="pristine,repaired,bronze,inscriptions")
args = ap.parse_args()
tiers = args.tiers.split(",")
id2tm = build_tm_map()
print("ddbdp id->TM map: %d entries" % len(id2tm), flush=True)
tasks = []
if "pristine" in tiers or "repaired" in tiers:
for tier in ("pristine", "repaired"):
if tier not in tiers:
continue
shard_idx = 0
for path in sorted(glob.glob(os.path.join(
RAW, "AncientGreek", "data", tier, "*.parquet"))):
pf = pq.ParquetFile(path)
for rg in range(pf.num_row_groups):
nrows = pf.metadata.row_group(rg).num_rows
for off in range(0, nrows, ROWS_PER_SHARD):
ln = min(ROWS_PER_SHARD, nrows - off)
tasks.append((tier, "pq",
(tier, path, rg, off, ln, shard_idx, id2tm)))
shard_idx += 1
if "bronze" in tiers:
tasks += [("bronze", "jl", t) for t in
jsonl_tasks("bronze", os.path.join(RAW, "bronze.jsonl"), 96, 0)]
if "inscriptions" in tiers:
tasks += [("inscriptions", "jl", t) for t in
jsonl_tasks("inscriptions", INSCR_JSONL, 48, 0)]
print("%d tasks" % len(tasks), flush=True)
agg = {}
with ProcessPoolExecutor(max_workers=args.workers) as ex:
futs = []
for tier, kind, t in tasks:
fn = do_parquet_task if kind == "pq" else do_jsonl_task
futs.append((tier, ex.submit(fn, t)))
for i, (tier, f) in enumerate(futs):
st = f.result()
a = agg.setdefault(tier, {"records": 0, "sentences": 0, "zones": {},
"ddbdp_tm_fallback": 0})
a["records"] += st["records"]
a["sentences"] += st["sentences"]
a["ddbdp_tm_fallback"] += st.get("ddbdp_tm_fallback", 0)
for z, c in st["zones"].items():
a["zones"][z] = a["zones"].get(z, 0) + c
if (i + 1) % 25 == 0:
print(" %d/%d tasks done" % (i + 1, len(futs)), flush=True)
os.makedirs(os.path.join(ROOT, "work"), exist_ok=True)
with open(os.path.join(ROOT, "work", "stage1_stats.json"), "w") as f:
json.dump(agg, f, indent=2, default=str)
print(json.dumps(agg, indent=2, default=str))
if __name__ == "__main__":
main()
|