diff --git a/.github/workflows/check_notebooks.yml b/.github/workflows/check_notebooks.yml index 391512cf..3bf0168c 100644 --- a/.github/workflows/check_notebooks.yml +++ b/.github/workflows/check_notebooks.yml @@ -13,15 +13,15 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Set up Python 3.12 - uses: actions/setup-python@v5 + + - name: Install uv + uses: astral-sh/setup-uv@v7 with: - python-version: "3.12" - cache: 'pip' - cache-dependency-path: '**/pyproject.toml' + enable-cache: true + # uses .python-version if not specified - name: Install package with notebook dependencies - run: pip install -e ".[notebooks]" + run: uv sync --extra notebooks # copy notebook config file to current directory - name: Check jupyter notebooks with treon @@ -29,4 +29,4 @@ jobs: # run treon to confirm that notebooks run with current code - name: Check jupyter notebooks with treon - run: treon notebooks/excerpt_overlap_review.ipynb notebooks/poetry_excerpt_review.ipynb + run: uv run treon notebooks/excerpt_overlap_review.ipynb notebooks/poetry_excerpt_review.ipynb diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 8023a6e1..c7c1d166 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python: ["3.11", "3.12"] + python: ["3.12", "3.13"] defaults: run: working-directory: . @@ -23,28 +23,17 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + - name: Install uv and set the Python version + uses: astral-sh/setup-uv@v7 with: python-version: ${{ matrix.python }} - - # base the python cache on the hash of all pyproject.toml, - # which includes python requirements. - # if any change, the cache is invalidated. - - name: Cache pip - uses: actions/cache@v4 - with: - path: ~/.cache/pip - key: pip-${{ hashFiles('pyproject.toml') }} - restore-keys: | - pip-${{ hashFiles('pyproject.toml') }} - pip- + enable-cache: true - name: Install dependencies - run: pip install -e ".[test]" + run: uv sync --extra test - name: Run pytest - run: pytest --cov=corppa --cov=test --cov-report=xml + run: uv run pytest --cov=corppa --cov=test --cov-report=xml - name: Upload test coverage to Codecov uses: codecov/codecov-action@v4 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 55bb4dd3..6a33a6dd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,5 +7,10 @@ repos: rev: v0.3.4 hooks: - id: ruff - args: [ --select, I, --fix, --exit-non-zero-on-fix ] + args: [ --fix, --exit-non-zero-on-fix ] - id: ruff-format + # validate GitHub Actions workflow files + - repo: https://github.com/mpalmer/action-validator + rev: v0.8.0 + hooks: + - id: action-validator diff --git a/CHANGELOG.md b/CHANGELOG.md index 7df804b1..90ae12ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,34 @@ # CHANGELOG -## 0.4.0 +## [0.5] 2026-05-12 + +Updates to support publication of PPA found poems v0.5 dataset + +### Poetry Detection +- Logic for managing poetry detection configuration for reference corpora + and found poems dataset compilation; a sample config file is provided + - Compile dataset script uses config file to combine excerpts and + metadata (poem and PPA) for publishable dataset + - `merge_excerpts` method now combines any excerpts with matching + spans in PPA text; conflicting poem ids are preserved in `alt_poem_ids` + - Poem metadata now supports optional `cluster_id` for known duplicates +- ReferenceCorpus classes for a consistent way to access reference corpora + metadata and text +- New utility methods for working with PPA metadata when compiling or loading excerpt data + in `corppa.poetry_dection.ppa_works` +- Now supports building text corpus from tar.gz file in addition to directory of text files +- Update `run_passim` script to use same corppa-specific defaults for command line and `run_passim` method +- Includes new marimo notebooks for exploring found poems excerpt data + +### Misc +- Now supports and tested against both Python 3.12 and 3.13; dropped support for 3.11 +- Now using uv for GitHub Actions and local development +- Added pre-commit hook to validate GitHub Actions workflow files +- Sphinx code documentation split out into files matching python modules +- Experimental scripts to subset excerpt data and upload to Grist +- Configured intentionally untested code to be exempted from code coverage (CH TML parsing) + +## [0.4] 2025-04-30 - Now supports and tested against both Python 3.11 and 3.12 - Now licensed under Apache 2 ### Documentation @@ -35,7 +63,7 @@ - Increased use of Python type hinting - Configured codecov with separate reporting for tests and whole project, with different targets for coverage -## 0.3.0 +## [0.3] 2024-11-01 - New dependency: intspan ### Poetry Detection - New Prodigy recipe for adjudicating text annotations @@ -45,7 +73,7 @@ ### Misc - Fixed Codecov integration -## 0.2.0 +## [0.2] 2024-10-07 - Now requires Python 3.12 ### Corppa Utilities - Basic readme documentation for filter script @@ -64,8 +92,15 @@ - Ruff precommit hook now configured to autofix import order -## 0.1.0 +## [0.1] 2024-06-05 - Utility to filter the full text corpus by source ID - Experimental Scripts - OCR evaluation - Character-level statistics + + +[0.1]: https://github.com/Princeton-CDH/corppa/releases/tag/0.1 +[0.2]: https://github.com/Princeton-CDH/corppa/releases/tag/0.2 +[0.3]: https://github.com/Princeton-CDH/corppa/releases/tag/0.3 +[0.4]: https://github.com/Princeton-CDH/corppa/releases/tag/0.4 +[0.5]: https://github.com/Princeton-CDH/corppa/releases/tag/0.5 \ No newline at end of file diff --git a/codecov.yml b/codecov.yml index 1be1f106..960149a4 100644 --- a/codecov.yml +++ b/codecov.yml @@ -11,6 +11,6 @@ coverage: tests: # declare a new status context for "tests" target: 100% # we always want 100% coverage here paths: - - "test/" # only include coverage in "tests/" folder + - "tests/" # only include coverage in "tests/" folder diff --git a/docs/source/LICENSE.md b/docs/source/LICENSE.md new file mode 100644 index 00000000..165e2cc4 --- /dev/null +++ b/docs/source/LICENSE.md @@ -0,0 +1,7 @@ +--- +orphan: true +--- +``` +{include} ../../LICENSE.md +``` + diff --git a/docs/source/code-docs.rst b/docs/source/code-docs.rst deleted file mode 100644 index 454e16e3..00000000 --- a/docs/source/code-docs.rst +++ /dev/null @@ -1,95 +0,0 @@ -Code Documentation -################## - -.. toctree:: - :maxdepth: 2 - -OCR -=== -.. automodule:: corppa.ocr.gvision_ocr - :members: - - -Collate Texts -------------- -.. automodule:: corppa.ocr.collate_txt -.. Note: not including the members for the method docs, *but* we should we -.. make the top-level comment better. - - -Utils -===== - -Filter Utility --------------- -.. automodule:: corppa.utils.filter -.. Note: not including members for method docs, only top-level script usage - -Path Utilities --------------- -.. automodule:: corppa.utils.path_utils - :members: - -Generate PPA Page Set ----------------------- -.. automodule:: corppa.utils.generate_page_set -.. Note: not including members for method docs, only top-level script usage - -Add Image (Relative) Paths ---------------------------- -.. automodule:: corppa.utils.add_image_relpaths -.. Note: not including members for method docs, only top-level script usage - -Build Text Corpus ------------------ -.. automodule:: corppa.utils.build_text_corpus -.. Note: not including members for method docs, only top-level script usage - - -Annotation -========== - -Data Preparation ------------------ -Preliminary Page Set Creation -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -.. automodule:: corppa.poetry_detection.annotation.create_pageset -.. Note: not including members for method docs, only top-level script usage - -Add Metadata -^^^^^^^^^^^^ -.. automodule:: corppa.poetry_detection.annotation.add_metadata -.. Note: not including members for method docs, only top-level script usage - -Annotation Recipes ------------------- -.. automodule:: corppa.poetry_detection.annotation.annotation_recipes -.. Note: not including members for method docs, only top-level script usage - -Command Recipes ---------------- -.. automodule:: corppa.poetry_detection.annotation.command_recipes -.. Note: not including members for method docs, only top-level script usage - -Process Adjudication Data -------------------------- -.. automodule:: corppa.poetry_detection.annotation.process_adjudication_data -.. Note: not including members for method docs, only top-level script usage - - -Poetry Detection -================ - -Scripts -------- - -refmatcha -^^^^^^^^^ - -.. automodule:: corppa.poetry_detection.refmatcha - -Merge excerpts -^^^^^^^^^^^^^^ - -.. automodule:: corppa.poetry_detection.merge_excerpts -.. Note: not including members for method docs, only top-level script usage \ No newline at end of file diff --git a/docs/source/code-docs/annotation.rst b/docs/source/code-docs/annotation.rst new file mode 100644 index 00000000..8bbb8dc7 --- /dev/null +++ b/docs/source/code-docs/annotation.rst @@ -0,0 +1,30 @@ +Annotation +########## + +Data Preparation +================ + +Preliminary Page Set Creation +------------------------------ +.. automodule:: corppa.poetry_detection.annotation.create_pageset +.. Note: not including members for method docs, only top-level script usage + +Add Metadata +------------ +.. automodule:: corppa.poetry_detection.annotation.add_metadata +.. Note: not including members for method docs, only top-level script usage + +Annotation Recipes +================== +.. automodule:: corppa.poetry_detection.annotation.annotation_recipes +.. Note: not including members for method docs, only top-level script usage + +Command Recipes +=============== +.. automodule:: corppa.poetry_detection.annotation.command_recipes +.. Note: not including members for method docs, only top-level script usage + +Process Adjudication Data +========================= +.. automodule:: corppa.poetry_detection.annotation.process_adjudication_data +.. Note: not including members for method docs, only top-level script usage diff --git a/docs/source/code-docs/index.rst b/docs/source/code-docs/index.rst new file mode 100644 index 00000000..ca5a4ef2 --- /dev/null +++ b/docs/source/code-docs/index.rst @@ -0,0 +1,10 @@ +Code Documentation +################## + +.. toctree:: + :maxdepth: 2 + + ocr + utils + annotation + poetry-detection diff --git a/docs/source/code-docs/ocr.rst b/docs/source/code-docs/ocr.rst new file mode 100644 index 00000000..7c7938e8 --- /dev/null +++ b/docs/source/code-docs/ocr.rst @@ -0,0 +1,12 @@ +OCR +### + +.. automodule:: corppa.ocr.gvision_ocr + :members: + + +Collate Texts +============= +.. automodule:: corppa.ocr.collate_txt +.. Note: not including the members for the method docs, *but* we should we +.. make the top-level comment better. diff --git a/docs/source/code-docs/poetry-detection.rst b/docs/source/code-docs/poetry-detection.rst new file mode 100644 index 00000000..7cc44295 --- /dev/null +++ b/docs/source/code-docs/poetry-detection.rst @@ -0,0 +1,29 @@ +Poetry Detection +################ + +Core objects +============ + +.. automodule:: corppa.poetry_detection.core + :members: + +Reference Corpora +================= +.. automodule:: corppa.poetry_detection.ref_corpora + :members: + + + +Scripts +======= + +refmatcha +--------- + +.. automodule:: corppa.poetry_detection.refmatcha + +Merge excerpts +-------------- + +.. automodule:: corppa.poetry_detection.merge_excerpts + :members: diff --git a/docs/source/code-docs/utils.rst b/docs/source/code-docs/utils.rst new file mode 100644 index 00000000..994e9318 --- /dev/null +++ b/docs/source/code-docs/utils.rst @@ -0,0 +1,27 @@ +Utils +##### + +Filter Utility +============== +.. automodule:: corppa.utils.filter +.. Note: not including members for method docs, only top-level script usage + +Path Utilities +============== +.. automodule:: corppa.utils.path_utils + :members: + +Generate PPA Page Set +===================== +.. automodule:: corppa.utils.generate_page_set +.. Note: not including members for method docs, only top-level script usage + +Add Image (Relative) Paths +========================== +.. automodule:: corppa.utils.add_image_relpaths +.. Note: not including members for method docs, only top-level script usage + +Build Text Corpus +================= +.. automodule:: corppa.utils.build_text_corpus +.. Note: not including members for method docs, only top-level script usage diff --git a/docs/source/conf.py b/docs/source/conf.py index 6f2c6874..0724dd0d 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -9,7 +9,7 @@ from corppa import __version__ project = "corppa" -copyright = "2024,2025 Center for Digital Humanities, Princeton University" +copyright = "2024—2026 Center for Digital Humanities, Princeton University" author = "Center for Digital Humanities RSE Team, Princeton University" release = __version__ @@ -65,6 +65,3 @@ "sidebar_footer.html", ], } - - - diff --git a/docs/source/index.rst b/docs/source/index.rst index 39d4492b..a3f4bd1a 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -19,5 +19,5 @@ This repository is research software developed as part of the `Ends of Prosody < Overview Developer Notes - code-docs + code-docs/index eop-docs diff --git a/notebooks/__marimo__/session/poem-excerpt-length.py.json b/notebooks/__marimo__/session/poem-excerpt-length.py.json new file mode 100644 index 00000000..a66abedd --- /dev/null +++ b/notebooks/__marimo__/session/poem-excerpt-length.py.json @@ -0,0 +1,568 @@ +{ + "version": "1", + "metadata": { + "marimo_version": "0.20.4", + "script_metadata_hash": null + }, + "cells": [ + { + "id": "Hbol", + "code_hash": "537723cf2f1e49893574f4ece30a379b", + "outputs": [ + { + "type": "data", + "data": { + "text/markdown": "

PPA found poems \u2014 poem/excerpt length over time

\nMM expects that excerpts get shorter over time as the books get smaller. We also know that poems are also getting shorter over this time. What evidence of that can we find our found poem excerpt data?
" + } + } + ], + "console": [] + }, + { + "id": "MJUe", + "code_hash": "55094988fafe294d48983b0eedf34186", + "outputs": [ + { + "type": "data", + "data": { + "text/plain": "" + } + } + ], + "console": [] + }, + { + "id": "vblA", + "code_hash": "c032e1213a9c7f05204da2ebf8542066", + "outputs": [ + { + "type": "data", + "data": { + "text/markdown": "

Poems cited in PPA

\nWe don't currently have dates in our poem metadata, but as a starting point we can look at the lengths of being poems quoted in PPA over time.
" + } + } + ], + "console": [] + }, + { + "id": "bkHC", + "code_hash": "0072cc53b4f26995f9cf0c3d7079b9db", + "outputs": [ + { + "type": "data", + "data": { + "text/html": "" + } + } + ], + "console": [] + }, + { + "id": "lEQa", + "code_hash": "8108e62b1ecbf8367fc2d21c40d52eaf", + "outputs": [ + { + "type": "data", + "data": { + "text/html": "" + } + } + ], + "console": [] + }, + { + "id": "PKri", + "code_hash": "06c322d2883a22179f240429eb34c9c4", + "outputs": [ + { + "type": "data", + "data": { + "text/html": "" + } + } + ], + "console": [] + }, + { + "id": "Xref", + "code_hash": "8cb17d90872cb18eb5cac983334a24fe", + "outputs": [ + { + "type": "data", + "data": { + "text/html": "" + } + } + ], + "console": [] + }, + { + "id": "SFPL", + "code_hash": "d15013d0d549daa18f4d8e9785d61899", + "outputs": [ + { + "type": "data", + "data": { + "text/plain": "" + } + } + ], + "console": [] + }, + { + "id": "BYtC", + "code_hash": "c6d35f07f604231ca522bdf1730262e7", + "outputs": [ + { + "type": "data", + "data": { + "text/html": "" + } + } + ], + "console": [] + }, + { + "id": "RGSE", + "code_hash": "58673bd27359a8e6f13a0e1624d17666", + "outputs": [ + { + "type": "data", + "data": { + "text/html": "" + } + } + ], + "console": [] + }, + { + "id": "Kclp", + "code_hash": "d8727917223475e25d87c71ab9cf943c", + "outputs": [ + { + "type": "data", + "data": { + "text/plain": "" + } + } + ], + "console": [] + }, + { + "id": "emfo", + "code_hash": "835db7c67cde58aa447a06d1d5f78109", + "outputs": [ + { + "type": "data", + "data": { + "text/markdown": "We can plot poem length by number of words or number of characters - but the general trend looks the same across those measurements." + } + } + ], + "console": [] + }, + { + "id": "Hstk", + "code_hash": "42ae9af5a9b53dc30d8cde4ecc093331", + "outputs": [ + { + "type": "data", + "data": { + "text/html": "" + } + } + ], + "console": [] + }, + { + "id": "nWHF", + "code_hash": "d793988107eb1dad0c1e74b6802a139a", + "outputs": [ + { + "type": "data", + "data": { + "text/html": "" + } + } + ], + "console": [] + }, + { + "id": "iLit", + "code_hash": "4eb43305aff3b54d271d9e720180cbc8", + "outputs": [ + { + "type": "data", + "data": { + "text/markdown": "

Poem length by first appearance in PPA

" + } + } + ], + "console": [] + }, + { + "id": "ZHCJ", + "code_hash": "9c04bd15a4c7e8bedf0d1e5108a3cb67", + "outputs": [ + { + "type": "data", + "data": { + "text/html": "" + } + } + ], + "console": [] + }, + { + "id": "ROlb", + "code_hash": "e4cdd78d539c32943f968f8e0740b401", + "outputs": [ + { + "type": "data", + "data": { + "text/html": "" + } + } + ], + "console": [] + }, + { + "id": "qnkX", + "code_hash": "467a9a28e0f7cdcf531fd919e96726c2", + "outputs": [ + { + "type": "data", + "data": { + "text/html": "" + } + } + ], + "console": [] + }, + { + "id": "TqIu", + "code_hash": "78bcfb287fe28dc35883d7999f91b114", + "outputs": [ + { + "type": "data", + "data": { + "text/html": "" + } + } + ], + "console": [] + }, + { + "id": "Vxnm", + "code_hash": "79801d8494b6cd7d66641920a78f39a3", + "outputs": [ + { + "type": "data", + "data": { + "text/html": "" + } + } + ], + "console": [] + }, + { + "id": "DnEU", + "code_hash": "d8d76843bb8fc986d7894d921a180a49", + "outputs": [ + { + "type": "data", + "data": { + "text/html": "" + } + } + ], + "console": [] + }, + { + "id": "ulZA", + "code_hash": "452865be80ac4e839565ecb781228b1f", + "outputs": [ + { + "type": "data", + "data": { + "text/markdown": "As a way of checking & inspecting the above charts, what are the longest poems in each decade, based on first appearance in PPA?" + } + } + ], + "console": [] + }, + { + "id": "ecfG", + "code_hash": "46f3745a0b42e11b5b35ce54fb8bae64", + "outputs": [ + { + "type": "data", + "data": { + "text/html": "" + } + } + ], + "console": [] + }, + { + "id": "Pvdt", + "code_hash": "3122ae9d7c639d31915fc01a690fc215", + "outputs": [ + { + "type": "data", + "data": { + "text/markdown": "How can Shelley's \"The Cenci\" be quoted in 1532 ? Is this really in our data?" + } + } + ], + "console": [] + }, + { + "id": "ZBYS", + "code_hash": "7740d19eee35ec93a4baad46ff5ffb2f", + "outputs": [ + { + "type": "data", + "data": { + "text/html": "" + } + } + ], + "console": [] + }, + { + "id": "aLJB", + "code_hash": "c05ef44ae7ce10991059fc328d3424f7", + "outputs": [ + { + "type": "data", + "data": { + "text/markdown": "Answer: yes, it is in our data. Passim matched this line from PPA:\n
\nof this questyon (who dyd the dede) so whan there is no doubt but that the\n
\nwith this line of Shelley:\n
\nother Lurking among the rocks; there is no doubt But that the\n
\nCommon text? there is no doubt but that the
" + } + } + ], + "console": [] + }, + { + "id": "nHfw", + "code_hash": "5d65f5f26776ee93748a4de50f1aea57", + "outputs": [ + { + "type": "data", + "data": { + "text/html": "" + } + } + ], + "console": [] + }, + { + "id": "xXTn", + "code_hash": "131233381e523a5abb22d4d084dfc6d7", + "outputs": [ + { + "type": "data", + "data": { + "text/html": "" + } + } + ], + "console": [] + }, + { + "id": "AjVT", + "code_hash": "422225a885003b34649fe7f3e09c29f8", + "outputs": [ + { + "type": "data", + "data": { + "text/markdown": "Identified 47,447 suspect excerpts (PPA work publication year < poem author birth year + 10)" + } + } + ], + "console": [] + }, + { + "id": "pHFh", + "code_hash": "191aed126dea5ceeb1cbe4f1c397d3bb", + "outputs": [ + { + "type": "data", + "data": { + "text/html": "" + } + } + ], + "console": [] + }, + { + "id": "NCOB", + "code_hash": "1fad41b59e48ec393f14646b8c1cf6e2", + "outputs": [ + { + "type": "data", + "data": { + "text/html": "" + } + } + ], + "console": [] + }, + { + "id": "aqbW", + "code_hash": "dae86dde69fba9b0bb97157264f5f2bc", + "outputs": [ + { + "type": "data", + "data": { + "text/html": "" + } + } + ], + "console": [] + }, + { + "id": "TRpd", + "code_hash": "c1f01d42a0bc4ba39bcd88bc47ec39ca", + "outputs": [ + { + "type": "data", + "data": { + "text/html": "" + } + } + ], + "console": [] + }, + { + "id": "TXez", + "code_hash": "12e732823558a51c8f3440185766e959", + "outputs": [ + { + "type": "data", + "data": { + "text/markdown": "

Poem excerpt length

\nHow much of a poem is cited in a PPA work, and how does that change over time?\nTo simplify our measurement and avoid counting duplicate excerpts or excerpts split by page range, we aggregate excerpts\nand collapse the reference spans by PPA work, to determine the total length of each poem cited in each work.
" + } + } + ], + "console": [] + }, + { + "id": "dNNg", + "code_hash": "56fedfa9c8f0a25428bdcf153c50bd90", + "outputs": [ + { + "type": "data", + "data": { + "text/html": "" + } + } + ], + "console": [] + }, + { + "id": "yCnT", + "code_hash": "a959f2ecd67a48395ba7b6db2cd4b2ef", + "outputs": [ + { + "type": "data", + "data": { + "text/markdown": "Which poems are quoted from the most? (Sorting by sum of reference span lengths)" + } + } + ], + "console": [] + }, + { + "id": "wlCL", + "code_hash": "62ad7747b34064a6ea6034aae3359e73", + "outputs": [ + { + "type": "data", + "data": { + "text/html": "" + } + } + ], + "console": [] + }, + { + "id": "kqZH", + "code_hash": "988d1b87ec49df18a36280d2c0b527d2", + "outputs": [ + { + "type": "data", + "data": { + "text/markdown": "5,566 poems are quoted in full \n(based on total reference span length and percentage of poem length, which may not match exactly)" + } + } + ], + "console": [] + }, + { + "id": "wAgl", + "code_hash": "58cc6ed28ddc1b0d3171ff37074955c8", + "outputs": [ + { + "type": "data", + "data": { + "text/html": "" + } + } + ], + "console": [] + }, + { + "id": "rEll", + "code_hash": "c1246514a9874b5283bf1d467b8c7530", + "outputs": [ + { + "type": "data", + "data": { + "text/html": "" + } + } + ], + "console": [] + }, + { + "id": "dGlV", + "code_hash": "49dcd4711711c6bfb3a849b0d8ba5d66", + "outputs": [ + { + "type": "data", + "data": { + "text/markdown": "We can graph the min/max, but the maximum length is quite large and changes the scale substantially." + } + } + ], + "console": [] + }, + { + "id": "SdmI", + "code_hash": "8eeead6d6a17a8c53d38815ae7216ea6", + "outputs": [ + { + "type": "data", + "data": { + "text/html": "" + } + } + ], + "console": [] + }, + { + "id": "lgWD", + "code_hash": "e4bc5ee65ce01047d193db2c7bce6174", + "outputs": [ + { + "type": "data", + "data": { + "text/html": "" + } + } + ], + "console": [] + } + ] +} \ No newline at end of file diff --git a/notebooks/__marimo__/session/ppa-percent-poetry.py.json b/notebooks/__marimo__/session/ppa-percent-poetry.py.json new file mode 100644 index 00000000..a87ad013 --- /dev/null +++ b/notebooks/__marimo__/session/ppa-percent-poetry.py.json @@ -0,0 +1,269 @@ +{ + "version": "1", + "metadata": { + "marimo_version": "0.20.4", + "script_metadata_hash": null + }, + "cells": [ + { + "id": "Hbol", + "code_hash": "f4d18fa114b3c07a1b69d756c955f225", + "outputs": [ + { + "type": "data", + "data": { + "text/markdown": "

What percent of PPA is poetry?

\nAccording to the poetry we have detected,* what percentage of PPA is poetry, and how does that change over time?\n* We know we have not detected all of the poetry; data may include some false positives, but this is likely undercounting to some extent.
" + } + } + ], + "console": [] + }, + { + "id": "MJUe", + "code_hash": "5590afc0cb6088fe6c5516f78ca3b9c0", + "outputs": [ + { + "type": "data", + "data": { + "text/plain": "" + } + } + ], + "console": [] + }, + { + "id": "vblA", + "code_hash": "cd8cb593ef92cabc3c3eaaa1e8b3c48d", + "outputs": [ + { + "type": "data", + "data": { + "text/plain": "" + } + } + ], + "console": [] + }, + { + "id": "bkHC", + "code_hash": "dda2488e82dde38dafaf2a5bf3363b9a", + "outputs": [ + { + "type": "data", + "data": { + "text/markdown": "

Works: what proportion of PPA works have poetry detected?

\nBroad overview: this is just looking at works with any excerpts detected and those zero excerpts.\n
\nUse the toggle to control whether the bar charts are normalized or not (show as raw counts or as the percent for that date).\nMouse over to get counts; zoom in to see more detailed dates; double click to reset zoom.
" + } + } + ], + "console": [] + }, + { + "id": "lEQa", + "code_hash": "ea6c8cacdec3563f4a53cb2b66512557", + "outputs": [ + { + "type": "data", + "data": { + "text/markdown": "Of 7,061 total PPA works:\n
    \n
  • 6,221 have poetry detected (88.1%)
  • \n
  • 326 have just one excerpt detected (4.6%)
  • \n
  • 13 have at least 10 excerpts, all from only one poem (0.2%)
  • \n
  • 386 have at least 10 excerpts, all from only one poet (5.5%)
  • \n
\nMaximum numbers for a single PPA work * (likely includes duplicates)\n
    \n
  • 15,572 excerpts
  • \n
  • 1,944 poems
  • \n
  • 59 poets
  • \n
" + } + } + ], + "console": [] + }, + { + "id": "PKri", + "code_hash": "0b4442051bc9b8f8b77cbade25a9d2ec", + "outputs": [ + { + "type": "data", + "data": { + "text/html": "" + } + } + ], + "console": [] + }, + { + "id": "Xref", + "code_hash": "ca48e26e81b414fb3c75d3eb8172f285", + "outputs": [ + { + "type": "data", + "data": { + "text/html": "" + } + } + ], + "console": [] + }, + { + "id": "SFPL", + "code_hash": "d5f1aa765d3fceec0c45acfa41148863", + "outputs": [ + { + "type": "data", + "data": { + "text/markdown": "

Pages: what proportion of PPA pages have poetry detected?

\nIf we look at the page level, how many pages in PPA have any poetry detected?
" + } + } + ], + "console": [] + }, + { + "id": "BYtC", + "code_hash": "78c301b39736dfb53bfe2329cc31fb19", + "outputs": [ + { + "type": "data", + "data": { + "text/plain": "" + } + } + ], + "console": [] + }, + { + "id": "RGSE", + "code_hash": "2b27fedf625b9d05abd9cb26ee3475c8", + "outputs": [ + { + "type": "data", + "data": { + "text/markdown": "Of 1,982,321 total pages in PPA:\n
    \n
  • 481,293 pages have poetry detected (24.3%)
  • \n
  • 225,943 pages have just one excerpt detected (11.4%)
  • \n
  • 264,259 pages have excerpts from a single poem (13.3%)
  • \n
\nMaximum numbers for a single PPA page * (likely includes duplicates)\n
    \n
  • 72 excerpts
  • \n
  • 40 poems
  • \n
" + } + } + ], + "console": [] + }, + { + "id": "Kclp", + "code_hash": "0ab1279637f1555b16dd5b267b5e18cb", + "outputs": [ + { + "type": "data", + "data": { + "text/html": "" + } + } + ], + "console": [] + }, + { + "id": "emfo", + "code_hash": "8860eee0a0fa6f2c09182d2be68e891c", + "outputs": [ + { + "type": "data", + "data": { + "text/html": "" + } + } + ], + "console": [] + }, + { + "id": "Hstk", + "code_hash": "efdd33f590289247fc72767240053115", + "outputs": [ + { + "type": "data", + "data": { + "text/markdown": "

Text: what proportion of PPA text has been detected as poetry?

\nIf we look at page text at the character level, what portion of the text is been included in any of the detected poetry excerpt spans?
" + } + } + ], + "console": [] + }, + { + "id": "nWHF", + "code_hash": "54bb6b3cf7d186a203091f464962bd2f", + "outputs": [ + { + "type": "data", + "data": { + "text/plain": "" + } + } + ], + "console": [] + }, + { + "id": "iLit", + "code_hash": "565cbef4007a8b2ddaf98f7e62f43889", + "outputs": [ + { + "type": "data", + "data": { + "text/html": "" + } + } + ], + "console": [] + }, + { + "id": "ZHCJ", + "code_hash": "fbef5f51266971e305723002b82c85e9", + "outputs": [ + { + "type": "data", + "data": { + "text/plain": "" + } + } + ], + "console": [] + }, + { + "id": "ROlb", + "code_hash": "8623962f4904331ec8994d862378af83", + "outputs": [ + { + "type": "data", + "data": { + "text/plain": "" + } + } + ], + "console": [] + }, + { + "id": "qnkX", + "code_hash": "60cfbf2197fc71f864b4881ee43c9c61", + "outputs": [ + { + "type": "data", + "data": { + "text/markdown": "Across all 1,982,321 PPA pages there are a total of 3,842,611,368 characters of text.\n
    \n
  • 388,260,552 characters detected as poetry (10.1%)
  • \n
\nExcerpt length in characters (unmerged, 1,252,074 total excerpts):\n
    \n
  • Longest: 6,081
  • \n
  • Shortest: 3
  • \n
  • Average: 378.6
  • \n
\nExcerpt length in characters (merged all overlapping spans, 813,475 total excerpts):\n
    \n
  • Longest: 6,081
  • \n
  • Shortest: 3
  • \n
  • Average: 477.3
  • \n
" + } + } + ], + "console": [] + }, + { + "id": "TqIu", + "code_hash": "f06d512d94ad407a6cbacba06cd12069", + "outputs": [ + { + "type": "data", + "data": { + "text/html": "" + } + } + ], + "console": [] + }, + { + "id": "Vxnm", + "code_hash": "6ee4e85e2025f9e025c66b1a1105b353", + "outputs": [ + { + "type": "data", + "data": { + "text/html": "" + } + } + ], + "console": [] + } + ] +} \ No newline at end of file diff --git a/notebooks/excerpt_overlap_review.ipynb b/notebooks/excerpt_overlap_review.ipynb index 587bf594..06d5e466 100644 --- a/notebooks/excerpt_overlap_review.ipynb +++ b/notebooks/excerpt_overlap_review.ipynb @@ -13,7 +13,7 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": 2, "id": "c95992e4-de1f-41ce-922e-ddfd0c15a2a7", "metadata": {}, "outputs": [], @@ -28,7 +28,7 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": 3, "id": "5501e39d-d5d9-497b-9739-ee96d1bb8939", "metadata": {}, "outputs": [ @@ -36,7 +36,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "Data will be loaded from /Users/lauret/cdh-dev/ppa/ppa-found-poems/data\n" + "Data will be loaded from sample_data\n" ] } ], @@ -44,7 +44,7 @@ "# load local configuration options to get path to data\n", "config_opts = get_config()\n", "\n", - "data_dir = pathlib.Path(config_opts[\"poem_dataset\"][\"data_dir\"])\n", + "data_dir = pathlib.Path(config_opts.compiled_dataset_dir)\n", "if not data_dir.exists() or not data_dir.is_dir():\n", " raise ValueError(f\"Data directory {data_dir} not found. \" + \n", " \"\\nCheck your configuration file, and remember to use an absolute path for the poem dataset data directory.\")\n", @@ -776,7 +776,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.10" + "version": "3.12.7" } }, "nbformat": 4, diff --git a/notebooks/explore-excerpts.py b/notebooks/explore-excerpts.py new file mode 100644 index 00000000..96dc1bae --- /dev/null +++ b/notebooks/explore-excerpts.py @@ -0,0 +1,208 @@ +import marimo + +__generated_with = "0.19.11" +app = marimo.App(width="medium") + + +@app.cell(hide_code=True) +def _(mo): + mo.md(r""" + # Exploring Poetry Excerpt Data + + This notebook is for exploring the poetry excerpt data to aid data play at Ends of Prosody. + """) + return + + +@app.cell +def _(): + import pathlib + + import marimo as mo + import polars as pl + + from corppa.config import get_config + from corppa.poetry_detection.polars_utils import load_excerpts_df + + return get_config, load_excerpts_df, mo, pathlib, pl + + +@app.cell +def _(get_config, pathlib): + # load local configuration options to get path to data + config_opts = get_config() + + data_dir = pathlib.Path(config_opts["compiled_dataset"]["data_dir"]) + if not data_dir.exists() or not data_dir.is_dir(): + raise ValueError( + f"Data directory {data_dir} not found. " + + "\nCheck your configuration file, and remember to use an absolute path for the poem dataset data directory." + ) + else: + print(f"Data will be loaded from {data_dir}") + + # Create a dictionary of data files for lookup based on file base name without any extension + # so that excerpts data can be .csv or compressed .csv.gz + data_paths = { + data_file.stem.split(".", 1)[0]: data_file for data_file in data_dir.iterdir() + } + return (data_paths,) + + +@app.cell +def _(data_paths, load_excerpts_df): + # load the excerpts into a polars dataframe, + # and join poem & ppa work-level metadata + excerpts_df = load_excerpts_df( + data_paths["excerpts"], + ppa_works_meta=data_paths["ppa_work_metadata"], + ref_poems_meta=data_paths["poem_meta"], + ) + excerpts_df + return (excerpts_df,) + + +@app.cell +def _(excerpts_df, pl): + # summarize the data + total_excerpts = excerpts_df.height + labeled_excerpts = excerpts_df.filter(pl.col("poem_id").is_not_null()) + total_labeled_excerpts = labeled_excerpts.height + print(f"""{total_excerpts:,} total excerpts in combined data + {total_labeled_excerpts:,} labeled excerpts; {total_excerpts - total_labeled_excerpts:,} unlabeled ({((total_excerpts - total_labeled_excerpts) / total_excerpts) * 100:.1f}%)""") + return + + +@app.cell +def _(excerpts_df, pl): + detectmethod_counts = excerpts_df["detection_methods"].value_counts() + idmethod_counts = excerpts_df.filter(pl.col("poem_id").is_not_null())[ + "identification_methods" + ].value_counts() + print("Total by detection method:") + for value, count in detectmethod_counts.iter_rows(): + # row is a tuple of value, count; vlaue is a list + print(f"\t{','.join(value)}: {count:,}") + print("Total by identification method:") + for value, count in idmethod_counts.iter_rows(): + # row is a tuple of value, count + print(f"\t{','.join(value)}: {count:,}") + return + + +@app.cell(hide_code=True) +def _(mo): + mo.md(r""" + ## View page-level statistics + """) + return + + +@app.cell +def _(excerpts_df): + excerpts_df.columns + return + + +@app.cell +def _(excerpts_df, pl): + excerpts_by_page = ( + excerpts_df.group_by("page_id") + .agg( + pl.count("excerpt_id").alias("num_excerpts"), + pl.n_unique("excerpt_id").alias("num_distinct_excerpts"), + pl.n_unique("poem_id").alias("num_poems"), + pl.first("ppa_title"), # previously ppa_work_title + pl.first("ppa_author"), # previously ppa_work_author + pl.first("ppa_pub_year"), + ) + .sort("num_distinct_excerpts", descending=True) + ) + + excerpts_by_page + return + + +@app.cell(hide_code=True) +def _(mo): + mo.md(r""" + ## View work-level statistics + """) + return + + +@app.cell +def _(excerpts_df, pl): + excerpts_by_work = ( + excerpts_df.group_by("ppa_work_id") + .agg( + pl.count("excerpt_id").alias("num_excerpts"), + pl.n_unique("excerpt_id").alias("num_distinct_excerpts"), + pl.n_unique("poem_id").alias("num_poems"), + pl.n_unique("page_id").alias("num_pages_with_poems"), + pl.first("ppa_title"), + pl.first("ppa_author"), + pl.first("ppa_pub_year"), + ) + .sort("num_distinct_excerpts", descending=True) + ) + + excerpts_by_work + return + + +@app.cell(hide_code=True) +def _(mo): + mo.md(r""" + ## View poetry-level statistics + """) + return + + +@app.cell +def _(excerpts_df, pl): + excerpts_by_poem = ( + excerpts_df.group_by("poem_id") + .agg( + pl.count("excerpt_id").alias("num_excerpts"), + pl.n_unique("excerpt_id").alias("num_distinct_excerpts"), + pl.n_unique("page_id").alias("n_pages"), + pl.n_unique("ppa_work_id").alias("n_works"), + pl.first("poem_title"), + pl.first("poem_author"), + ) + .sort("num_distinct_excerpts", descending=True) + ) + + excerpts_by_poem + return + + +@app.cell(hide_code=True) +def _(mo): + mo.md(""" + ## View poem author statistics + """) + return + + +@app.cell +def _(excerpts_df, pl): + excerpts_by_poem_author = ( + excerpts_df.group_by("poem_author") + .agg( + pl.count("excerpt_id").alias("num_excerpts"), + pl.n_unique("excerpt_id").alias("num_distinct_excerpts"), + pl.n_unique("poem_id").alias("n_poems"), + pl.n_unique("page_id").alias("n_pages"), + pl.n_unique("ppa_work_id").alias("n_works"), + ) + .sort("num_distinct_excerpts", descending=True) + ) + + excerpts_by_poem_author + return + + +if __name__ == "__main__": + app.run() diff --git a/notebooks/explore-merged-excerpts.py b/notebooks/explore-merged-excerpts.py new file mode 100644 index 00000000..7335f5ee --- /dev/null +++ b/notebooks/explore-merged-excerpts.py @@ -0,0 +1,138 @@ +import marimo + +__generated_with = "0.19.11" +app = marimo.App(width="medium") + + +@app.cell(hide_code=True) +def _(mo): + mo.md(r""" + # Merged excerpts + + Preliminary notebook for reviewing merged excerpts. + """) + return + + +@app.cell +def _(): + import pathlib + + import marimo as mo + import polars as pl + + from corppa.config import get_config + from corppa.poetry_detection.polars_utils import ( + add_ref_poems_meta, + load_excerpts_df, + ) + + return add_ref_poems_meta, get_config, load_excerpts_df, mo, pathlib, pl + + +@app.cell +def _(get_config, pathlib): + config_opts = get_config() + + data_dir = pathlib.Path(config_opts["compiled_dataset"]["data_dir"]) + if not data_dir.exists() or not data_dir.is_dir(): + raise ValueError( + f"Data directory {data_dir} not found. " + + "\nCheck your configuration file, and remember to use an absolute path for the poem dataset data directory." + ) + else: + print(f"Data will be loaded from {data_dir}") + + # Create a dictionary of data files for lookup based on file base name without any extension + # so that excerpts data can be .csv or compressed .csv.gz + data_paths = { + data_file.stem.split(".", 1)[0]: data_file for data_file in data_dir.iterdir() + } + return (data_paths,) + + +@app.cell +def _(data_paths, load_excerpts_df, pl): + excerpts_df = load_excerpts_df( + data_paths["excerpts"], + ppa_works_meta=data_paths["ppa_work_metadata"], + ref_poems_meta=data_paths["poem_meta"], + ) + + # identify merged excerpts by presence of merge note + merged_ex_df = excerpts_df.filter(pl.col("notes").str.contains("merge")) + merged_ex_df + return excerpts_df, merged_ex_df + + +@app.cell +def _(): + return + + +@app.cell +def _(excerpts_df, pl): + # check for excerpts with multiple poem identifications + multi_poem_id_df = excerpts_df.filter(pl.col("alt_poem_ids").list.len().gt(0)) + multi_poem_id_df + return (multi_poem_id_df,) + + +@app.cell +def _(excerpts_df, merged_ex_df, mo, multi_poem_id_df): + # summarize + mo.md( + f""" + {merged_ex_df.height:,} out of {excerpts_df.height:,} total excerpts are merges ({merged_ex_df.height/excerpts_df.height * 100:.2f}%). + + {multi_poem_id_df.height:,} out of {merged_ex_df.height:,} merged excerpts have multiple poem ids ({multi_poem_id_df.height/merged_ex_df.height * 100:.2f}%)""" + ) + return + + +@app.cell +def _(add_ref_poems_meta, data_paths, multi_poem_id_df, pl): + # check what poem ids are getting matched / collapsed + poem_pairs_df = ( + multi_poem_id_df.select( + "poem_id", + "alt_poem_ids", + "poem_author", + "poem_title", + ) + .explode("alt_poem_ids") + .group_by("poem_id", "alt_poem_ids") + .agg( + pl.len().alias("count"), + pl.first("poem_author", "poem_title"), + ) + .with_columns( + alt_poem_id=pl.col("alt_poem_ids"), # rename to singular + alt_ref_corpus=pl.when(pl.col("alt_poem_ids").str.starts_with("Z")) + .then(pl.lit("chadwyck-healey")) + .otherwise(pl.lit("internet_poems")), + ) + .drop("alt_poem_ids") + ) + poem_pairs_df = add_ref_poems_meta( + poem_pairs_df, + data_paths["poem_meta"], + "alt_poem_id", + "alt_ref_corpus", + suffix="_alt", + ) + + poem_pairs_df.select( + "poem_id", + "alt_poem_id", + "count", + "poem_author", + "poem_title", + "poem_author_alt", + "poem_title_alt", + ).sort("count", descending=True) + return + + +if __name__ == "__main__": + app.run() diff --git a/notebooks/highlight.css b/notebooks/highlight.css new file mode 100644 index 00000000..65251597 --- /dev/null +++ b/notebooks/highlight.css @@ -0,0 +1,165 @@ +:root { + /* define highlight colors here as CSS variables */ + +/* https://observablehq.com/@jotasolano/paul-tol-schemes */ +/* qualitative vibrant + ["#EE7733","#0077BB","#33BBEE","#EE3377","#CC3311","#009988"] */ + --light-highlight-color1: #EE7733; + --light-gradient-color1: rgba(238, 119, 51, 0.25); + --light-highlight-color2: #0077BB; + --light-gradient-color3: rgba(0, 119, 187, 0.25); + --light-highlight-color3: #009988; + --light-gradient-color3: rgba(0, 153, 136, 0.25); + --light-highlight-color4: #EE3377; + --light-gradient-color4: rgba(238, 51, 119, 0.25); + --light-highlight-color5: #33BBEE; + --light-gradient-color5: rgba(51, 187, 238, 0.25); + --light-highlight-color6: #CC3311; + --light-gradient-color6: rgba(204, 51, 17, 0.25); + + /* qualitative bright +["#4477AA","#EE6677","#228833","#CCBB44","#66CCEE","#AA3377"] */ + --dark-highlight-color1: #4477AA; + --dark-gradient-color1: rgba(68, 119, 170, 0.25); + --dark-highlight-color2: #EE6677; + --dark-gradient-color2: rgba(238, 102, 119, 0.25); + --dark-highlight-color3: #228833; + --dark-gradient-color3: rgba(34, 136, 51, 0.25); + --dark-highlight-color4: #CCBB44; + --dark-gradient-color4: rgba(204, 187, 68, 0.25); + --dark-highlight-color5: #66CCEE; + --dark-gradient-color5: rgba(102, 204, 238, 0.25); + --dark-highlight-color6: #AA3377; + --dark-gradient-color6: rgba(170, 51, 119, 0.25); + +} + +mark { + /* override default styles for mark; remove highlight/color, add underline */ + background-color: transparent; + + + color: currentColor; + + /* set separate styles so it is easier to override */ + text-decoration-line: underline; + text-decoration-style: solid; + text-decoration-color: light-dark(var(--light-highlight-color1), var(--dark-highlight-color1)); + text-decoration-thickness: 2px; + text-underline-offset: 3px; + + --bg-gradient: light-dark(var(--light-highlight-color1), var(--dark-highlight-color1)); + background: linear-gradient(90deg,var(--bg-gradient) 0%, rgba(0, 0, 0, 0) 10%, rgba(0, 0, 0, 0) 90%, var(--bg-gradient) 100%); + + +} + + +mark.passim { + text-decoration-color: light-dark(var(--light-highlight-color3), var(--dark-highlight-color3)); + text-decoration-style: dotted; + text-decoration-thickness: 2px; + text-underline-offset: 8px; + --bg-gradient: light-dark(var(--light-highlight-color3), var(--dark-highlight-color3)); + /*background: linear-gradient(90deg,light-dark(var(--light-highlight-color3), var(--dark-highlight-color3)), rgba(0, 0, 0, 0) 25%); */ +} + + +mark.adjudication { + /*border-bottom: 2px solid light-dark(var(--light-highlight-color1), var(--dark-highlight-color1));*/ + /*background: linear-gradient(90deg, light-dark(var(--light-highlight-color1), var(--dark-highlight-color1)), rgba(0, 0, 0, 0) 25%);*/ + + /*text-decoration-color: light-dark(var(--light-highlight-color3), var(--dark-highlight-color3));*/ + /*text-decoration-style: dotted;*/ + /*text-decoration-thickness: 4px; /* 2px dotted is hard to see*/*/ + /*text-underline-offset: 4px;*/ +} + + + +section.page { + white-space: pre-line; + position: relative; +} + +section.page header { + position: sticky; + z-index: 4; /* display over page text */ + top: -1em; /* adjust for scrolling within marimo cell */ + padding-top: 1em; + padding-bottom: 1em; + border-bottom: 1px solid currentColor; + /* background-color: var(--background); not working */ + background-color: light-dark(white, black); +} + + +p.info { + font-size: smaller; + position: absolute; + top: 1em; + right: 5px; +} + +section header h1 { + font-weight: bold; +} + +div.compare { + position: relative; +} +div.compare > div { + white-space: pre; + + position: absolute; + top: 0; + left: 0; +} + +/* for any div after the first, make the text transparent to avoid artifacts + when text does not exactly align (although beware of texts that differ too much!) */ +div.compare :not(div:first-child()) { + /* comment color and uncomment opacity to test */ + color: transparent; + /*opacity: 0.9;*/ +} + +div.compare div.spacer { + color: transparent; + position: relative; + opacity: 0; +} + +.highlight2 mark { + text-decoration-color: light-dark(var(--light-highlight-color2), var(--dark-highlight-color2)); + text-decoration-style: wavy; + text-decoration-thickness: 1px; /* 2px wavy looks too thick */ +} + +.highlight3 mark { + text-decoration-color: light-dark(var(--light-highlight-color3), var(--dark-highlight-color3)); + text-decoration-style: dotted; + text-decoration-thickness: 4px; /* 2px dotted is hard to see*/ + text-underline-offset: 4px; +} + +.compare.multi div:nth-child(n+2) mark, .compare.secondary mark { + /* use wavy underlines for secondary highlights; + everything after the first div in a span-compare section, or secondary-span + */ + text-decoration-style: wavy; + text-underline-offset: 5px; + text-decoration-thickness: 1px; + text-decoration-color: light-dark(var(--light-highlight-color2), var(--dark-highlight-color3)); +} + +.compare.multi div:nth-child(3) mark { + text-decoration-style: dotted; + text-decoration-color: light-dark(var(--light-highlight-color3), var(--dark-highlight-color3)); +} +.compare.multi div:nth-child(4) mark { + text-decoration-color: light-dark(var(--light-highlight-color4), var(--dark-highlight-color4)); +} +.compare.multi div:nth-child(5) mark { + text-decoration-color: light-dark(var(--light-highlight-color5), var(--dark-highlight-color5)); +} \ No newline at end of file diff --git a/notebooks/poem-excerpt-length.py b/notebooks/poem-excerpt-length.py new file mode 100644 index 00000000..7974406f --- /dev/null +++ b/notebooks/poem-excerpt-length.py @@ -0,0 +1,904 @@ +import marimo + +__generated_with = "0.20.4" +app = marimo.App(width="medium") + + +@app.cell(hide_code=True) +def _(mo): + mo.md(r""" + # PPA found poems — poem/excerpt length over time + + MM expects that excerpts get shorter over time as the books get smaller. We also know that poems are also getting shorter over this time. What evidence of that can we find our found poem excerpt data? + """) + return + + +@app.cell +def _(): + import pathlib + + import altair as alt + import marimo as mo + import polars as pl + + from corppa.config import get_config + from corppa.poetry_detection.polars_utils import load_excerpts_df + + return alt, get_config, load_excerpts_df, mo, pathlib, pl + + +@app.cell(hide_code=True) +def _(mo): + mo.md(r""" + ## Poems cited in PPA + + We don't currently have dates in our poem metadata, but as a starting point we can look at the lengths of being poems quoted in PPA over time. + """) + return + + +@app.cell +def _(get_config, load_excerpts_df, pathlib, pl): + config_opts = get_config() + data_dir = pathlib.Path(config_opts["compiled_dataset"]["data_dir"]) + + # Create a dictionary of data files for lookup based on file base name without any extension + # so that excerpts data can be .csv or compressed .csv.gz + data_paths = { + data_file.stem.split(".", 1)[0]: data_file for data_file in data_dir.iterdir() + } + + # use the existing method to do the maximal load and join excerpts with ppa and poem metadata, and then subset/combine + excerpts_df = ( + load_excerpts_df( + data_paths["excerpts"], + ppa_works_meta=data_paths["ppa_work_metadata"], + ref_poems_meta=data_paths["poem_meta"], + ) + .with_columns( + # round years to decade + ppa_pub_decade=pl.col("ppa_pub_year").floordiv(10).mul(10), + ) + .cast( + # convert all the length measures to numeric so we can calculate stats + { + "poem_num_lines": pl.Int32, + "poem_num_words": pl.Int32, + "poem_char_len": pl.Int32, + } + ) + ) + + excerpts_df + return (excerpts_df,) + + +@app.cell +def _(excerpts_df): + excerpts_df.select("ppa_work_id", "ppa_pub_year", "ppa_pub_decade") + return + + +@app.cell +def _(excerpts_df): + # filter down to unique pairs of works + poems with decade and poem length field + works_poems_df = excerpts_df.select( + "ppa_work_id", + "ppa_pub_decade", + "poem_id", + "poem_num_lines", + "poem_num_words", + "poem_char_len", + ).unique() + works_poems_df + return (works_poems_df,) + + +@app.cell +def _(pl, works_poems_df): + # aggregate by decade and calculate min/max/average for all poem length measurements + work_poem_decade_stats_df = works_poems_df.group_by("ppa_pub_decade").agg( + count=pl.len(), + # number of lines + min_lines=pl.col("poem_num_lines").min(), + max_lines=pl.col("poem_num_lines").max(), + mean_lines=pl.col("poem_num_lines").mean(), + lines_Q1=pl.col("poem_num_lines").quantile(0.25), + median_lines=pl.col("poem_num_lines").quantile(0.5), # Q2 = median + lines_Q3=pl.col("poem_num_lines").quantile(0.75), + # number of words + min_words=pl.col("poem_num_words").min(), + max_words=pl.col("poem_num_words").max(), + mean_words=pl.col("poem_num_words").mean(), + words_Q1=pl.col("poem_num_words").quantile(0.25), + median_words=pl.col("poem_num_words").quantile(0.5), + words_Q3=pl.col("poem_num_words").quantile(0.75), + # number of characters poem_char_len + min_chars=pl.col("poem_char_len").min(), + max_chars=pl.col("poem_char_len").max(), + mean_chars=pl.col("poem_char_len").mean(), + chars_Q1=pl.col("poem_char_len").quantile(0.25), + median_chars=pl.col("poem_char_len").quantile(0.5), + chars_Q3=pl.col("poem_char_len").quantile(0.75), + ) + work_poem_decade_stats_df + return (work_poem_decade_stats_df,) + + +@app.cell +def _(alt): + def plot_quartiles(df, x_field, x_field_title, stat_field, stat_noun): + # generate a layered chart of area between Q1/Q3 and lines for quartiles, means, median + + # unpivot mean/median to graph together with color legend + stats_fields = [ + f"{stat_field}_Q1", + f"mean_{stat_field}", + f"median_{stat_field}", + f"{stat_field}_Q3", + ] + stats_df = df.unpivot(on=stats_fields, index=x_field) + + # return a layered chart with area and lines + return alt.layer( + alt.Chart(df) + .mark_area( + opacity=0.4, + color="#f05b69", + ) + .encode( + x=alt.X(x_field, title=x_field_title) + .axis(format="r") + .scale(zero=False), + y=alt.Y( + f"{stat_field}_Q3", + title=f"{stat_noun} (Q1, Q2, Q3, mean, max)", + ), + y2=f"{stat_field}_Q1", + tooltip=stats_fields, + ), + alt.Chart(stats_df) + .mark_line() + .encode(x=x_field, y="value", color="variable"), + ) + + return (plot_quartiles,) + + +@app.cell +def _(mo, plot_quartiles, work_poem_decade_stats_df): + mo.ui.altair_chart( + plot_quartiles( + work_poem_decade_stats_df, + "ppa_pub_decade", + "PPA Publication decade", + "lines", + "Number of lines", + ).properties( + title="Mean and quartile poem length in lines for poems cited in PPA works by decade" + ) + ) + return + + +@app.cell +def _(custom_boxplot, mo, work_poem_decade_stats_df): + mo.ui.altair_chart( + custom_boxplot( + work_poem_decade_stats_df, + "ppa_pub_decade", + "PPA Publication decade", + "lines", + "Number of lines", + ) + .properties( + title="Distribution of poem length for all poems quoted in PPA by decade" + ) + .interactive() + ) + return + + +@app.cell +def _(alt): + # define a custom box plot method using layered plots, + # so that we can quickly generate plots from statistics generated by polars + # adapted from prior work https://princeton-cdh.github.io/simulating-risk/notebooks/hawkdovemulti-noadjust/ + + def custom_boxplot(df, x_field, x_field_title, stat_field, stat_noun): + stats_fields = [ + f"min_{stat_field}", + f"{stat_field}_Q1", + f"mean_{stat_field}", + f"median_{stat_field}", + f"{stat_field}_Q3", + f"max_{stat_field}", + ] + + # create base chart to use across layers + base_chart = alt.Chart(df) + + # area chart for Q1 to Q3 + area_chart = base_chart.mark_rect(width=15).encode( + y=alt.Y(f"{stat_field}_Q1").axis( + offset=12 + ), # add offset so axis does not crowd rectangle + y2=f"{stat_field}_Q3", + x=alt.X(x_field, title=x_field_title).axis(format="r"), + tooltip=stats_fields, + ) + stroke_color = "orange" + # line chart for min-max spread + # specifying a stroke for point on the line only adds the min point + minmax_line_chart = base_chart.mark_line( + point=alt.OverlayMarkDef( + filled=False, shape="stroke", color=stroke_color, strokeWidth=2 + ), + color=stroke_color, + ).encode(alt.Y(f"min_{stat_field}"), alt.Y2(f"max_{stat_field}"), x=x_field) + # add a stroke for the max + max_marks = base_chart.mark_point( + shape="stroke", size=55, color=stroke_color + ).encode( + y=alt.Y(f"max_{stat_field}"), + x=x_field, + ) + # add a stroke for the min + median_marks = base_chart.mark_point( + shape="stroke", size=100, strokeWidth=1, color=stroke_color + ).encode(y=f"median_{stat_field}", x=x_field) + + # mean line ? + mean_line_chart = base_chart.mark_line( + interpolate="monotone", color="yellow", opacity=0.5 + ).encode( + x=alt.X(x_field), + y=alt.Y(f"mean_{stat_field}", title=f"{stat_noun} (mean)").scale( + zero=False + ), + ) + + return alt.layer( + mean_line_chart, minmax_line_chart, area_chart, median_marks, max_marks + ).resolve_axis("shared") + + return (custom_boxplot,) + + +@app.cell(hide_code=True) +def _(mo): + mo.md(r""" + We can plot poem length by number of words or number of characters - but the general trend looks the same across those measurements. + """) + return + + +@app.cell +def _(mo, plot_quartiles, work_poem_decade_stats_df): + mo.ui.altair_chart( + plot_quartiles( + work_poem_decade_stats_df, + "ppa_pub_decade", + "PPA Publication decade", + "words", + "Number of words", + ).properties( + title="Mean and quartile poem length by number of words for poems cited in PPA works by decade" + ) + ) + return + + +@app.cell +def _(mo, plot_quartiles, work_poem_decade_stats_df): + mo.ui.altair_chart( + plot_quartiles( + work_poem_decade_stats_df, + "ppa_pub_decade", + "PPA Publication decade", + "chars", + "Number of characters", + ).properties( + title="Mean and quartile poem length by number of characters for poems cited in PPA works by decade" + ) + ) + return + + +@app.cell(hide_code=True) +def _(mo): + mo.md(r""" + ## Poem length by first appearance in PPA + """) + return + + +@app.cell +def _(excerpts_df, pl): + # instead of filtering to unique pairs of works + poems with decade and poem length field, + # aggregate by poem id and get the earliest date it is quoted in the PPA + poems_firstquoted_df = ( + # filter out the few PPA works with no publication date, then sort by publication year + excerpts_df.filter(~pl.col.ppa_pub_year.is_null()) + .sort("ppa_pub_year") + # group by poem but maintain order so we can get the earliest PPA work an poem is found in + .group_by("poem_id", maintain_order=True) + .agg( + pl.first("ppa_pub_decade"), + pl.first("poem_num_lines"), + pl.first("poem_num_words"), + pl.first("poem_char_len"), + pl.first("ppa_work_id"), + pl.first("ppa_pub_year"), + pl.first("poem_title"), + pl.first("poem_author"), + ) + ) + poems_firstquoted_df + return (poems_firstquoted_df,) + + +@app.cell +def _(pl, poems_firstquoted_df): + # now generate stats + # aggregate by decade and calculate min/max/average for all poem length measurements + poems_firstquoted_stats_df = poems_firstquoted_df.group_by("ppa_pub_decade").agg( + count=pl.len(), # number of poems + # number of lines + min_lines=pl.col("poem_num_lines").min(), + max_lines=pl.col("poem_num_lines").max(), + mean_lines=pl.col("poem_num_lines").mean(), + lines_Q1=pl.col("poem_num_lines").quantile(0.25), + median_lines=pl.col("poem_num_lines").quantile(0.5), + lines_Q3=pl.col("poem_num_lines").quantile(0.75), + # number of words + min_words=pl.col("poem_num_words").min(), + max_words=pl.col("poem_num_words").max(), + mean_words=pl.col("poem_num_words").mean(), + words_Q1=pl.col("poem_num_words").quantile(0.25), + median_words=pl.col("poem_num_words").quantile(0.5), + words_Q3=pl.col("poem_num_words").quantile(0.75), + # number of characters poem_char_len + min_chars=pl.col("poem_char_len").min(), + max_chars=pl.col("poem_char_len").max(), + mean_chars=pl.col("poem_char_len").mean(), + chars_Q1=pl.col("poem_char_len").quantile(0.25), + median_chars=pl.col("poem_char_len").quantile(0.5), + chars_Q3=pl.col("poem_char_len").quantile(0.75), + ) + poems_firstquoted_stats_df + return (poems_firstquoted_stats_df,) + + +@app.cell +def _(pl, poems_firstquoted_df): + # what is that early outlier skewing the graphs? + poems_firstquoted_df.filter(pl.col.ppa_pub_decade.lt(1600)).sort( + "poem_num_lines", descending=True + ) + return + + +@app.cell +def _(pl, poems_firstquoted_stats_df): + poems_firstquoted_stats_df.filter(pl.col.median_lines.gt(100)) + return + + +@app.cell +def _(mo, plot_quartiles, poems_firstquoted_stats_df): + mo.ui.altair_chart( + plot_quartiles( + poems_firstquoted_stats_df, + "ppa_pub_decade", + "PPA Publication decade", + "lines", + "Number of lines", + ).properties( + title="Mean and quartile poem length by number of lines for poems first appearance in PPA" + ) + ) + return + + +@app.cell +def _(custom_boxplot, mo, poems_firstquoted_stats_df): + mo.ui.altair_chart( + custom_boxplot( + poems_firstquoted_stats_df, + "ppa_pub_decade", + "PPA Publication decade", + "lines", + "Number of lines", + ) + .properties( + title="Distribution of poem length based on poem first appearance in PPA by decade" + ) + .interactive() + ) + return + + +@app.cell(hide_code=True) +def _(mo): + mo.md(r""" + As a way of checking & inspecting the above charts, what are the longest poems in each decade, based on first appearance in PPA? + """) + return + + +@app.cell +def _(mo, pl, poems_firstquoted_df): + mo.ui.table( + poems_firstquoted_df.sort( + "ppa_pub_decade", + "poem_num_lines", + descending=[False, True], + nulls_last=True, + ) + .group_by("ppa_pub_decade", maintain_order=True) + .agg( + pl.first("poem_title"), + pl.first("poem_author"), + pl.first("poem_num_lines"), + pl.first("ppa_work_id"), + pl.first("poem_id"), + ) + .cast({"ppa_pub_decade": pl.String}), # cast decade to str for readability + label="Longest poem for each decade first quoted in PPA", + page_size=40, + selection=None, + ) + return + + +@app.cell(hide_code=True) +def _(mo): + mo.md(r""" + How can Shelley's "The Cenci" be quoted in 1532 ? Is this really in our data? + """) + return + + +@app.cell +def _(excerpts_df, pl): + excerpts_df.filter(pl.col.poem_id.eq("Z200484006")).sort("ppa_pub_year") + return + + +@app.cell(hide_code=True) +def _(mo): + mo.md(r""" + Answer: yes, it is in our data. Passim matched this line from PPA: + + > of this questyon (who dyd the dede) so whan there is no doubt but that the + + with this line of Shelley: + + > other Lurking among the rocks; there is no doubt But that the + + + Common text? **there is no doubt but that the** + """) + return + + +@app.cell +def _(pl): + ### Refine poem first quoted date by poet dates + + poet_meta_df = pl.read_csv("data/ref-corpora/ch_poets_meta.csv").with_columns( + # construct combined author field for matching with poem metadata + author=pl.concat_str( + [pl.col("author_firstname"), pl.col("author_lastname")], + separator=" ", + ), + ) + poet_meta_df + return (poet_meta_df,) + + +@app.cell +def _(excerpts_df, pl, poet_meta_df): + # join poet metadata with excerpts to get author birth year + # for now maybe we limit to numerics? (undate later ;-P ) + + # filter to subset with numeric birth year + poet_birthyear_df = poet_meta_df.with_columns( + # convert year to number; convert to null if can't be converted + poet_birthyear=pl.col.author_birth.str.to_integer(strict=False), + ).filter(pl.col.poet_birthyear.is_not_null()) # drop nulls + + excerpts_poets_df = excerpts_df.join( + poet_birthyear_df.select("author", "poet_birthyear"), + left_on="poem_author", + right_on="author", + ) + excerpts_poets_df + return (excerpts_poets_df,) + + +@app.cell +def _(excerpts_poets_df, mo, pl): + # how many excerpts are temporally suspect based on poet birth date and ppa publication date? + # (or maybe cases where quotation is going in the other direction, possibly through an intermediary....) + + suspect_excerpts_df = excerpts_poets_df.filter( + pl.col("ppa_pub_year").lt(pl.col("poet_birthyear").add(10)) + ) + + mo.md( + f"Identified {suspect_excerpts_df.height:,} suspect excerpts (PPA work publication year < poem author birth year + 10)" + ) + return (suspect_excerpts_df,) + + +@app.cell +def _(mo, suspect_excerpts_df): + mo.ui.table( + suspect_excerpts_df.select( + "ppa_work_id", + "ppa_title", + "ppa_pub_year", + "poem_title", + "poem_author", + "poet_birthyear", + "ppa_span_text", + "ref_span_text", + ), + page_size=25, + selection=None, + ) + return + + +@app.cell +def _(excerpts_poets_df, mo, pl, plot_quartiles): + # filter those out and rerun the first-citation logic + + filtered_excerpts_df = excerpts_poets_df.filter( + pl.col("ppa_pub_year").gt(pl.col("poet_birthyear")) + ) + + # repeat above logic to identify earliest quote, but on the filtered set + filtered_poems_firstquoted_df = ( + # filter out the few PPA works with no publication date, then sort by publication year + filtered_excerpts_df.filter(~pl.col.ppa_pub_year.is_null()) + .sort("ppa_pub_year") + # group by poem but maintain order so we can get the earliest PPA work an poem is found in + .group_by("poem_id", maintain_order=True) + .agg( + pl.first("ppa_pub_decade"), + pl.first("poem_num_lines"), + pl.first("poem_num_words"), + pl.first("poem_char_len"), + pl.first("ppa_work_id"), + pl.first("ppa_pub_year"), + pl.first("poem_title"), + pl.first("poem_author"), + ) + ) + + # aggregate by decade and calculate min/max/average for all poem length measurements + filtered_poems_firstquoted_stats_df = filtered_poems_firstquoted_df.group_by( + "ppa_pub_decade" + ).agg( + count=pl.len(), # number of poems + # number of lines + min_lines=pl.col("poem_num_lines").min(), + max_lines=pl.col("poem_num_lines").max(), + mean_lines=pl.col("poem_num_lines").mean(), + lines_Q1=pl.col("poem_num_lines").quantile(0.25), + median_lines=pl.col("poem_num_lines").quantile(0.5), + lines_Q3=pl.col("poem_num_lines").quantile(0.75), + # number of words + min_words=pl.col("poem_num_words").min(), + max_words=pl.col("poem_num_words").max(), + mean_words=pl.col("poem_num_words").mean(), + words_Q1=pl.col("poem_num_words").quantile(0.25), + median_words=pl.col("poem_num_words").quantile(0.5), + words_Q3=pl.col("poem_num_words").quantile(0.75), + # number of characters poem_char_len + min_chars=pl.col("poem_char_len").min(), + max_chars=pl.col("poem_char_len").max(), + mean_chars=pl.col("poem_char_len").mean(), + chars_Q1=pl.col("poem_char_len").quantile(0.25), + median_chars=pl.col("poem_char_len").quantile(0.5), + chars_Q3=pl.col("poem_char_len").quantile(0.75), + ) + + mo.ui.altair_chart( + plot_quartiles( + filtered_poems_firstquoted_stats_df, + "ppa_pub_decade", + "PPA Publication decade", + "lines", + "Number of lines", + ).properties( + title="Mean and quartile poem length by number of lines for poems first appearance in PPA" + ) + ) + return filtered_poems_firstquoted_df, filtered_poems_firstquoted_stats_df + + +@app.cell +def _(custom_boxplot, filtered_poems_firstquoted_stats_df, mo): + mo.ui.altair_chart( + custom_boxplot( + filtered_poems_firstquoted_stats_df, + "ppa_pub_decade", + "PPA Publication decade", + "lines", + "Number of lines", + ) + .properties( + title="Distribution of poem length based on poem first appearance in PPA by decade" + ) + .interactive() + ) + return + + +@app.cell +def _(filtered_poems_firstquoted_df, mo, pl): + mo.ui.table( + filtered_poems_firstquoted_df.sort( + "ppa_pub_decade", + "poem_num_lines", + descending=[False, True], + nulls_last=True, + ) + .group_by("ppa_pub_decade", maintain_order=True) + .agg( + pl.first("poem_title"), + pl.first("poem_author"), + pl.first("poem_num_lines"), + pl.first("ppa_work_id"), + pl.first("poem_id"), + ) + .cast({"ppa_pub_decade": pl.String}), # cast decade to str for readability + label="Longest poem for each decade first quoted in PPA (filtered set, PPA publication > poet birth year + 10)", + page_size=40, + selection=None, + ) + return + + +@app.cell(hide_code=True) +def _(mo): + mo.md(r""" + ## Poem excerpt length + + How much of a poem is cited in a PPA work, and how does that change over time? + + To simplify our measurement and avoid counting duplicate excerpts or excerpts split by page range, we aggregate excerpts + and collapse the reference spans by PPA work, to determine the total length of each poem cited in each work. + """) + return + + +@app.cell +def _(excerpts_df, pl): + # in the percent ppa poetry notebook, we collapsed overlapping spans for the ppa text, for each page of ppa + # here, we want to do collapse reference spans for each ppa work + + # collapse excerpts with any overlap to a single span so we can calculate the total number of characters + # covered by any of the merged spans + + ref_merged_excerpts_df = ( + # sort by work, poem, and reference span start + excerpts_df.sort("ppa_work_id", "poem_id", "ref_span_start") + .select( + "ppa_work_id", + "ppa_pub_decade", + "poem_id", + "ref_span_start", + "ref_span_end", + "poem_author", + "poem_title", + "poem_char_len", + ) + .with_columns( + # Use shift and cumulative max to determine if current span + # has any overlap with previous spans or is the beginning of a new group. + # shift(1) gets previous row; fill null for first row (which has no previous row), + # and calculate current max span end for this page. + # NOTE: we use >= because span end is exclusive (i.e., is not included in the range) + new_group=( + pl.col("ref_span_start") + >= pl.col("ref_span_end").shift(1).fill_null(-1).cum_max() + ) + .cast(pl.Int32) # cast to int gives 1 or 0 to indicate new group + .over( + "ppa_work_id", "poem_id" + ) # limit to spans to a single poem quoted in a single work + ) + .with_columns( + # because new_group is 1 or 0, cumulative sum gives each group on a page a unique group id + pl.col("new_group") + .cum_sum() + .alias("group_id") + .over("ppa_work_id", "poem_id") + ) + .group_by("ppa_work_id", "poem_id", "group_id") + .agg( + # group by page id and group id and get the smallest start and largest end + # to get the outer bounds of the overlapping spans + pl.col("ref_span_start").min(), + pl.col("ref_span_end").max(), + pl.col("ppa_pub_decade").first(), + pl.col("poem_title").first(), + pl.col("poem_author").first(), + pl.col("poem_char_len").first(), + ) + # calculate length of the consolidated reference + .with_columns(ref_span_len=pl.col.ref_span_end - pl.col.ref_span_start) + # calculate percentage of the poem that is quoted + .with_columns(ref_percent=pl.col.ref_span_len.truediv(pl.col.poem_char_len)) + .drop("group_id") + ) + + # based on the merged reference spans, calculate how much of each poem is quoted in each work + + excerpt_poem_chars_df = ref_merged_excerpts_df.group_by( + "ppa_work_id", "poem_id" + ).agg( + # calculate the number of characters covered by all merged spans for each poem + ref_char_len=(pl.col("ref_span_end") - pl.col("ref_span_start")).sum(), + ppa_pub_decade=pl.col.ppa_pub_decade.first(), + ) + + excerpt_poem_chars_df + return (ref_merged_excerpts_df,) + + +@app.cell(hide_code=True) +def _(mo): + mo.md(r""" + Which poems are quoted from the most? (Sorting by sum of reference span lengths) + """) + return + + +@app.cell +def _(ref_merged_excerpts_df): + ref_merged_excerpts_df.sort( + "ref_span_len", descending=True, nulls_last=True + ).select( + "ppa_work_id", + "poem_id", + "poem_author", + "poem_title", + "poem_char_len", + "ref_span_len", + "ref_percent", + ) + return + + +@app.cell +def _(mo, pl, ref_merged_excerpts_df): + fully_quoted_poems = ( + ref_merged_excerpts_df.filter(pl.col.ref_percent.ge(1)) + .select("poem_id") + .unique() + .height + ) + + mo.md( + f"""{fully_quoted_poems:,} poems are quoted in full + + (based on total reference span length and percentage of poem length, which may not match exactly)""" + ) + return + + +@app.cell +def _(ref_merged_excerpts_df): + # which ones are quoted most? + # we have numbers of 100% here - guessing this is due to lack of alignment / different ways of counting characters + + ref_merged_excerpts_df.sort("ref_percent", descending=True, nulls_last=True).select( + "ppa_work_id", + "poem_id", + "poem_author", + "poem_title", + "poem_char_len", + "ref_span_len", + "ref_percent", + ) + return + + +@app.cell +def _(alt, mo, pl, plot_quartiles, ref_merged_excerpts_df): + # aggregrate reference spans to get statistics over PPA works by decade + + ref_excerpts_stats_df = ref_merged_excerpts_df.group_by("ppa_pub_decade").agg( + count=pl.len(), + # number of characters quoted from a poem, based on combined reference span length + min_chars=pl.col("ref_span_len").min(), + max_chars=pl.col("ref_span_len").max(), + mean_chars=pl.col("ref_span_len").mean(), + chars_Q1=pl.col("ref_span_len").quantile(0.25), + median_chars=pl.col("ref_span_len").quantile(0.5), + chars_Q3=pl.col("ref_span_len").quantile(0.75), + # percent of poem by character length + mean_percent=pl.col("ref_percent").mean(), + percent_Q1=pl.col("ref_percent").quantile(0.25), + median_percent=pl.col("ref_percent").quantile(0.5), + percent_Q3=pl.col("ref_percent").quantile(0.75), + ) + + # unpivot mean/median to graph together with color legend + mean_median_ref_stats_df = ref_excerpts_stats_df.unpivot( + on=["mean_chars", "median_chars"], index="ppa_pub_decade" + ) + + mean_median_reflength_chart = ( + alt.Chart(mean_median_ref_stats_df) + .mark_line() + .encode(x="ppa_pub_decade", y="value", color="variable") + .properties( + title="Average and quantiles for poem excerpt length included per PPA work, by PPA publication decade" + ) + ) + + mo.ui.altair_chart( + plot_quartiles( + ref_excerpts_stats_df, + "ppa_pub_decade", + "PPA Publication decade", + "chars", + "Number of characters", + ).properties( + title="Mean and quartile poem quotation length by number of characters for poems found in PPA" + ) + ) + return (ref_excerpts_stats_df,) + + +@app.cell(hide_code=True) +def _(mo): + mo.md(r""" + We can graph the min/max, but the maximum length is quite large and changes the scale substantially. + """) + return + + +@app.cell +def _(alt, mo, ref_excerpts_stats_df): + mo.ui.altair_chart( + alt.Chart(ref_excerpts_stats_df) + .mark_area( + opacity=0.4, + color="#6252a0", + ) + .encode( + x=alt.X("ppa_pub_decade", title="PPA Publication decade").axis(format="r"), + y=alt.Y("min_chars", title="Poem characters quoted (min/max length)"), + y2="max_chars", + ) + ) + return + + +@app.cell +def _(mo, plot_quartiles, ref_excerpts_stats_df): + # what percent of poems are quoted over time? + + mo.ui.altair_chart( + plot_quartiles( + ref_excerpts_stats_df, + "ppa_pub_decade", + "PPA Publication decade", + "percent", + "Percent of poem", + ).properties(title="Percent of poem quoted in a single work") + ) + return + + +if __name__ == "__main__": + app.run() diff --git a/notebooks/poetry_excerpt_review.ipynb b/notebooks/poetry_excerpt_review.ipynb index 83f92e85..ebb813a0 100644 --- a/notebooks/poetry_excerpt_review.ipynb +++ b/notebooks/poetry_excerpt_review.ipynb @@ -13,7 +13,7 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": 2, "id": "c95992e4-de1f-41ce-922e-ddfd0c15a2a7", "metadata": {}, "outputs": [ @@ -71,7 +71,7 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": 4, "id": "5501e39d-d5d9-497b-9739-ee96d1bb8939", "metadata": {}, "outputs": [ @@ -79,7 +79,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "Data will be loaded from /Users/rkoeser/workarea/gitlab/ppa-found-poems/data\n" + "Data will be loaded from sample_data\n" ] } ], @@ -87,7 +87,7 @@ "# load local configuration options to get path to data\n", "config_opts = get_config()\n", "\n", - "data_dir = pathlib.Path(config_opts[\"poem_dataset\"][\"data_dir\"])\n", + "data_dir = pathlib.Path(config_opts.compiled_dataset_dir)\n", "if not data_dir.exists() or not data_dir.is_dir():\n", " raise ValueError(f\"Data directory {data_dir} not found. \" + \n", " \"\\nCheck your configuration file, and remember to use an absolute path for the poem dataset data directory.\")\n", @@ -1140,7 +1140,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.10" + "version": "3.12.7" } }, "nbformat": 4, diff --git a/notebooks/poetry_metadata_exploration.ipynb b/notebooks/poetry_metadata_exploration.ipynb new file mode 100644 index 00000000..4e11d4a6 --- /dev/null +++ b/notebooks/poetry_metadata_exploration.ipynb @@ -0,0 +1,838 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "title", + "metadata": {}, + "source": [ + "# Poetry metadata\n", + "\n", + "summary of `data/poetry_metadata.csv`" + ] + }, + { + "cell_type": "markdown", + "id": "md-load", + "metadata": {}, + "source": [ + "Load the metadata table and print how many poem records it contains." + ] + }, + { + "cell_type": "code", + "execution_count": 56, + "id": "code-load", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Poem records (rows): 312,988\n" + ] + } + ], + "source": [ + "from pathlib import Path\n", + "\n", + "import matplotlib.pyplot as plt\n", + "import pandas as pd\n", + "\n", + "REPO_ROOT = Path.cwd() if (Path.cwd() / \"data\").is_dir() else Path.cwd().parent\n", + "CSV_PATH = REPO_ROOT / \"data\" / \"poetry_metadata.csv\"\n", + "\n", + "df = pd.read_csv(CSV_PATH, low_memory=False)\n", + "print(f\"Poem records (rows): {len(df):,}\")" + ] + }, + { + "cell_type": "markdown", + "id": "md-cols", + "metadata": {}, + "source": [ + "We show every column and the percentage of rows where that column is non-missing." + ] + }, + { + "cell_type": "code", + "execution_count": 27, + "id": "code-cols", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
pct_non_null
title_id100.0
filename100.0
author_lastname99.8
author_firstname98.9
qid96.0
wikidata_url96.0
occupation_wd94.6
date_of_birth_wd93.5
date_of_death_wd86.4
sex_or_gender_wd94.1
country_of_citizenship_wd83.5
author_birth_origch99.8
author_death_origch84.8
author_period_origch0.9
title_main_origch100.0
title_sub_origch14.8
transl_lastname_origch1.7
transl_firstname_origch1.7
transl_birth_origch1.7
transl_death_origch1.5
edition_id100.0
edition_text100.0
edition_year99.9
period99.4
genre11.2
rhymes72.4
\n", + "
" + ], + "text/plain": [ + " pct_non_null\n", + "title_id 100.0\n", + "filename 100.0\n", + "author_lastname 99.8\n", + "author_firstname 98.9\n", + "qid 96.0\n", + "wikidata_url 96.0\n", + "occupation_wd 94.6\n", + "date_of_birth_wd 93.5\n", + "date_of_death_wd 86.4\n", + "sex_or_gender_wd 94.1\n", + "country_of_citizenship_wd 83.5\n", + "author_birth_origch 99.8\n", + "author_death_origch 84.8\n", + "author_period_origch 0.9\n", + "title_main_origch 100.0\n", + "title_sub_origch 14.8\n", + "transl_lastname_origch 1.7\n", + "transl_firstname_origch 1.7\n", + "transl_birth_origch 1.7\n", + "transl_death_origch 1.5\n", + "edition_id 100.0\n", + "edition_text 100.0\n", + "edition_year 99.9\n", + "period 99.4\n", + "genre 11.2\n", + "rhymes 72.4" + ] + }, + "execution_count": 27, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "def pct_non_null(series: pd.Series) -> float:\n", + " s = series\n", + " if s.dtype == object:\n", + " empty = s.astype(str).str.strip().isin([\"\", \"nan\", \"None\"])\n", + " return float(100 * (~s.isna() & ~empty).mean())\n", + " return float(100 * s.notna().mean())\n", + "\n", + "coverage = pd.DataFrame(\n", + " {\"pct_non_null\": [pct_non_null(df[c]) for c in df.columns]},\n", + " index=df.columns,\n", + ")\n", + "coverage[\"pct_non_null\"] = coverage[\"pct_non_null\"].round(1)\n", + "coverage" + ] + }, + { + "cell_type": "markdown", + "id": "md-provenance", + "metadata": {}, + "source": [ + "This data inherits a _very_ noisy historical Chadwyck–Healey scrape: columns ending in `_origch` are the original values, kept for reference. But wherever you can, use the data in columns ending in `_wd`. E.g. `author_birth_origch` contains the original dob info from CH -- but that data is often contaminated/compromised; it contains last names, periods, floruit dates etc... (in other words: the 99.8pct non-null for that column is high only because data types are mixed in that column).\n", + "\n", + "New columns add author facts reconciled from Wikidata; for dates of birth and death you should rely on `date_of_birth_wd` and `date_of_death_wd`, not `author_birth_origch` / `author_death_origch`.\n", + "\n", + "Another new column that was added is `edition_year` (parsed carefully from `edition_text` that often ends with a pub year)." + ] + }, + { + "cell_type": "markdown", + "id": "md-authors", + "metadata": {}, + "source": [ + "We count unique authors by joining surname and given name, then report how many of those names have a Wikidata `qid`." + ] + }, + { + "cell_type": "code", + "execution_count": 47, + "id": "code-authors", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Unique authors (name key): 3,630\n", + "With a Wikidata qid: 2,713 (74.7%)\n" + ] + } + ], + "source": [ + "def build_author_key(frame: pd.DataFrame) -> pd.Series:\n", + " ln = frame[\"author_lastname\"].fillna(\"\").astype(str).str.strip()\n", + " fn = frame[\"author_firstname\"].fillna(\"\").astype(str).str.strip()\n", + " ln = ln.replace({\"nan\": \"\"})\n", + " fn = fn.replace({\"nan\": \"\"})\n", + " both = ln.ne(\"\") & fn.ne(\"\")\n", + " ln_only = ln.ne(\"\") & fn.eq(\"\")\n", + " fn_only = ln.eq(\"\") & fn.ne(\"\")\n", + " key = pd.Series(\"\", index=frame.index, dtype=object)\n", + " key.loc[both] = ln[both] + \", \" + fn[both]\n", + " key.loc[ln_only] = ln[ln_only]\n", + " key.loc[fn_only] = fn[fn_only]\n", + " return key\n", + "\n", + "\n", + "def group_has_qid(s: pd.Series) -> bool:\n", + " for v in s.dropna().astype(str).str.strip():\n", + " if len(v) > 1 and v[0] in \"Qq\" and v[1:].isdigit():\n", + " return True\n", + " return False\n", + "\n", + "\n", + "df[\"author_key\"] = build_author_key(df)\n", + "_df_auth = df[df[\"author_key\"].ne(\"\")].copy()\n", + "n_authors = _df_auth[\"author_key\"].nunique()\n", + "by_author = _df_auth.groupby(\"author_key\", sort=False)[\"qid\"].agg(group_has_qid)\n", + "n_with_qid = int(by_author.sum())\n", + "pct_qid = 100.0 * n_with_qid / n_authors if n_authors else 0.0\n", + "\n", + "print(f\"Unique authors (name key): {n_authors:,}\")\n", + "print(f\"With a Wikidata qid: {n_with_qid:,} ({pct_qid:.1f}%)\")" + ] + }, + { + "cell_type": "markdown", + "id": "md-filter", + "metadata": {}, + "source": [ + "We drop every row whose author has a Wikidata date of birth strictly after 1920, since the PPA horizon is roughly pre-1930; birth years are read from the ISO date string so we stay clear of pandas stupid datetime limit." + ] + }, + { + "cell_type": "code", + "execution_count": 48, + "id": "code-filter", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Rows before filter: 312,988\n", + "Rows after filter: 262,799\n", + "Unique authors dropped (any DOB year > 1920): 338\n" + ] + } + ], + "source": [ + "def birth_year_from_iso(s: pd.Series) -> pd.Series:\n", + " t = s.astype(str).str.strip().replace({\"\": pd.NA, \"nan\": pd.NA})\n", + " m = t.str.extract(r\"^(-?)(\\d{4})-\", expand=True)\n", + " y = pd.to_numeric(m[1], errors=\"coerce\")\n", + " return y.where(m[0] != \"-\", -y)\n", + "\n", + "\n", + "df[\"birth_year_wd\"] = birth_year_from_iso(df[\"date_of_birth_wd\"])\n", + "_named = df[\"author_key\"].ne(\"\")\n", + "by_birth = (\n", + " df.loc[_named]\n", + " .groupby(\"author_key\", sort=False)[\"birth_year_wd\"]\n", + " .max()\n", + ")\n", + "late_authors = set(by_birth.index[by_birth > 1920])\n", + "df_ppa = df[~df[\"author_key\"].isin(late_authors)].copy()\n", + "\n", + "print(f\"Rows before filter: {len(df):,}\")\n", + "print(f\"Rows after filter: {len(df_ppa):,}\")\n", + "print(f\"Unique authors dropped (any DOB year > 1920): {len(late_authors):,}\")" + ] + }, + { + "cell_type": "markdown", + "id": "md-ppa-authors", + "metadata": {}, + "source": [ + "On this PPA-focused slice we recount distinct authors and the share that still carry a Wikidata identifier." + ] + }, + { + "cell_type": "code", + "execution_count": 49, + "id": "code-ppa-authors", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "PPA-focused unique authors: 3,292\n", + "With a Wikidata qid: 2,375 (72.1%)\n" + ] + } + ], + "source": [ + "_ppa_auth = df_ppa[df_ppa[\"author_key\"].ne(\"\")].copy()\n", + "n_authors_ppa = _ppa_auth[\"author_key\"].nunique()\n", + "by_author_ppa = _ppa_auth.groupby(\"author_key\", sort=False)[\"qid\"].agg(group_has_qid)\n", + "n_with_qid_ppa = int(by_author_ppa.sum())\n", + "pct_qid_ppa = 100.0 * n_with_qid_ppa / n_authors_ppa if n_authors_ppa else 0.0\n", + "\n", + "print(f\"PPA-focused unique authors: {n_authors_ppa:,}\")\n", + "print(f\"With a Wikidata qid: {n_with_qid_ppa:,} ({pct_qid_ppa:.1f}%)\")" + ] + }, + { + "cell_type": "markdown", + "id": "fb752d7b", + "metadata": {}, + "source": [ + "So, roughly thirty percent of the unique poets do not have a Qid assigned to them; here are the five name keys with the most poem rows among authors still missing a Wikidata id." + ] + }, + { + "cell_type": "code", + "execution_count": 50, + "id": "2569b4b2", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
author_keyrows
0Ward, Frederick William Orde1936
1Ellison, Henry1622
2Heath, Robert287
3Douglas, Evelyn258
4Goddard, William236
\n", + "
" + ], + "text/plain": [ + " author_key rows\n", + "0 Ward, Frederick William Orde 1936\n", + "1 Ellison, Henry 1622\n", + "2 Heath, Robert 287\n", + "3 Douglas, Evelyn 258\n", + "4 Goddard, William 236" + ] + }, + "execution_count": 50, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "no_qid = by_author_ppa.index[~by_author_ppa]\n", + "top_no_qid = (\n", + " _ppa_auth[_ppa_auth[\"author_key\"].isin(no_qid)]\n", + " .groupby(\"author_key\", sort=False)\n", + " .size()\n", + " .sort_values(ascending=False)\n", + " .head(5)\n", + " .reset_index(name=\"rows\")\n", + ")\n", + "top_no_qid" + ] + }, + { + "cell_type": "markdown", + "id": "fd33f377", + "metadata": {}, + "source": [ + "We tabulate Wikidata `sex_or_gender_wd` for one row per PPA-focused author and show counts, percent of all such authors, and a bar chart." + ] + }, + { + "cell_type": "code", + "execution_count": 51, + "id": "064b46c0", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
countpct_of_authors
sex_or_gender_wd
male194459.1
(missing)103231.3
female3169.6
\n", + "
" + ], + "text/plain": [ + " count pct_of_authors\n", + "sex_or_gender_wd \n", + "male 1944 59.1\n", + "(missing) 1032 31.3\n", + "female 316 9.6" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "import numpy as np\n", + "from IPython.display import display\n", + "\n", + "\n", + "def _ppa_plot_style():\n", + " plt.rcParams.update(\n", + " {\n", + " \"figure.facecolor\": \"white\",\n", + " \"axes.facecolor\": \"white\",\n", + " \"axes.edgecolor\": \"#333333\",\n", + " \"axes.labelcolor\": \"#222222\",\n", + " \"text.color\": \"#222222\",\n", + " \"axes.grid\": True,\n", + " \"grid.color\": \"#cccccc\",\n", + " \"grid.linestyle\": \"-\",\n", + " \"grid.alpha\": 0.45,\n", + " \"axes.spines.top\": False,\n", + " \"axes.spines.right\": False,\n", + " \"font.size\": 10,\n", + " \"axes.titlesize\": 11,\n", + " \"axes.titleweight\": \"600\",\n", + " }\n", + " )\n", + "\n", + "\n", + "_ppa_plot_style()\n", + "\n", + "_poet_ppa = (\n", + " _ppa_auth.sort_values(\"edition_year\", na_position=\"last\")\n", + " .drop_duplicates(\"author_key\", keep=\"first\")\n", + " .copy()\n", + ")\n", + "\n", + "g = _poet_ppa[\"sex_or_gender_wd\"].fillna(\"\").astype(str).str.strip()\n", + "g = g.replace({\"nan\": \"\"})\n", + "g_display = g.where(g.ne(\"\"), \"(missing)\")\n", + "gender_tab = g_display.value_counts().to_frame(\"count\")\n", + "gender_tab[\"pct_of_authors\"] = (100 * gender_tab[\"count\"] / len(_poet_ppa)).round(1)\n", + "display(gender_tab)\n", + "\n", + "fig, ax = plt.subplots(figsize=(5.5, 3.2))\n", + "_gplot = gender_tab.drop(index=\"(missing)\", errors=\"ignore\")\n", + "ax.bar(_gplot.index.astype(str), _gplot[\"count\"].values, color=\"#4c72b0\", edgecolor=\"none\")\n", + "ax.set_ylabel(\"Unique authors\")\n", + "ax.set_xlabel(\"sex_or_gender_wd\")\n", + "ax.set_title(\"PPA-focused authors: gender (known values only)\")\n", + "plt.xticks(rotation=20, ha=\"right\")\n", + "fig.tight_layout()\n", + "plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "id": "md-ppa-dob", + "metadata": {}, + "source": [ + "We histogram wikidata birth years from the same one-row-per-author table (20-year bins, years from 1000 CE onward)." + ] + }, + { + "cell_type": "code", + "execution_count": 52, + "id": "code-ppa-dob", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "_ppa_plot_style()\n", + "y = _poet_ppa[\"birth_year_wd\"].dropna()\n", + "y = y[y >= 1000]\n", + "bin_edges = np.arange(1000, 1931, 20)\n", + "fig, ax = plt.subplots(figsize=(7, 3.5))\n", + "ax.hist(y, bins=bin_edges, color=\"#4c72b0\", edgecolor=\"white\", linewidth=0.4)\n", + "ax.set_xlabel(\"date_of_birth_wd\")\n", + "ax.set_ylabel(\"Unique authors\")\n", + "ax.set_title(\"PPA-focused authors: DOB (20-year bins, CE)\")\n", + "fig.tight_layout()\n", + "plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "id": "md-ppa-period", + "metadata": {}, + "source": [ + "We count how many poem rows fall into each `period` label (raw corpus counts)." + ] + }, + { + "cell_type": "code", + "execution_count": 53, + "id": "code-ppa-period", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "_ppa_plot_style()\n", + "pc = df_ppa[\"period\"].fillna(\"(missing)\").astype(str).str.strip()\n", + "pc = pc.replace({\"\": \"(missing)\", \"nan\": \"(missing)\"})\n", + "tab = pc.value_counts()\n", + "fig, ax = plt.subplots(figsize=(7, max(4, min(28, 0.22 * len(tab)))))\n", + "y = list(range(len(tab)))\n", + "ax.barh(y, tab.values, color=\"#4c72b0\", edgecolor=\"none\")\n", + "ax.set_yticks(y)\n", + "ax.set_yticklabels(list(tab.index), fontsize=8)\n", + "ax.invert_yaxis()\n", + "ax.set_xlabel(\"Poem rows\")\n", + "ax.set_title(\"PPA-focused corpus: period (raw counts)\")\n", + "fig.tight_layout()\n", + "plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "id": "md-ppa-country", + "metadata": {}, + "source": [ + "We take the first citizenship label (text before `|`) from `country_of_citizenship_wd` on the same one-row-per-author table as gender (`_poet_ppa`), then count unique authors and show the 25 most frequent values." + ] + }, + { + "cell_type": "code", + "execution_count": 54, + "id": "code-ppa-country", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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inH+/PlMn8uUlBTefPNNfvjhB1q0aAHADz/8YIxIpl9laNCgAQcOHODQoUNs27aNCRMm4O3tnaG/tLQ01q9fz/79+7nvvvsA+PTTT3M8jcXJyYlNmzbx/fff8+CDDwI3nvv06QdZOXLkCAD33nuv8Vi5cuU4efIkDzzwAABly5a1my5isViYPXs2NpuNkiVLsnHjRqKjo+nSpQsA0dHRme73djP+01tvvcWVK1eAG1M3MvPHH3+QlpZGQkICO3fupF+/fkRHR9utU7VqVZydnYEbr105pQJXRBxK+qXTkJAQ/vjjDwCaNm1qXIq92cSJE6latSrt2rUzislq1aoREhLCyZMn+fnnn4Ebl39dXFxo3769MRqU02kKWfniiy+M75999lkCAgKoVKkSw4YNA24UV3v27OHvv/82LiFWqVKFSZMm4evrS8mSJXnkkUeMP5I55enpaYyUbN68mTfffJPo6Gjuu+8+Y0Tt1KlTxiXBI0eO0L59e+6//37GjRtn9JM+pzn9j4nZbGbMmDF4eHjQsWNHGjVqlONM165dY/To0TzwwAPUqlWLxx9/3GhLf17yyujRowkMDKR27drUqFEDgLi4OODGeTh79iwA7dq1o3Xr1nh4eDBu3DjjD2ZmRVfLli0ZOHAg7u7uVK1aFR8fn2zPOfxfwZGb+ai345FHHqFTp064urpSvXp1AgICcHJyAmDVqlW88847/PTTTzRs2JCBAwfe9n6ioqKM78ePH0+TJk1wdXWlRo0ahIaG3ulhZCopKYlhw4Zx8eJFXF1deeuttyhbtixXr161uxQfEhJCzZo1GTx4MGlpaQCZztPv0KED7du3x8vLi44dOxqPp/+MlC1bFoATJ06waNEiduzYwfXr13nyySepUKFChv769u1Lo0aN8PX1NaYKXLt2Lcf/EPbt25c6derg4+PD2LFjjcezu+NC+qixj49PjvYDNwr59NHunj17Uq9ePby9ve1eBzL7+b/djDdbt26dMardokWLDKP9zzzzDJs2beLQoUN8/fXX9O3bF7hxLufPn2+3rslkMq625eYfb01REBGH5OzsTNmyZencuTPPPvusUZhmplixYpQuXZr27dszevRoSpQoYfdGsxo1ahi31alUqRKnTp3im2++4dKlS/j6+vLBBx/YzU+EGyNctxpNSy++4cYbITL7/o8//iAhIcGY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9hr16tXLsN+nnnqK+vXrU7JkSZydnY1RxZUrVxq392rRogUffvghXbp0wdfXl2LFiuHn50ejRo0YP368MTrn7e3NqlWraNiwIcWLF6d8+fLMnDkzx7fi6dmzJ+PHjycgIIASJUrQsmVL3nvvvUzXbd26NVOmTKF8+fI4OztTt25d1q5dm+m6DRo04IUXXqBMmTKZXs7MqbZt27J27VratWuHl5cXxYoVIzAwkMGDB/PBBx/k+FZxOTnnBcXb25shQ4ZQq1YtvLy8cHZ2JiAggG7durFq1SpjakhgYCD/+c9/qFChQoY5kVmZPXs2c+bMISgoCDc3N1xcXKhSpUq2xd/dev5ultnvUcmSJalbty4jR468rTtadOrUiQ4dOlCmTBlcXFzw8PCgVq1ahIeH39Eb9LIyevRonnzySUqVKkWJEiXo0KED77333i3/2QPo1q0bLi4upKSksHv3bru2Rx99lB49emQ63WbAgAGsWLGCpk2b4u7ujrOzM5UqVeLpp5+2+9m4Gxlzql27djz55JPUqlULHx8fihUrRkBAAD169GDTpk12H/QA/3dXlI4dO+Lh4ZHj/ZhsubnRpIiIiIjk2DfffGOMdt/qfQPZmTJlCmvXrqVNmzZ2I9x3w93KeLdduHCB4OBg0tLS+PDDD6lfv36Ot9UIroiIiIiDGzVqFCVKlOCLL74w7gpT1L377rukpaXx4IMP5qq4Bc3BFREREXF4/v7+xgc+/FtMmjTJbrpXbmiKgoiIiIgUKZqiICIiIiJFigpcERERESlSVOCKiIiISJGiAldEHI7NZsNisWT7SUsiIiKZUYErIg4nOTmZ4OBgkpKSCjpKrlitVn777TesVmtBR8kxZc4fypw/CmNmKJy5HT2zClwRERERKVJU4IqIiIhIkaICV0RERESKFBW4IiIiIlKkqMAVERERkSJFBa6IiIiIFCkqcEVERESkSFGBKyIiIiJFigpcERERESlSVOCKiIiISJGiAldEREREihQVuCIiIiJSpKjAFREREZEiRQWuiIiIiBQpKnBFREREpEhRgSsiIiIiRYoKXBEREREpUlTgioiIiEiRogJXRERERIoUFbgiIiIiUqSowBURERGRIkUFroiIiIgUKSpwRURERKRIUYErIiIiIkWKClwRERERKVJU4IqIiIhIkaICV0RERESKFBW4IiIiIlKk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Dh48yMGDB/nxxx+ZOnUq77zzDkFBQXz99df8/PPP/P3334SFhdkVritXruS3337DZDLh4+OD2WymWLEb/8/6+/tz8uTJW+4/MTGRYsWK4efnh9Vq5Z133uHw4cM5yp6YmEiJEiXw8fHBYrEwadKk2z8RIiIit0EFrogDevvttxkwYAA1atQgICDA+HruueeIi4vDZDLx1FNP0bx5c6pVq0adOnXw8PAwtt+5cyf16tXD3d2dbt268eqrrxpTHWbOnMlzzz2Hj48Pc+fOzXT/nTp1olevXtSpU4fAwECOHDlCixYtcpR97NixODk54e/vT+3atWnWrNmdnxAREZFcMNlycs1RRCQfWSwWgoODCWw1AZvZJdN1tszrns+psme1WomPj8fPz88hL9llRpnzhzLnj8KYGQpnbkfP7HiJRERERETugApcERERESlSVOCKiIiISJGiAldEREREihTdB1dEHNbqGZ3w9PQs6BgiIlLIaARXRERERIoUFbgiIiIiUqSowBURERGRIkUFroiIiIgUKXqTmYg4rEHTP8nwSWaO+AlmIiLiWDSCKyIiIiJFigpcERERESlSVOCKiIiISJGiAldEREREihQVuCJFTHBwMC4uLri7uxtfS5cuLehYIiIi+UZ3URApgl5++WXGjBmTZXtaWhrFiunXX0REiiaN4Ir8CwwdOpRhw4bRt29fPD09WbZsGQcOHKBly5aULFkSPz8/BgwYwOXLl41tgoODCQsLo2PHjri7u9OgQQNiYmKM9sTEREaOHEmFChXw9PSkcePGnDt3DgCLxWK0lS5dmkcffZS//vor349bRET+nTSEI/IvsXbtWj766CPWrVvH33//zfHjx5k7dy4PPPAAf/zxB3369OHFF1/krbfeMrZZuXIlW7dupU6dOjzzzDOMGjWKqKgo4EbRfOXKFb755hsCAgL48ccfueeeewB4/PHHKVasGIcOHcLZ2ZknnniCkSNHsmrVqkyzpaSkkJKSYiwnJycDYDaT4d9wq9V6907KXWa1WrHZbA6d8Z+UOX8oc/4ojJmhcObO68xm852NwZpsNpvtLmUREQcQHBzM/v37cXH5vw9IePDBB0lNTWXTpk1Zbrdp0ybGjx/P8ePHjX6aNm3K3LlzAfjqq6/o1KkTSUlJ/PbbbwQEBHDmzBkqVKhg1098fDwBAQHEx8dTsmRJ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mIqQ3n7mzBljOTU11a69XLlynD592q7P06dPU65cuVxnyUx6/w888AAAFy9eJCUl5bb6L1asGF26dKF9+/bGXNeKFSty4sSJW+473bVr14iLizP2nd0L0ogRI7jvvvt477338Pb2ZtOmTQwdOjRHWQMDA/n777/5/fffjSL37NmzWa7v4uKCi8v/fSRvejarFWw3reeIL6L/ZDKZMJvNhSJrOmXOH8qcPwpjZiicuR05s+Mlkn+N4sWL88EHH3DvvffStm1bfv/991z3MWDAANasWcP3339Pamoqs2bNIjk52a49MjKS/fv3c+3aNV566SW79kGDBhEZGclXX31FWloaixYt4vLlyzmaOpETgwYNYvbs2Zw7dw6LxcLYsWPp0KFDjkZvAV577TV27tyJxWLBZrPx1VdfERUVZUwxeOqpp1i4cCFffPEFVquV33//3XiT3KBBg1i8eDFHjx4lJSWFKVOmULZsWWM6h7+/PydPnsxy34mJiXh4eODp6cm5c+d49dVXc3zc5cuXp0WLFrz44otcvXqVX375hTfeeCPH24uIiNwJFbhSoJydnVm/fj01atQgODiYixcv5mr7Dh06MH36dHr06EFAQABpaWncd999xmhghw4dmDlzJr169aJMmTJYrVZjPi1AmzZtWLRoEcOGDaNUqVKsW7eOHTt24O3tfVeOL/0uA82aNaNSpUqkpqayevXqHG/v5ubGpEmTKFu2LN7e3gwfPpxp06YxYMAAAHr06MH8+fN59tln8fLyonHjxsbo7qOPPsqoUaPo2rUrAQEB/Pjjj2zZsoVixW5cuJk0aRKLFy/Gx8cn0/sOz58/n61bt+Lp6Un37t3p1atXro59zZo1nDt3jtKlSzNw4EAef/zxXG0vIiJyu0w2m82W/WoihcO1a9coVaoUO3bsoGXLlgUdR26TxWIhODiYwFYTsJn/b+rClnndCzBV9qxWK/Hx8fj5+TnkJbvMKHP+UOb8URgzQ+HM7eiZHS+RSC5t3LiRq1evkpyczMSJEylZsqRxGV5ERET+fVTgSqG3atUqypQpQ2BgINHR0Xz88ccUL168oGOJiIhIAdFdFKTQu9UniYmIiMi/j0ZwRURERKRI0QiuiDis1TM64enpWdAxRESkkNEIroiIiIgUKSpwRURERKRIUYErIiIiIkWKClwRERERKVL0JjMRcViD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8N5775V2DBERucWp4Motb/r06WRlZXH27FlWrFhBeno6TZs25cSJE6UdzapLly5kZ2dz7tw5XnzxRQYPHsxvv/1W2rFEREQckgquyP/PZDJx++238+677+Lr68usWbOAwq/QNm7cmMWLF7N7926GDh3Kvn378PLywsvLi9TUVADee+89GjVqhI+PDxERESxevNi6/aVLlxg+fDhms5nw8HCWL19e5Izdu3fHbDazd+9e6+MbN27krrvuwmw2U79+fT766CPrusGDB/Poo4/ywAMP4OXlxR133MH27dut6/9+C8Lq1aupVq2azX73799PkyZN8PHxoUOHDhw9erTQfH+//eL48eMMGDCAkJAQzGYzrVu35sKFC0U6VhERkX+qbGkHEHE0ZcuWpVu3bnz++efXHRsZGcmbb77J7NmzrfehAqxdu5bhw4fz/vvvExUVxalTp0hPT7eu//TTT3n77beZM2cOiYmJPP7443Tu3Blvb+9r7u/SpUt8+OGHnD59mttuuw2AH3/8kZ49e7Jy5UqioqLYsWMH0dHR7Ny5kzp16gCQmJjIBx98wPvvv8+CBQvo2rUrv//+O2azuUjnZMGCBWzYsIHw8HCGDRtG//792bx58zW3sVgsdO3aldtvv539+/fj7e3NN998g5vblX9X5+bmkpuba13OyckBwDAMLBZLkTI6AovFosx2oMz2ocz244y5SzpzYb8rboQKrkghQkNDOXPmzD/eft68eYwaNYp27doBULlyZSpXrmxd36RJE+ubsQYOHMiQIUP47bffaNq0aaHzrV+/HrPZbC1+8+bNo1GjRgC89dZbDB482LqvVq1a0aVLF1asWMGkSZMAuO+++4iJiQFg6NChzJkzh3Xr1jFgwIAiHc+wYcOoW7cuADNmzCA4OJi0tDTCwsKuus13333Hzz//zNatW6lYsaI1W2ESEhKYMmWKddnNzY3IyEhOnTrFxYsXi5TREVgsFnJycjAM46afnO1Fme1Dme3DGTODc+Yu6cxBQUE3tb0Krkgh0tPT8fPz+8fbp6Sk8PDDD191fXBwsPV7k8lExYoVycrKuur46OhoVq9eTU5ODiNHjuSLL75g6NChACQnJ7Np0yYWLVpkHV9QUICPj491OSIiwma+iIgImyvK1/PX7YOCgnB3dyc9Pf2aBTclJYXQ0FBrub2W2NhYxowZY13O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ioiIiIhL0acoiNP66ycGiIiIiFymK7giIiIi4lJUcEVERETEpajgioiIiIhLUcEVEREREZeigisiIiIiLkUFV0RERERcigquiIiIiLgUFVwRERERcSkquCIiIiLiUlRwRURERMSlqOCKQ+vUqRPz5s0rsfkbN27M4sWLS2z+G3Hw4EGaNWuGt7c3zzzzTGnHKXZbtmzBbDaXdgwREbkFqOBKqYqKimL27NnW5cOHD1OjRg1GjRqFYRhs2LCBp556qvQC2tGMGTO44447yMrKYubMmVesX7x4MWXKlMHLywtvb29q1arFrFmz/vH+/n7uRUREXIUKrjiMH3/8kVatWvHwww8zZ84cTCZTaUeyq6SkJBo2bHjNMQ0bNiQ7O5usrCwWLlxIXFwcn3322Q3txzAMLl26dDNRRUREHJoKrjiEHTt20LZtWyZMmEB8fLz18b9eZbz8EveCBQuoWrUq/v7+jBs3zmaeuXPnWtdNnDjxilsQXnvtNev6uLi4K3K8++671KtXD7PZTKtWrdi9e7dNlvHjx3Pffffh6elJixYtSE9PJz4+nsDAQMLCwli1atVVjzE/P5/Y2FjCw8MJDAykd+/eZGRkAHDXXXexefNmxo8fj5eXFxs3brzuOWvTpg3169dn3759GIbBzJkzqVmzJn5+fnTs2JHff//dOrZatWokJCTQokULPDw86NWrF9u2bbPur1OnTgCYTCb27Nlj3W727NlERUVZl/fv30+LFi3w9vambdu2jBs3zmb9uHHjiIiIwNvbm9tvv53333//uschIiJS3FRwpdRt2rSJTp06MXv2bEaMGHHNsVlZWezbt4+DBw+yfft2Xn/9dbZs2QLAF198wX/+8x9WrlzJsWPHcHNzY//+/Tb7iYuLY8WKFRw7dgyAn376ybp+27ZtDBs2jLfeeouMjAweeughOnTowLlz56xjEhMTmTNnDqdPn8bT05N7770XX19fjh07xuTJkxkyZAj5+fmFZk9ISGDdunVs376dpKQkTCYT/fv3B2Dnzp3ce++9TJ8+nezsbNq3b3/N82AYBps3b2b//v00adKEJUuWMGvWLFavXs3Ro0epX78+Xbp0oaCgwLrN4sWLefvtt8nOzua9996z2d+GDRuuuT/4s6B37dqVTp06cfr0aaZNm8b//vc/mzGNGjXiu+++4+zZs/znP/9h4MCBJCUlXXfu3NxcMjMzrV9ZWVnW47RYLE71pczKrMzKfKvkLsnMN6vsTc8gcpO2bNlCUFAQnTt3vu5YwzBISEigQoUK1KtXj3vuuYddu3YRFRXF0qVL6d+/P3fddRcAkyZN4tVXX7Vum5iYSP/+/bn77rsBiI+P57XXXrOuf+eddxgwYACtW7cGYPTo0bzxxhusX7+efv36ATBgwAAaNGgAQI8ePXjhhRd4+umnAejfvz9PPPEEKSkp1KpV64rsS5Ys4cUXXyQ8PByAWbNmERoaytGjRwkJCSnSudq3bx9msxk3NzdCQ0OZPXs2bdu25f7772fkyJHWWxymTp3K/Pnz2blzJ/fccw8Aw4YNo06dOgCUKVOmSPv7q2+++YbTp08TFxdH2bJlad68Ob1797b5I+JyYQfo06cP06ZNY8eOHVSvXv2acyckJDBlyhTrspubG5GRkZw6dYqLFy/ecNbSYrFYyMnJwTAM3Nyc4/qBMtuHMtuHM2YG58xd0pmDgoJuansVXCl1EydOZNu2bbRr144vvviCgICAq4718fHBw8PDuuzp6Wm92nf06FGbl8vLlStHlSpVrMvXW5+WlmazHqB69eqkpaVZl4ODg63fe3h42PwAXs6VnZ1daPa0tDSqVatmXQ4JCcHd3Z20tLQiF9yGDRva3EJwtbnd3d0JCQmxyX65WP9TR48epUqVKpQt+/+eNsLDw20K7iuvvMKCBQtIS0vDZDKRnZ3NqVOnrjt3bGwsY8aMsS7n5OQQExNDQEAA3t7eN5XbniwWCyaTiYCAAKf6JaXMJU+Z7cMZM4Nz5nb0zCq4UurKly/PypUr6dmzJ23btmXTpk0EBgbe8DwhISEcOXLEulxQUGC9FeHy+pSUFOtyfn6+zfqwsDCSk5Nt5kxOTiYsLOyGsxTm8vzNmzcH4Pjx4+Tm5hbL/H/PnpeXx9GjR23m/vsTUGFPSJ6enpw/f966/Pfzd/z4cQoKCqwlNzU11bp++/btxMfHs2nTJiIjI3Fzc6Nx48YYhnHd/O7u7ri7u1+RzWQyOeQT57VczuxMuZXZPpTZPpwxMzhnbkfO7HiJ5JZUvnx5PvjgA2rXrk3btm05efLkDc/Rt29fli5dyvfff09+fj4vvvgiOTk5NusTExP59ttvycvL4/nnn7dZP2DAABITE/nqq68oKChg7ty5nD59uki3ThTFgAEDmDp1KkeOHCE7O5sxY8bQvn37Il+9vd7cr732Gj///DO5ublMnDiR0NBQ6+0ahQkKCuLw4cM2j12+n7egoIA9e/awZMkS67oWLVpQqVIlEhISyM/P57vvvmPFihXW9ZmZmZQtW5bAwEAsFgv/+9//bO5xFhERsRcVXHEY5cqVY/ny5dStW5eoqCiOHz9+Q9u3b9+eyZMn0717d4KDgykoKOC2226zXhls3749L7zwAj169KBKlSpYLBbr/bTw56cSzJ07l8ceewx/f3+WLVvGhg0biu0/J4iNjaVDhw7cfffdVKtWjfz8fN59991imfvhhx9mxIgRdOnSheDgYPbu3cvatWttbif4u9GjR7Nx40bMZjNdunQB/vwUiq+//hqz2cz48eMZNGiQdXy5cuVYvXo169ato1KlSowbN44BAwZYz2/Hjh3p0aMHDRs2JCQkhP3799OyZctiOT4REZEbYTKK8vqhiBPKy8vD39+fDRs20KpVq9KO45KeeOIJLBYLCxYsKNZ5s7OziYqKYtOmTfj4+BTr3CXJYrGQkZFBYGCgQ75kVxhltg9ltg9nzAzOmdvRMzteIpGb8OGHH3LhwgVycnIYP348fn5+13yZXm7Mtm3bOHLkCBaLhS+++IKlS5fSs2fP0o4lIiJiQwVXXMqSJUuoUqUKISEh7Nq1izVr1lC+fPnSjuUyfv/9d1q0aIGXlxdDhw5l6tSpdOjQobRjiYiI2NCnKIhLudb/JCY3b9CgQTb35YqIiDgiXcEVEREREZeigisiIiIiLkUFV0RERERcigquiIiIiLgUFVwRERERcSkquCIiIiLiUlRwRURERMSlqOCKiIiIiEtRwRURERERl6KCKyIiIiIuRQVXRERERFyKCq6IiIiIuBQVXBERERFxKSq4IiIiIuJSVHBFRERExKWo4IqIiIiIS1HBFRERERGXooIrIiIiIi5FBVdEREREXIoKroiIiIi4FBVcEREREXEpKrgiIiIi4lJUcEVERETEpajgioiIiIhLUcEVEREREZeigisiIiIiLkUFV0RERERcigquiIiIiLgUFVwRERERcSkquCIiIiLiUsqWdgARkb8zDAOAnJwc3Nyc5+9wi8XC+fPnyc7OdprcymwfymwfzpgZnDO3PTJ7enpiMpn+0bYquCLicM6ePQtATExM6QYREZFSs2XLFry8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FBVcEREREXEpKrgiIiIi4lJUcEVERP6mX79+vPDCC6Udo0TNmTOHLl26lHYMkRKhgisiIi7haqX0s88+o2bNmjc017x583j66aeLK1qpq1mzJp999llpxxCxm7KlHUBERMTRmM3m0o7glAzD4NKlS5Qtq3ohpUtXcEVE5JZy+aX5VatW0bp1axo1asTIkSPJzs62jvn71eBTp04xZMgQbr/9dtq0acOaNWto3bo1ixYtAiAtLY2aNWvy888/W7fJzMykZs2afPPNN9bHDh48yKOPPkrDhg256667eOaZZzhz5sxVs/7xxx+MGjWKli1bUr9+fTp16sRHH31kM+avOS7r0qULc+bMsa4HGDZsGDVr1rQuX3at85Cbm8uUKVNo1qwZ9erVo1evXvz444/W9d988w01a9bkyy+/pFu3btSrV4/vvvuOX375hX79+nHHHXfQqFEjunbtarOdSElTwRURkVtOamoqn3/+OfPnz2fBggXs3LmTN99886rjx40bR1paGu+++y6vvfYa7777LqdPn76hfZ48eZJ+/fpx++23s3r1ahYtWsSpU6cYMWLEVbfJzc2lQYMGzJ8/nw0bNtCnTx+effZZ9uzZU+T9rlq1CoDp06fzzTffWJfh+udh+vTpfPrpp7z88st89NFHREREMHjwYM6ePWuzj+nTpzN27Fg+/fRT6taty9NPP02VKlVYtWoVq1evZujQoZQrV67ImUVull5DEBGRW47FYmHGjBl4eXkB0L17d77++utCxyYlJbF161ZWrlxJ48aNAZg2bRr/+te/bmifiYmJ1K9fn2effdb62LRp02jVqhVJSUlUr179im2Cg4MZMmSIdXnQoEF8+eWXfPzxx9Ys1+Pv7w+Aj48PgYGBNuuudR7Onz/P0qVLmTFjBlFRUQBMnTqVNm3asGLFCp544gnrPKNHj6ZVq1bW5WPHjjFkyBDrvc+FHZtISVLBFRGRW05YWJi11AFUrlz5qldkDx06RNmyZWnYsKH1sZo1a+Lj43ND+/zpp5/45ptvbOa5LCUlpdASeOnSJd58803Wr1/PiRMnyMvLIy8vDw8Pjxva99Vc6zykpqaSn59P06ZNrevLlSvHHXfcweHDh23m+fsxPfroo0yYMIHVq1fTsmVLOnXqRERERLFkFikKFVwREXEJXl5eZGVlXfF4ZmamTYkDrngTlMlkwmKxFDqvYRjWMVfj5uZmMxYgPz/fZozFYqFdu3aMGzfuiu0rV65c6LwLFixg0aJFTJw4kTp16lCxYkVefPFF8vLybPb91/0CFBQUXDXrX13rPFztuA3DuOKxvxfuUaNG0bVrVzZv3szWrVuZM2cOs2fPpkOHDkXKJXKzdA+uiIi4hBo1arBv374rHt+3bx81atT4x/PWqlWLgoICm7l///13MjMzrct+fn7An/fZXvbLL7/YzFO/fn0OHjxIWFgY1apVs/m62hXZ77//nvbt29O9e3fq1atHeHg4ycnJNmP8/Pxs9puVlcWRI0dsxpQrV+6qBf5qIiIiKF++PN9//731sfz8fH766acifexa9erVefTRR3n77bf517/+xcqVK29o/yI3QwVXRERcwoABA0hNTWXy5Mn88ssvJCUlsWTJElasWGFzH+uNqlGjBq1bt2bChAns2bOHffv2ERsbS4UKFaxjKlSoQGRkJG+99RYHDx5k586dzJo1y2aegQMHcvbsWUaPHs3evXtJTU1l27ZtjB8/nkuXLhW674iICLZv386uXbs4dOgQcXFxZGRk2Iy5++67Wb16Nd999x2//vorY8eOpUyZMjZjQkND2bFjBxkZGZw7d65Ix+3h4UG/fv2YNm0aW7du5eDBg0yYMIELFy7Qq1evq2538eJF4uPj+eabb0hPT+f777/nxx9/vOHPIha5GbpFQUREXEJYWBjLli1j5syZDBo0iNzcXKpXr86MGTPo3LnzTc09Y8YMYmNj6du3LwEBAYwZM4ZXXnnFZsy0adN47rnn6N69OzVq1GD8+PEMGjTIuj4oKIgVK1YwY8YMBg8eTF5eHqGhobRu3dp6i8PfDR8+nCNHjvDII49QoUIF+vTpw/33329zK8bQoUNJTU3l8ccfx9vbm6effpq0tDSbeSZMmMBLL73E8uXLCQoK4ssvvyzScY8bNw6LxcKzzz5LdnY2DRs2ZPHixfj6+l51Gzc3N/744w+effZZTp8+TaVKlfjXv/7F6NGji7RPkeJgMv5+446IiIhcV+vWrXnkkUd45JFHSjuKiPyNblEQEREREZeigisiIiIiLkW3KIiIiIiIS9EVXBERERFxKSq4IiIiIuJSVHBFRERExKWo4IqIiIiIS1HBFRERERGXooIrIiIiIi5FBVdEREREXIoKroiIiIi4FBVcEREREXEp/x+PuSxSr/uj9QAAAABJRU5ErkJggg==", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "_ppa_plot_style()\n", + "cc = _poet_ppa[\"country_of_citizenship_wd\"].fillna(\"\").astype(str).str.strip()\n", + "cc = cc.replace({\"nan\": \"\"})\n", + "cc = cc.str.split(\"|\").str[0].str.strip()\n", + "cc = cc.where(cc.ne(\"\"), \"(missing)\")\n", + "tab = cc.value_counts().head(25)\n", + "fig, ax = plt.subplots(figsize=(7, max(4, 0.3 * len(tab))))\n", + "y = list(range(len(tab)))\n", + "ax.barh(y, tab.values, color=\"#4c72b0\", edgecolor=\"none\")\n", + "ax.set_yticks(y)\n", + "ax.set_yticklabels(list(tab.index), fontsize=9)\n", + "ax.invert_yaxis()\n", + "ax.set_xlabel(\"Unique authors\")\n", + "ax.set_title(\"PPA-focused authors: first citizenship (top 25)\")\n", + "fig.tight_layout()\n", + "plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "id": "md-ppa-occ", + "metadata": {}, + "source": [ + "We take the first Wikidata occupation (text before `|`) per author and plot the twenty most common labels." + ] + }, + { + "cell_type": "code", + "execution_count": 55, + "id": "code-ppa-occ", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAArEAAAJ1CAYAAADOo6iPAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjEsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvc2/+5QAAAAlwSFlzAAAPYQAAD2EBqD+naQAAmDFJREFUeJzs3X98zfX///HbOTMH+2HDbM1s86NCwkhCaX68y4823inyK8pbIUoKmcJKjUpNRL5515Ipyo9Q9MOv5EdJVKZoyzC/mvzYdjA/Xuf7h895vS2/5sdsr7P79XI5l8t5nfM8r9fzdd/r8DjP83y9js3lcrkQEREREbEQe2F3QERERETkcqmIFRERERHLURErIiIiIpajIlZERERELEdFrIiIiIhYjopYEREREbEcFbEiIiIiYjkqYkVERETEclTEioiIiIjlqIgV+T9DhgyhWrVqeW41a9bkX//6FxMmTCA3N9dsO2HChHPa3nzzzURHR/PSSy+RlZWVZ9379+/nxhtvNNsOGDDgsvv39ddfExMTQ+3atalWrRrNmjW76n0u6s7+mxSGdevWMWHCBCZMmHDO3/TsYyAjI6NQ+nctnDp1ijFjxtC0aVNuuukmqlWrxoQJE/j000/N/Vu3bt01296WLVvMTPOT2+OPP061atWoXr06R44cMR//6quvLvh+6tWrF9WqVaNGjRrk5uaSkZFhtp0wYYLZzv3YkCFDLtqHZs2aUa1aNbp27XrRdhfaTn5lZGSY2WzZsuWyXy95FYf3b3FXorA7IFKUnThxgj///JO33nqLjRs3kpSUdMG2p06dYteuXSQlJbF27VrmzZuHw+EA4PPPP8cwDLPt8uXLcTqd+Pj45Ksfhw4d4sknn+TEiRNXtT9yeb7//nveeustADp27Ii/v38h9+ja++ijj3j//fev2/a2bNliZtqoUSPCwsIu2r5+/fp88803uFwuNm3axN133w3Apk2bzDYbN24077tcLn7++WcAatSoYb4HrSAjI8PMplKlStSqVauQe2RtxeH9W9xpJFbkPJKTk0lNTWXBggWUL18egFWrVrF27dpz2o4bN460tDSWLVtmjhhu3bqVhQsXmm0WLVqU5zXHjx/nm2++yXd/0tLSzAL26aefJjU1lW+//fay90us6cSJE3k+BF1LZ4/4rVq1irS0NJ566ikeeOAB0tLSSEtL44477rjoOo4fP14gfQOoV6+eef/sYvXsInbfvn3s27cPOPNecY+6uV8bFhZm7stTTz112X349ttvSUtLY+bMmZe/A1IkPfXUU+YxcakPUlJ0qYgVuQCbzcYtt9zCfffdZz7266+/XrB9REQEDz300Dltd+3aZY4MtWvXjpIlSwLnFrYXMmTIEDp37mwuv/nmm1SvXt38CtQwDKZPn05MTAy33HILtWvX5t///jeffvrpOevauXMnzz33HHfeeSc1atSgYcOG9OzZkx07dgAX/oqta9eu50xhSE9P58knn6RJkybUrFmThg0b8sADDzBlypQ82/z111/p168fDRs2pEaNGtx999288sorOJ3OPO1+++03OnXqRK1atWjevDmffPJJvvJxGzZsGG3btqV+/frcfPPN3HbbbTz66KP8+OOPedqdb1/g3K+WmzVrZo7iANx9990XnMZx4MABBg0aRN26dWnatCmvv/46p0+fztPm559/5rHHHuO2224zc3jxxRc5fPiw2ebsr6PfeOMN3nzzTZo0aUKtWrXIycnJV+Znr+NSX5NXq1aN2bNnm8t33XWXOX3gfNMJ1q1bZz42Y8YMRo8eTcOGDWncuDFw5m/9n//8h0aNGlGzZk0aNWpE165dmTVrlpn9sGHDzO1169btktNF6tSpQ4kSZ740dBexp0+f5tdff8XLy4smTZoA/ytqzy50o6KizsnkYl/z5+bm0qNHD3N60JdffgmcfzqBYRi88cYbNGrUiNq1a9OvXz/++uuv8673tddeo3379jRs2JCbb76ZevXq0bVrV5YuXWq2GTJkCN26dTOXhw0blue9mJWVxdNPP80999xDvXr1uPnmm2nUqBFPPPEE27Ztu+A+ne3PP/9k0KBBNGrUiBo1atCkSROGDRvGnj178rRzuVx88sknPPDAA9SpU4datWrxr3/9i6lTp15Wm/Mdh2cfQ+5/o87++yQmJvL222/TtGlTatWqRdeuXfnjjz/M1+c3h0u9fy/0b93+/fuJi4ujadOm5r+RAwYMOCfjs4+Jr776irZt25r/X6xZsyZffw+5eppOIHIJLpfrqtqeXax26NCBnJwcVq5cyXfffceRI0coW7bsVfVvyJAhzJ8/P89jv/zyC7/88gupqak899xzwJnR4c6dO5OdnW22O3jwIN999x179+4lIiLisrb72GOPkZaWlmddBw8eJCcnh379+gHw3Xff0adPnzzTIDIyMvjvf//L999/z+zZs3E4HBw5coQePXpw6NAh4H/FdlBQUL7788+i/dChQ6xcuZK1a9cyf/58br755svav8vx2GOP8ffffwOQk5PDlClTqFSpEl26dAFg5cqVPP7445w8edJ8TUZGBh988AHffvstc+fOPeerzuTk5DwFrns7l8r8ennzzTfN/vn5+XH06FEeeeQR828IZ4r7AwcOUKZMmTwfxC5H6dKlufnmm0lJSWHTpk0YhsHWrVs5evQoNWvWpGnTpqxZs4aNGzfSunXrPEXs2aO4l3L69Gmeeuop1qxZQ4kSJUhMTOTee++9YPu3336bt99+21z+6quv8owOn23hwoXs3r3bXM7Ozub777/nhx9+ICkpiTvvvPOS/cvKymLBggV5Hjtw4ABLlixh3bp1fPnll1SoUOGCr//tt9/o3Llzng+P+/fv59NPP2XZsmXMmzfPHJGMi4vL8+EGzhTA7uM4v22uxIwZM/IcQ99//z3dunXjiy++oEKFCledw8Xs3buX+++/P8+HkYMHD7J48WJWrFjBzJkzqVOnTp7XpKSk0L9/f/Pf/t9++42+ffuycuVKAgMDr6gfkn8aiRW5iC1btuQpQmvXrn3Btjt27ODjjz8+p6379WXKlKFp06a0atUKOPMVsXuk52Jee+01kpOTzWX39IXXXnuNH374wSxgo6KiWL16NUuXLjVHtqZNm8aff/4JwJgxY8wC9tFHH+X7779n/fr1vP7665QrV+6S/TjboUOHzGJqxIgR/Pbbb3z//fd88MEH/Pvf/zbbjRw5khMnTnDLLbewdOlStmzZwvjx4wHYvHmz+Z/ge++9Z/7H1bt3bzZt2sTkyZM5cOBAvvv05ptvsnLlSlJSUtiyZQvvvfcecCbnf/5nmx/ffvstTz75pLm8cuVK0tLSzjuNIzQ0lFWrVvHZZ5+ZczAXL14MnPlgM3r0aE6ePInD4eD9999n06ZNPPLIIwBs376d//f//t856zxy5AgvvfQSP//8M1999RUnTpzIV+aXIy0tjfvvvz/Pcn6mD8CZKQRvv/02v/76K7NmzSItLc38G06ePJnff/+d1atXM3XqVFq0aAHAzJkzGTdunLmO5ORkc5sX4x5RzcnJ4Y8//jCLxXr16pmF6j9HYsuVK5fvD2Yul4vnnnuOr7/+Gi8vL9544w3atGlzwfbZ2dlMmzYNgKCgIBYuXMj3339/wRHlESNG8M033/DLL7/w22+/MW/ePEqXLo3L5eLDDz8ELvw+d3/dXbZsWSZPnsx3333Hli1b+PXXX3nllVcAOHz4cJ7pS+czZswYs4AdP348mzZtYvjw4cCZQs39vvzxxx/N90toaCgfffQRv/76K59//jnt2rXLd5srdezYMaZPn86mTZvo0aMHAH///bd5PkJ+c7ic969bYmKiWcAOGTKETZs2mSP3x44d46WXXjrnNTk5OQwcOJBNmzbxxBNPAOB0OjXd6zrRSKzIeZz9tZ7bHXfcYX5terZhw4bl+YoUoHr16sTExJCWlsbvv/8OnPmq1uFw0LJlS0aOHInL5WLRokV06tTpivu5cuVK8/4TTzxBSEgIcKYQjIuLw+VysWrVKkJDQ/n+++8BqFq1KnFxcdhsNoArKoD8/f3x9fUlJyeHBQsWcPToUW688UaioqLMUaXt27eb0xRSUlJo2bLlOetZu3YtPXr0YMOGDQDY7XYGDRpEmTJluPfee7nttttYv359vvp04sQJnnrqKVJTU3E6nXlGxd2FfEF56qmnCA0NJTQ0lBo1avDzzz+bX9Fu376dnTt3AtCiRQvz68xnnnmGGTNmcPLkSb799lueffbZPOu88847za+vfX19OX369CUzh//N/yxo//73v2ndujUAN998M5mZmXh5eXH69Gk+/PBD0tPTuemmm2jQoMFVj0hFRUUxY8YM4EyR6i5U69WrR506dfDy8mLz5s0cPnyY1NRU87n8WrJkCceOHcPLy4vXX3/9koXY77//Tk5ODnAmB/cJWP379z/vvPkSJUqYHzyys7PzHJvbt2/PVx99fX3ZsWMHEydOJD09nWPHjuV5/mLH+LFjx8z30a233kqHDh2AM/9OvP/+++zbt88sulasWGG+bsiQIdx+++3AmZPkatSoke82V+ree++ladOmADz77LPMmDEDl8tlTmm5mhwuxf3vafny5Xnsscew2+3cd999TJ8+nQ0bNrBx40ays7Px8/MzX1OhQgUGDhyI3W4nNjbWHJ3/5xQNKRgqYkUuwtvbm0qVKtGmTRueeOIJs/A7nxIlSlCxYkVatmzJU089RalSpfKMjtSoUYOtW7cCEBkZyfbt21m3bh0HDhygQoUKfPrpp+cUw8nJyRcdFTt48KB5/4Ybbjjv/YMHD3L48GFzjmbVqlUvuh/n88/5nV5eXrz66quMHDmSX3/91Zz/a7PZePDBB0lISDC/Xr8Y99fR7tEPPz8/ypQpYz4fHBycr/4tWbLknOzOdvbl0c7nn/t3uSIjI8377pFY9xSKC/2NSpcuTUBAAJmZmXnauNWsWTPPcn4yv57+2b+goCBGjhzJ66+/ztq1a81irkSJEjzxxBN5RsUul3skFs4Use5R16ioKMqUKcPNN9/Mli1bmDVrlnkC3OUUse5CyM/P75yvi89n//795v2zj1H3h8izbdq0if79+3Pq1Knzriu/J8X997//zTOKfTnrOXLkiHmMn30M2mw2brjhBvbt22f+G3H2+/ZCI8v5aXMhl3qvnd0/X19f/Pz8yMrKMt8jV5PDpbi/SQgJCcFu/98X1e4+uVwuDh06lKeIDQ8PN9uefSUMXUnm+tB0ApHzcH/N+fvvv7N06VKeffZZSpcufd627q/9tm7dyqpVqxg9erQ58vT555+b7SZMmEDbtm1p27atOfpy+vRp82vnK3H2NIC9e/ee935gYCABAQF4eXkBZ0YqLjTP133SGeQt/M53HcV7772XtWvXsmjRIiZOnEj79u1xuVzMnj2bH3/80byqA0CXLl3Mr0bPvrm/Pq1YsSJw5mvao0ePmq87u1i4mCVLlpj33333XX7//Xd++eWXi+7j2fu3a9eu87bNb7Hv7e19wdec/Tdyn0EPZ/6zdRfx55vOUapUqXMeu1Tm19P5Ll3VvXt31q9fz7x583jzzTe5++67OXXqFG+99ZZ5TF7uByg4c9KkO6PvvvuO7du3U7ZsWapWrQr8r8h1fzUPl1fE1q5dmzJlynD48GEeeeSRS05jObtwPfsYPfvv6/bNN9+YBWx8fDxbtmwhLS3tvKPTF8vGfYw7HA7mzp3Ltm3b+OKLLy6+Y/+nbNmy5vv/7D66XC7z7+Juc/b79kIj+vlpA+d/r13qmqxn/9uVk5NjToFy//0vJ4fLPdbcf5P9+/fn+Tfy7GP3n3+3i733peCpiBUpICkpKfn6ass9Z/bsSxrld27i2WfKT5kyhX379rFz505zPqjNZuOuu+6iVKlS5rr+/PNPxo4dy99//82hQ4dYsGCBeebt2aMg7q/WPv744/P+5zx69GjWr19PUFAQrVq1ytOXgwcPUqVKFXNO4ty5c1m8eDFHjx4lOzubVatW8fjjj5vzeRs0aACcOeM7MTGR7Oxsvvzyy3wXZmefMOXj40Nubi6vv/76edu69/HAgQNs3ryZ06dPM3HixPO2Pftkq/yeAf5PVapUoXLlygAsXbqU1atXk52dzRtvvGH2O78/XHGpzOHyrk5wLR04cIBXX32VlJQUKleubE4Hgf+NYAF5TmT8448/8n3ipLtQ3bdvHy6Xizp16phFg7tgdRcbdrudunXr5rvvN910ExMmTMDLy4udO3fy6KOP5jkB8p9q1KiBr68vAPPmzWPLli0cOHCAyZMnn9P27BE5Hx8fTp8+zdSpU/OcvOR2djapqal5Ri3dx4rNZsPHx4esrKw8Z99fTOnSpc2/xS+//MKCBQvIyckxpxLA/47B6Oho83Xjx4/nxx9/5NixY2zbts380JmfNvC/kWn31/CHDh3igw8+uGhfv/rqK9auXUt2djavv/66eXy4//26nBwu9/3rzuDAgQP8v//3/8jJyeGLL77gp59+As4cZ2ePwkrh03QCkQJy9glhr732Wp4TaADat2/P5s2b2bBhA3v37s1TQOZXo0aNiImJYeHChWzYsMGcS+b26KOPml/3jRgxwrw6wbRp08wTUwDzP567774bHx8fnE4nL7/8MomJiTidTkqVKnXO13QffvhhnpEvNz8/P7PgeOmll/jPf/5Dbm7ueX+l7F//+hcAjzzyCDNmzODw4cP897//5b///S9wZmTkfP/Z/1PLli3NERr3PNLw8PDztm3Tpo15Usq///3vi14M/+yvlvv06QOc+bu98cYbl+yTm81mY9SoUfTt25fc3FwefvjhPM9HRESY676U/GReWI4fP87UqVPzXGLJ7YYbbqB69eoA1KpVixIlSnDq1ClGjx7N6NGjadCgwSVPvouKispzSaqz9/ef+169enWzyMyvFi1aMHLkSEaNGkVKSgr9+vXjvffey/PthJufnx+9e/dmwoQJZGZmEhMTA3De0dWWLVuax/Ozzz7Ls88+S2BgIP7+/uf8ilRERARly5blyJEjvPvuu7z77ruEhISwevVqWrVqxebNmzl+/Lh51YQLHePnM2LECB566CGOHj3K008/nee5cuXK8cwzzwBw22230alTJ2bPnk1GRkaeq0o0atSIbt265asNQNu2bXnnnXfY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RETEclTEioiIiIjlqIgVEREREcvR1Qk8SPdRSzzmF7sK+letRERExNo0EisiIiIilqMiVkREREQsR0WsiIiIiFiOilgRERERsRwVsSIiIiJiOSpir7GkpCTq1atX2N0QERER8WgqYkVERETEcixTxLpcLk6fPl3Y3eDUqVMevT0RERERKyjSRWxkZCQJCQnccccdlClThu+++45u3boRGhpKaGgogwYNIjc3F4CDBw/y73//m3LlyhEQEECDBg3YsWMHACdPnmTkyJFUq1aN8uXLExsby549e8ztDB06lIiICPz8/KhVqxaffPKJ+dyKFSsICAhgypQphIeH07hxYwC+/vprGjVqREBAADfccAMJCQl5+v7SSy9RsWJFgoODSUxMzPPcxx9/TJ06dQgICKBhw4asWbPGfC46OpqhQ4dyzz334OPjw+LFi8/JJTc3l6ysLPOWnZ0NgN0OXh5yMwzjim4ul+uKX+tJN+WgDJSDMlAOysDKOeRHkf/FrqSkJBYsWED16tVp2LAhzZs3JzU1lWPHjvHAAw8wZswYXnrpJV5//XVOnTpFRkYGDoeDX3/9FT8/PwBGjBjBhg0b+O677yhfvjxxcXE89NBDfPvttwDUrVuXZ599lvLly/PJJ5/Qo0cPbrvtNqpUqQJAdnY2P//8M7///jsAGzdupH379nz44YfExsZy9OhRfvvtN7PPKSkpdOvWjd27d7N69WpatWpFTEwM1apV44svvuDZZ59lwYIF1KtXj/nz5xMTE8O2bdsoX768uc+LFi2iYcOGHD9+/JxMEhISiI+PN5ftdjtRUVE0qOaDl3epgvlDXGeZmZmX/RrDMHA6nbhcLuz2Iv35rEApB2XgphyUgZtyUAZuVskhODj4km1sLpfLdR36ckUiIyMZNGgQgwYNYv369bRu3ZrMzEwz9K+//pq+ffuSlpbGqFGj+Prrr5kyZQp169Y11+FyufDz82P16tXm48ePH8fHx4f09HQqV658znbr1avHkCFD6NatGytWrKB58+YcOnSIgIAAAPr160dubi7vvffeOa9NSkriueeeY9++feZjN954I2PHjqVjx460a9eOe+65h6eeesp8vmnTpvTt25cePXoQHR1NvXr1zhm9PVtubq45Ag3gdDqJiYkh7O6h4CE/Ozt3XMxlv8YwDA4cOECFChWK9BuzoCkHZeCm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95x1XM4fdObMmcycOZMvv/ySG2+8EZvNRmBgIC7XpUvJihUrMnnyZAA2b95Mq1atuPXWW+nYsaPlDyoRERGRguZR1VL37t1JTk5m9erVnDp1iokTJ/L333/Ttm1bAOrXr89HH33E8ePH+fPPP3n77bevantZWVmULFmSChUqcOLECV588UWysrLy9drZs2ezc+dOXC4XZcuWxcvLixIlznymCA4OJi0t7ar6JiIiIuLJPKqIvfvuu5k4cSK9e/emfPnyfPzxxyxevJiAgAAAxowZw+HDhwkKCqJr1648/PDDV7W9nj17cssttxAREUHVqlUpXbo0lStXztdrN2zYQJMmTfD19aVx48b07t2b2NhYAOLi4pg0aRKBgYH079//qvooIiIi4olsrvx8913EVa5cmalTp5ojrsVNTk4O0dHRhN41FJfdcekXFJKF49sX6PoNwyAzM5OgoKBiPSVDOSgDN+WgDNyUgzJw86QcrN17YP/+/fz1119UrVq1sLsiIiIiIteJpYvYb775hho1atC/f39q1KhR2N0RERERkevE0lcnaNWqFYcOHSrsboiIiIjIdWbpIlbymhHfGn9//8LuhoiIiEiBs/R0AhEREREpnlTEioiIiIjlqIgVEREREctRESsiIiIilqMTuzxI91FLiuyPHRT0Dx2IiIhI8aKRWBERERGxHBWxIiIiImI5KmJFRERExHJUxIqIiIiI5aiIFRERERHL8egiNjIykvnz5xd2NwC45ZZbWLRoUWF3Q0RERMQjeHQRe6WSkpKoV6/eNV1nSkoK99133yXbFaXCW0RERKSoUhErIiIiIpbj8T92kJKSwosvvkhqaiqNGzfm/fffJzQ0lNTUVJ544gnWr19PYGAgAwcOZNCgQWzcuJG+ffty8uRJfH19AdiyZQt///03AwcOZMuWLXh5edGqVSsmTZpE+fLlAYiOjqZx48b89NNPrF69mptuuokPPviAW2+9FTgzwpqYmEiHDh3Yvn07ffr0Yf369Xh5eVGzZk2+/vprevbsyc6dO+nSpQteXl50796dd95555x9ys3NJTc311x2Op0A2O0U2Y8lhmFcl224XK7rsq2iTDkoAzfloAzclIMycLNKDnb7pQsajy9ip02bxuLFiwkPD6dfv35069aNr7/+mvvuu4/Y2Fg+++wztm3bRuvWralYsSJdu3bl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chXENEqP99hjj2EYBtOmTSvsrpCTk0N0dDShdw3FZXdc+gWXaeH49td8nQXBMAzzZLniPDVDOSgDN+WgDNyUgzJw86QcrN3762jVqlXs2rULwzBYunQpM2fO5MEHHyzsbomIiIgUS7o6QT79+eefPPTQQxw6dIhKlSrxyiuvcO+99xZ2t0RERESKJRWx+dSzZ0969uxZ2N0QEREREVTEepQZ8a3x9/cv7G6IiIiIFDjNiRURERERy1ERKyIiIiKWoyJWRERERCxHRayIiIiIWI5O7PIg3UctKdY/diAiIiLFh0ZiRURERMRyVMSKiIiIiOWoiBURERERy1ERKyIiIiKWoyJWRERERCxHRWwBWbFiBQEBAeZymzZtmDx5cuF1SERERMSD6BJb18nixYvz1S46OpoOHTowaNCggu2QiIiIiIVpJFZERERELKdYjMRGRkbSv39/5s6dS0pKCvXr12fGjBlUrlyZ1NRUnnjiCdavX09gYCADBw5k0KBBnDx5ktDQUObMmUOzZs3MddWqVYuRI0fy0EMP8ddff/H000+zfPlyADp16sS4ceNwOM79wYGzR1gPHjxI7969WblyJYZhUK1aNebOnctbb73FqlWrWLt2Lc8//zx33XXXeUdwc3Nzyc3NNZedTicAdjsF8rHEMIxrv9ICYBgGLpfLMv0tKMpBGbgpB2XgphyUgZtVcrDbL13QFIsiFmD69OksWLCA0NBQ7r//fl544QWmTZvGfffdR2xsLJ999hnbtm2jdevWVKxYka5du9K5c2c+/PBDs4j98ccf2b17N+3bt8flchEbG0vTpk1JTU3l2LFjPPDAA4wZM4aXXnrpon15/fXXOXXqFBkZGTgcDn799Vf8/PwYP348GzZsuOR0goSEBOLj481lu91OVFQUDar54OVd6prkdbbMzMxrvs6CYBgGTqcTl8uVr4PfUykHZeCmHJSBm3JQBm5WySE4OPiSbYpNETtgwACqVq0KQLdu3Rg7dizff/89e/fuZcyYMZQsWZI6deowYMAAkpKS6Nq1Kw8//DD33HMPEydOpFSpUnz44Yc88MADlC5dmvXr1/PHH3+wZs0a7HY7ZcqUIS4ujr59+16yiPX29ubvv//mjz/+oG7dutSrV++y9mX48OEMHjzYXHY6ncTExLAhzQn2U5edzaU8FxR0zddZEAzDwGazUaFChSL9xixoykEZuCkHZeCmHJSBmyflUGyK2JCQEPO+j48P2dnZZGRkEBoaSsmSJc3nqlatyowZMwC4/fbbCQkJYcGCBdx///18/PHHzJ49G4D09HQOHz5MuXLlzNe6XC5Onz59yb4MGTKE48eP06lTJ44cOULnzp0ZO3YspUuXzte+OByOPFMW3AehYYArX2u4PFY6yG02G3a73VJ9LgjKQRm4KQdl4KYclIGbp+Rg7d5fpbCwMPbs2cPJkyfNx7Zv305YWJi53KNHDz788EOWLFlC6dKlzakFlStXpmLFihw+fNi8HTlyhJycnEtu19fXl3HjxrF161bWrl3L0qVLzctvWf2AEhEREbkeinXFdPvttxMcHMzIkSPJzc1l8+bNTJo0iZ49e5ptevTowVdffcWbb75J9+7dsdlsADRs2JDw8HCef/55srOzcblc7NixI1+X0lq0aBHbtm3DMAz8/f3x9vamRIkzg+LBwcGkpaUVzA6LiIiIeIhiXcR6e3uzaNEiNmzYQEhICLGxsQwePJiuXbuabcLDw2nSpAnLli2jR48e5uNeXl4sXLiQ3bt3U7NmTcqWLUu7du1ITU295HZTU1Np3bo1fn5+1KpVi8aNG9OvXz8ABg0axDfffENAQAD33Xfftd9pEREREQ9gc7lcBTGNUq6jnJwcoqOjCb1rKC77uZf3uloLx7e/5ussCIZhkJmZSVBQULGelqEclIGbclAGbspBGbh5Ug7W7r2IiIiIFEsqYkVERETEclTEioiIiIjlFJvrxBYHM+Jb4+/vX9jdEBERESlwGokVEREREctRESsiIiIilqMiVkREREQsR0WsiIiIiFiOTuzyIN1HLbkmP3ZglR83EBERkeJLI7EiIiIiYjkqYkVERETEclTEioiIiIjlqIgVEREREctRESsiIiIilqMiVkREREQsR0VsAUpPT8dms3H48OHC7oqIiIiIR1ERW8SdOnWqsLsgIiIiUuToxw7y6Y033mDKlCns27ePihUr8vTTTzNgwAAA/vjjD5555hnWrl3L6dOniY6OZu7cudx+++0AhIWFATB16lS6devGV199xbBhw/jzzz+pVq0ar776Kq1atQKgV69eeHl5kZ2dzZIlS3j55ZcZOHBgnr7k5uaSm5trLjudTgDsdq7JxxLDMK5+JYXAMAxcLpdl+3+tKAdl4KYclIGb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pfNMRRARERGRy2Nz6Tvta2LNmjVER0dz/Pjx6z7HJCcnh+joaELvGorLfm4RfTkWjm9/jXp1/RmGQWZmJkFBQZaf53M1lIMycFMOysBNOSgDN0/Kwdq9L0R//fUXy5cv5/Tp0+zZs4e4uDg6duxo+QNCRERExApUcV2h06dP8/TTT1O2bFnq1q3LDTfcwMSJEwu7WyIiIiLFgiUvsVUU3HDDDWzatKmwuyEiIiJSLKmI9SAz4lvj7+9f2N0QERERKXCaTiAiIiIilqMiVkREREQsR0WsiIiIiFiOilgRERERsRyd2OVBuo9aUqx/7EBERESKD43EioiIiIjlqIgVEREREctRESsiIiIilqMiVkREREQsR0WsiIiIiFhOsS9iIyMjmT9/fmF3Q0REREQuQ7EvYkVERETEelTEFmGnTp0q7C6IiIiIFEn6sYP/s3PnTnr37s2mTZs4deoUTZo04e233yYyMpJ9+/YRHh7OwYMH8fX1ZeLEiTz55JP89ttv1KhRg4ULFzJixAg2bNhAWFgYs2fP5u677zbXXaNGDV588UU6derEX3/9xdNPP83y5csB6NSpE+PGjcPhcLBixQo6dOhAQkICCQkJBAcHs379+nP6mpubS25urrnsdDoBsNu56o8lhmFc3QoKkWEYuFwuS+/DtaAclIGbclAGbspBGbhZJQe7/dIFjYrY/2MYBoMHD6Z58+acOHGC3r1706dPH77++mtCQkKoXr06q1atok2bNixbtoxq1aqxfPlyatSowbJly2jevDne3t706NGD999/3yxi165dy19//UX79u1xuVzExsbStGlTUlNTOXbsGA888ABjxozhpZdeAiA7O5uff/6Z33///YJ9TUhIID4+3ly22+1ERUXRoJoPXt6lriqHzMzMq3p9YTIMA6fTicvlytfB76mUgzJwUw7KwE05KAM3q+QQHBx8yTY2l8vlug59KbIiIyNJTEykQ4cOeR7ftGkTjRo14tixY9jtdp544gl8fHwYO3YsISEhjBs3jsWLFzN79mzq1q3Liy++SPv27fntt9+4/fbb2bt3L76+vjz++ON4e3szadIk1q9fT+vWrcnMzDQPnK+//pq+ffuSlpbGihUraN68OYcOHSIgIOCCfT7fSGxMTAxhdw+Fq/zZ2bnjYq7q9YXJMAwOHDhAhQoVivQbs6ApB2XgphyUgZtyUAZuVslBI7GXITMzk6eeeopVq1Zx5MgRAE6cOEF2djZly5alefPmjBs3jo0bN1KlShXat2/P8OHDyczMZMuWLebIa82aNalduzaffvopDz30ELNnz+abb74BID09ncOHD1OuXDlzuy6Xi9OnT5vLfn5+Fy1gARwOBw7H/4pV9x/aMOBqP5EU5QM6P2w2G3a73fL7cbWUgzJwUw7KwE05KAM3T8lBRez/GT58OEePHuWnn34iKCiITZs2ERUVhXugOjo6mi5dujBv3jxatGhBuXLlCA0NZdKkSdStWzdP4dm7d2+SkpJwOByEh4fToEEDACpXrkzFihXZu3fvBfth9QNKRERE5HpQxfR/srKyKFOmDAEBAfz999955pwCVKhQgZo1azJx4kSaN28OQIsWLUhMTKRFixZ52nbu3JmffvqJsWPH8sgjj5iPN2zYkPDwcJ5//nmys7NxuVzs2LGDxYsXF/wOioiIiHgQFbH/Jz4+ntTUVAIDA2natClt2rQ5p03z5s05duwYd955JwAtW7YkKyvrnCLWz8+PBx54gN9++41u3bqZj3t5ebFw4UJ2795NzZo1KVu2LO3atSM1NbVgd05ERETEwxT76QTp6enm/R9++CHPc4899lie5QkTJjBhwgRzuU2bNlzovLjIyEjuu+8+goKC8jxesWJF3n///fO+Jjo6msOHD19G70VERESKp2JfxBaEzMxM3n333QsWqyIiIiJydTSd4Bp7+eWXiYyMpF27drRq1aqwuyMiIiLikVTEXmMjRozA6XTyzjvvFHZXRERERDyWphN4kBnxrfH39y/sboiIiIgUOI3EioiIiIjlqIgVEREREctRESsiIiIilqMiVkREREQsRyd2eZDuo5bgsjsu6zULx7cvoN6IiIiIFByNxIqIiIiI5aiIFRERERHLURErIiIiIpajIlZERERELEdFrIiIiIhYjscWsTabjU2bNl3Tda5atYqwsLB8tW3Tpg2TJ0++ptsXERERkTN0ia3LcNddd5GRkZGvtosXLy7g3oiIiIgUXx47EisiIiIinsvSI7GRkZH06dOHOXPmkJqaSuPGjXn//fcJDQ0FYN26dXTv3p2dO3cSHR3Nhx9+SNmyZfn3v/9NvXr1GDVqlLmuxx9/HC8vLyZPnkxycjKjR49m3759+Pv707dvX1544QVWrFhBhw4d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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "def primary_occupation(s):\n", + " if pd.isna(s):\n", + " return pd.NA\n", + " t = str(s).strip()\n", + " if not t or t.lower() == \"nan\":\n", + " return pd.NA\n", + " return t.split(\"|\")[0].strip()\n", + "\n", + "\n", + "_ppa_plot_style()\n", + "prim = _poet_ppa[\"occupation_wd\"].map(primary_occupation)\n", + "prim = prim.astype(\"string\").fillna(\"(missing)\")\n", + "prim = prim.astype(str).str.strip()\n", + "prim = prim.replace({\"\": \"(missing)\", \"\": \"(missing)\"})\n", + "tab = prim.value_counts().head(20)\n", + "fig, ax = plt.subplots(figsize=(7, max(3.5, 0.32 * len(tab))))\n", + "y = list(range(len(tab)))\n", + "ax.barh(y, tab.values, color=\"#4c72b0\", edgecolor=\"none\")\n", + "ax.set_yticks(y)\n", + "ax.set_yticklabels(list(tab.index), fontsize=9)\n", + "ax.invert_yaxis()\n", + "ax.set_xlabel(\"Unique authors\")\n", + "ax.set_title(\"PPA-focused authors: first Wikidata occupation\")\n", + "fig.tight_layout()\n", + "plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "id": "651600d1", + "metadata": {}, + "source": [ + "----" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "corppa", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.10" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/notebooks/ppa-percent-poetry.py b/notebooks/ppa-percent-poetry.py new file mode 100644 index 00000000..e148081a --- /dev/null +++ b/notebooks/ppa-percent-poetry.py @@ -0,0 +1,566 @@ +import marimo + +__generated_with = "0.20.4" +app = marimo.App(width="medium") + + +@app.cell(hide_code=True) +def _(mo): + mo.md(r""" + # What percent of PPA is poetry? + + According to the poetry we have detected,\* what percentage of PPA is poetry, and how does that change over time? + + \* We know we have not detected all of the poetry; data may include some false positives, but this is likely undercounting to some extent. + """) + return + + +@app.cell +def _(): + import pathlib + + import altair as alt + import marimo as mo + import polars as pl + + from corppa.config import get_config + from corppa.poetry_detection.polars_utils import load_excerpts_df + from corppa.poetry_detection.ppa_works import ( + extract_page_meta, + load_ppa_works_df, + ) + + return ( + alt, + extract_page_meta, + get_config, + load_excerpts_df, + load_ppa_works_df, + mo, + pathlib, + pl, + ) + + +@app.cell +def _(get_config, load_ppa_works_df, pathlib, pl): + config_opts = get_config() + data_dir = pathlib.Path(config_opts["compiled_dataset"]["data_dir"]) + + # Create a dictionary of data files for lookup based on file base name without any extension + # so that excerpts data can be .csv or compressed .csv.gz + data_paths = { + data_file.stem.split(".", 1)[0]: data_file for data_file in data_dir.iterdir() + } + + ppa_meta_df = load_ppa_works_df(data_paths["ppa_work_metadata"]).with_columns( + # add a boolean field for has poetry + has_poetry=pl.col("ppa_num_excerpts").ne(0), + # round years to decade + ppa_pub_decade=pl.col("ppa_pub_year").floordiv(10).mul(10), + ) + return data_paths, ppa_meta_df + + +@app.cell(hide_code=True) +def _(mo): + mo.md(r""" + ## Works: what proportion of PPA works have poetry detected? + + Broad overview: this is just looking at works with any excerpts detected and those zero excerpts. + + --- + Use the toggle to control whether the bar charts are normalized or not (show as raw counts or as the percent for that date). + + Mouse over to get counts; zoom in to see more detailed dates; double click to reset zoom. + """) + return + + +@app.cell +def _(mo, pl, ppa_meta_df): + # output some numbers + total_works = ppa_meta_df.height + total_with_poetry = ppa_meta_df.filter(pl.col.has_poetry).height + # check numbers for single poem / single poet / single excerpt + with_1excerpt = ppa_meta_df.filter(pl.col.ppa_num_excerpts.eq(1)).height + single_poem_works = ppa_meta_df.filter( + pl.col.ppa_num_excerpts.gt(10), pl.col.ppa_num_poems.eq(1) + ).height + single_poet_works = ppa_meta_df.filter( + pl.col.ppa_num_excerpts.gt(10), pl.col.ppa_num_poets.eq(1) + ).height + + max_num_excerpts = ppa_meta_df["ppa_num_excerpts"].max() + max_num_poems = ppa_meta_df["ppa_num_poems"].max() + max_num_poets = ppa_meta_df["ppa_num_poets"].max() + + mo.md(f"""Of {total_works:,} total PPA works: + + - {total_with_poetry:,} have poetry detected ({total_with_poetry/total_works * 100:.1f}%) + - {with_1excerpt:,} have just one excerpt detected ({with_1excerpt/total_works * 100:.1f}%) + - {single_poem_works:,} have at least 10 excerpts, all from only one poem ({single_poem_works/total_works * 100:.1f}%) + - {single_poet_works:,} have at least 10 excerpts, all from only one poet ({single_poet_works/total_works * 100:.1f}%) + + + Maximum numbers for a single PPA work * (likely includes duplicates) + + - {max_num_excerpts:,} excerpts + - {max_num_poems:,} poems + - {max_num_poets:,} poets + + """) + return + + +@app.cell +def _(alt, mo, pl, ppa_meta_df): + ppa_works_year_df = ppa_meta_df.group_by("ppa_pub_year", "has_poetry").agg( + count=pl.len() + ) + + def chart_has_poetry( + df, + field, + field_title, + y_axis_title, + normalize=True, + mark_opts=None, + ): + if mark_opts is None: + mark_opts = {} + return ( + alt.Chart(df) + .mark_bar(**mark_opts) + .encode( + x=alt.X(field, title=field_title).axis( + format="r" + ), # no commas in years + y=alt.Y("count", title=y_axis_title).stack( + "normalize" if normalize else "zero" + ), + color=alt.Color("has_poetry", title="Has poetry"), + tooltip=["count", "has_poetry"], + ) + .properties( + height=150, + ) + ) + + works_chart_normalized = chart_has_poetry( + ppa_works_year_df, "ppa_pub_year", "Publication year", "Works" + ) + works_chart_count = chart_has_poetry( + ppa_works_year_df, + "ppa_pub_year", + "Publication year", + "Works", + normalize=False, + ) + + mo.ui.altair_chart( + (works_chart_normalized & works_chart_count).properties( + title="PPA works with detected poetry, by year" + ) + ) + return (chart_has_poetry,) + + +@app.cell +def _(chart_has_poetry, mo, pl, ppa_meta_df): + ppa_works_decade_df = ppa_meta_df.group_by("ppa_pub_decade", "has_poetry").agg( + count=pl.len(), + ) + + decade_works_chart_normalized = chart_has_poetry( + ppa_works_decade_df, + "ppa_pub_decade", + field_title=None, + y_axis_title="Works", + mark_opts={"width": 18}, + ) + decade_works_chart_count = chart_has_poetry( + ppa_works_decade_df, + "ppa_pub_decade", + "Publication decade", + y_axis_title="Works", + normalize=False, + mark_opts={"width": 18}, + ) + + mo.ui.altair_chart( + (decade_works_chart_normalized & decade_works_chart_count).properties( + title="PPA works with detected poetry, by decade" + ) + ) + return + + +@app.cell(hide_code=True) +def _(mo): + mo.md(r""" + ## Pages: what proportion of PPA pages have poetry detected? + + If we look at the page level, how many pages in PPA have any poetry detected? + """) + return + + +@app.cell +def _(data_paths, extract_page_meta, load_excerpts_df, pl, ppa_meta_df): + # load the excerpts into a polars dataframe and extract ppa work/page ids + excerpts_df = extract_page_meta( + load_excerpts_df( + data_paths["excerpts"] # , ppa_works_meta= + ).with_columns( + # calculate span length for later + ppa_span_len=pl.col.ppa_span_end - pl.col.ppa_span_start + ) + ) + + # aggregate by work id and count pages to determine number of pages with excerpts + work_excerpt_pages_df = ( + excerpts_df.group_by("ppa_work_id") + .agg(poetry_pages=pl.n_unique("page_id")) + .join( + # join with ppa metadata to get total page count + ppa_meta_df.select( + "ppa_work_id", "ppa_page_count", "ppa_pub_year", "ppa_pub_decade" + ), + on="ppa_work_id", + ) + .with_columns(nonpoetry_pages=pl.col.ppa_page_count.sub(pl.col.poetry_pages)) + ) + return excerpts_df, work_excerpt_pages_df + + +@app.cell +def _(excerpts_df, mo, pl, ppa_meta_df): + # output some numbers for page + excerpt_pages_df = excerpts_df.group_by("page_id").agg( + num_excerpts=pl.len(), num_poems=pl.n_unique("poem_id") + ) + + total_pages = ppa_meta_df["ppa_page_count"].sum() + num_pages_with_poetry = excerpt_pages_df.height + # check numbers for single poem / single poet / single excerpt + pages_with_1excerpt = excerpt_pages_df.filter(pl.col.num_excerpts.eq(1)).height + single_poem_pages = excerpt_pages_df.filter(pl.col.num_poems.eq(1)).height + # we don't have poet count because we haven't joined poem metadata yet + # single_poet_pages = excerpt_pages_df.filter(pl.col.ppa_num_poets.eq(1)).height + + page_max_num_excerpts = excerpt_pages_df["num_excerpts"].max() + page_max_num_poems = excerpt_pages_df["num_poems"].max() + # max_num_poets = excerpt_pages_df["ppa_num_poets"].max() + + mo.md(f"""Of {total_pages:,} total pages in PPA: + + - {num_pages_with_poetry:,} pages have poetry detected ({num_pages_with_poetry/total_pages * 100:.1f}%) + - {pages_with_1excerpt:,} pages have just one excerpt detected ({pages_with_1excerpt/total_pages * 100:.1f}%) + - {single_poem_pages:,} pages have excerpts from a single poem ({single_poem_pages/total_pages * 100:.1f}%) + + Maximum numbers for a single PPA page * (likely includes duplicates) + + - {page_max_num_excerpts:,} excerpts + - {page_max_num_poems:,} poems + """) + return (total_pages,) + + +@app.cell +def _(chart_has_poetry, mo, pl, work_excerpt_pages_df): + # aggregate before graphing with altair + ppa_pages_year_df = ( + work_excerpt_pages_df.group_by("ppa_pub_year") + .agg( + # sum all the poetry and non-poetry pages + pl.sum("poetry_pages", "nonpoetry_pages"), + ) + # unpivot so we can stack and color the two different sets + .unpivot(index="ppa_pub_year", value_name="num_pages") + # add a boolean for more readability in the graph + .with_columns(has_poetry=pl.col.variable.eq("poetry_pages")) + .rename({"num_pages": "count"}) + ) + + pages_chart_normalized = chart_has_poetry( + ppa_pages_year_df, + "ppa_pub_year", + field_title=None, + y_axis_title="Pages", + ) + pages_chart_count = chart_has_poetry( + ppa_pages_year_df, + "ppa_pub_year", + "Publication year", + normalize=False, + y_axis_title="Pages", + ) + + mo.ui.altair_chart( + (pages_chart_normalized & pages_chart_count).properties( + title="PPA pages with detected poetry, by work publication year" + ) + ) + return + + +@app.cell +def _(chart_has_poetry, mo, pl, work_excerpt_pages_df): + # same as above, but for decade instead of year + ppa_pages_decade_df = ( + work_excerpt_pages_df.group_by("ppa_pub_decade") + .agg( + # sum all the poetry and non-poetry pages + pl.sum("poetry_pages", "nonpoetry_pages"), + ) + # unpivot so we can stack and color the two different sets + .unpivot(index="ppa_pub_decade", value_name="num_pages") + # add a boolean for more readability in the graph + .with_columns(has_poetry=pl.col.variable.eq("poetry_pages")) + .rename({"num_pages": "count"}) + ) + + pages_decade_chart_normalized = chart_has_poetry( + ppa_pages_decade_df, + "ppa_pub_decade", + field_title=None, + y_axis_title="Pages", + mark_opts={"width": 18}, + ) + pages_decade_chart_count = chart_has_poetry( + ppa_pages_decade_df, + "ppa_pub_decade", + "Publication year", + y_axis_title="Pages", + normalize=False, + mark_opts={"width": 18}, + ) + + mo.ui.altair_chart( + (pages_decade_chart_normalized & pages_decade_chart_count).properties( + title="PPA pages with detected poetry, by work publication decade" + ) + ) + return + + +@app.cell(hide_code=True) +def _(mo): + mo.md(r""" + ## Text: what proportion of PPA text has been detected as poetry? + + If we look at page text at the character level, what portion of the text is been included in any of the detected poetry excerpt spans? + """) + return + + +@app.cell +def _(pl): + # load the PPA page data that was used to generate this found poems dataset + ppa_pages_df = ( + pl.scan_ndjson( + # TODO: use a config path? + "../ppa-django/fulltext-production/ppa_corpus_2026-01-07_091133/ppa_pages.jsonl.gz" + ) + # calculate the length of the text + .with_columns(text_len=pl.col("text").str.len_chars()) + .rename({"id": "page_id"}) # rename for joining with excerpt data later + .select("page_id", "work_id", "text_len") # limit to the fields we need + .collect() + ) + return (ppa_pages_df,) + + +@app.cell +def _(excerpts_df): + excerpts_df + return + + +@app.cell +def _(excerpts_df, pl): + # collapse excerpts with any overlap to a single span so we can calculate the total number of characters + # covered by any of the merged spans + + merged_excerpts_df = ( + # sort by page and span start + excerpts_df.sort("page_id", "ppa_span_start") + .select("page_id", "ppa_span_start", "ppa_span_end", "detection_methods") + .with_columns( + # Use shift and cumulative max to determine if current span + # has any overlap with previous spans or is the beginning of a new group. + # shift(1) gets previous row; fill null for first row (which has no previous row), + # and calculate current max span end for this page. + # NOTE: we use >= because span end is exclusive (i.e., is not included in the range) + new_group=( + pl.col("ppa_span_start") + >= pl.col("ppa_span_end").shift(1).fill_null(-1).cum_max() + ) + .cast(pl.Int32) # cast to int gives 1 or 0 to indicate new group + .over("page_id") # limit to spans on a single page + ) + .with_columns( + # because new_group is 1 or 0, cumulative sum gives each group on a page a unique group id + pl.col("new_group").cum_sum().alias("group_id").over("page_id") + ) + .group_by("page_id", "group_id") + .agg( + # group by page id and group id and get the smallest start and largest end + # to get the outer bounds of the overlapping spans + pl.col("ppa_span_start").min(), + pl.col("ppa_span_end").max(), + ) + .with_columns(ppa_span_len=pl.col.ppa_span_end - pl.col.ppa_span_start) + ) + + excerpt_page_chars_df = merged_excerpts_df.group_by("page_id").agg( + # calculate the number of characters covered by all merged spans for each page + poetry_chars=(pl.col("ppa_span_end") - pl.col("ppa_span_start")).sum() + ) + return excerpt_page_chars_df, merged_excerpts_df + + +@app.cell +def _(excerpt_page_chars_df, pl, ppa_meta_df, ppa_pages_df): + # join merged span char length data with page data to determine poetry/nonpoetry chars + text_poetrylen_df = ( + # We are starting with pages, so left join will include all pages, whether or not they have excerpts + ppa_pages_df.join(excerpt_page_chars_df, on="page_id", how="left") + .with_columns(pl.col("poetry_chars").fill_null(0)) + .with_columns(nonpoetry_chars=pl.col("text_len").sub(pl.col("poetry_chars"))) + .join( + ppa_meta_df.select("ppa_work_id", "ppa_pub_year", "ppa_pub_decade"), + left_on="work_id", + right_on="ppa_work_id", + ) + ) + return (text_poetrylen_df,) + + +@app.cell +def _( + excerpts_df, + merged_excerpts_df, + mo, + ppa_pages_df, + text_poetrylen_df, + total_pages, +): + # output some numbers for text characters + + total_characters = ppa_pages_df["text_len"].sum() + poetry_characters = text_poetrylen_df["poetry_chars"].sum() + + # longest / shortest excerpt + longest_excerpt = excerpts_df["ppa_span_len"].max() + shortest_excerpt = excerpts_df["ppa_span_len"].min() + average_excerpt_len = excerpts_df["ppa_span_len"].mean() + + # same for merged excerpts + longest_merged_excerpt = merged_excerpts_df["ppa_span_len"].max() + shortest_merged_excerpt = merged_excerpts_df["ppa_span_len"].min() + average_merged_excerpt_len = merged_excerpts_df["ppa_span_len"].mean() + + mo.md(f"""Across all {total_pages:,} PPA pages there are a total of {total_characters:,} characters of text. + + - {poetry_characters:,} characters detected as poetry ({poetry_characters/total_characters * 100:.1f}%) + + Excerpt length in characters (unmerged, {excerpts_df.height:,} total excerpts): + - Longest: {longest_excerpt:,} + - Shortest: {shortest_excerpt:,} + - Average: {average_excerpt_len:.1f} + + Excerpt length in characters (merged all overlapping spans, {merged_excerpts_df.height:,} total excerpts): + - Longest: {longest_merged_excerpt:,} + - Shortest: {shortest_merged_excerpt:,} + - Average: {average_merged_excerpt_len:.1f} + + + """) + return + + +@app.cell +def _(chart_has_poetry, mo, pl, text_poetrylen_df): + # aggregate before graphing with altair + text_poetrylen_year_df = ( + text_poetrylen_df.group_by("ppa_pub_year") + .agg( + # sum all the poetry and non-poetry characters + pl.sum("poetry_chars", "nonpoetry_chars"), + ) + # unpivot so we can stack and color the two different sets + .unpivot(index="ppa_pub_year", value_name="text_len") + # # add a boolean for more readability in the graph + .with_columns(has_poetry=pl.col.variable.eq("poetry_chars")) + .rename({"text_len": "count"}) + ) + + text_chart_normalized = chart_has_poetry( + text_poetrylen_year_df, + "ppa_pub_year", + field_title=None, + y_axis_title="Characters", + # mark_opts={"width": 18}, + ) + text_chart_count = chart_has_poetry( + text_poetrylen_year_df, + "ppa_pub_year", + "Publication year", + y_axis_title="Characters", + normalize=False, + # mark_opts={"width": 18}, + ) + + mo.ui.altair_chart( + (text_chart_normalized & text_chart_count).properties( + title="PPA text detected as poetry, by work publication year" + ) + ) + return + + +@app.cell +def _(chart_has_poetry, mo, pl, text_poetrylen_df): + # aggregate before graphing with altair + text_poetrylen_decade_df = ( + text_poetrylen_df.group_by("ppa_pub_decade") + .agg( + # sum all the poetry and non-poetry characters + pl.sum("poetry_chars", "nonpoetry_chars"), + ) + # unpivot so we can stack and color the two different sets + .unpivot(index="ppa_pub_decade", value_name="text_len") + # # add a boolean for more readability in the graph + .with_columns(has_poetry=pl.col.variable.eq("poetry_chars")) + .rename({"text_len": "count"}) + ) + + text_decade_chart_normalized = chart_has_poetry( + text_poetrylen_decade_df, + "ppa_pub_decade", + field_title=None, + y_axis_title="Characters", + mark_opts={"width": 18}, + ) + text_decade_chart_count = chart_has_poetry( + text_poetrylen_decade_df, + "ppa_pub_decade", + "Publication decade", + y_axis_title="Characters", + normalize=False, + mark_opts={"width": 18}, + ) + + mo.ui.altair_chart( + (text_decade_chart_normalized & text_decade_chart_count).properties( + title="PPA text detected as poetry, by work publication decade" + ) + ) + return + + +if __name__ == "__main__": + app.run() diff --git a/notebooks/sample_data/test_config.yml b/notebooks/sample_data/test_config.yml index 0c387203..0bd9b7f0 100644 --- a/notebooks/sample_data/test_config.yml +++ b/notebooks/sample_data/test_config.yml @@ -2,9 +2,10 @@ # SPDX-License-Identifier: Apache-2.0 # corppa configuration for automated tests / continuous integration +base_dir: . # local path to compiled poem dataset files -poem_dataset: - # Use relative path for CI, since the path depends on the build. - # For notebooks, relative path starts from the notebook directory. - data_dir: "sample_data/" +# Use relative path for CI, since the path depends on the build. +# For notebooks, relative path starts from the notebook directory. +compiled_dataset_dir: sample_data + diff --git a/notebooks/view-excerpts.py b/notebooks/view-excerpts.py new file mode 100644 index 00000000..8c059af1 --- /dev/null +++ b/notebooks/view-excerpts.py @@ -0,0 +1,288 @@ +# /// script +# requires-python = ">=3.13" +# dependencies = [ +# "corppa", +# "marimo>=0.20.2", +# "polars==1.38.1", +# ] +# +# [tool.uv.sources] +# corppa = { git = "https://github.com/Princeton-CDH/corppa.git", rev = "develop" } +# /// + +import marimo + +__generated_with = "0.20.4" +app = marimo.App(width="medium", auto_download=["html"]) + + +@app.cell +def _(mo): + mo.md(r""" + # PPA found poems: manual annotation & passim + + [![Open in molab](https://molab.marimo.io/molab-shield.svg)](https://molab.marimo.io/notebooks/nb_m9upHymqmyGsJXuLm59gFw) + + This notebook provides a page-level view of pages with manual annotation, showing adjudicated spans and passim-detected excerpts together in the context of the page of text in a PPA work. + + Pages are ordered by number of annotation, most annotated first. Data can either be loaded from a precompiled parquet file of selected excerpts and PPA page content (used on molab) or from excerpt dataset and a PPA page subset. + + Use the slider to move through pages. A table of the excerpts is displayed after the page text. + """) + return + + +@app.cell(hide_code=True) +def _(mo): + mo.Html(""" +

Legend

+
+
Content could have manual annotation, passim annotation, or some overlap of both.
+
Content could have manual annotation, passim annotation, or some overlap of both.
+
Content could have manual annotation, passim annotation, or some overlap of both.
+
""") + return + + +@app.cell(hide_code=True) +def _(annotated_pages_df, mo): + page_slider = mo.ui.slider( + start=0, + stop=annotated_pages_df.height - 1, + step=1, + label="annotated page", + include_input=True, + ) + + page_slider + return (page_slider,) + + +@app.cell(hide_code=True) +def _(annotated_pages_df, excerpts_annopages_df, mo, page_slider, pl): + # page through annotated pages in order of # annotations + + manually_annotated_pages_df = excerpts_annopages_df.group_by("page_id").agg( + pl.col("ppa_work_id").first().alias("work_id"), + pl.col("page_num").first(), + pl.len().alias("count"), + ) + + page = manually_annotated_pages_df.sort(pl.col("count"), descending=True).row( + page_slider.value, named=True + ) + + selected_page = annotated_pages_df.filter(pl.col("id").eq(page["page_id"])).row( + 0, named=True + ) + + # we want ALL annotations for this page, not just manual + page_annotations = ( + excerpts_annopages_df.filter(pl.col("page_id").eq(selected_page["id"])) + .with_columns(system=pl.col("detection_methods").list.join(",")) + .sort("ppa_span_start", "ppa_span_end") + ) + + # get a list of tuples for start and end of each span to highlight + adj_spans = ( + page_annotations.filter(pl.col("system").eq("adjudication")) + .select("ppa_span_start", "ppa_span_end", "system") + .rows() + ) + + passim_spans = ( + page_annotations.filter(pl.col("system").eq("passim")) + .select("ppa_span_start", "ppa_span_end", "system") + .rows() + ) + + mo.vstack( + [ + # preserve whitespace + mo.Html( + f"""
+
{selected_page["work_id"]} page {selected_page["label"]} ({len(adj_spans)} adjudication spans, {len(passim_spans)} passim spans)
+
+
{highlight_spans(selected_page["text"], adj_spans)}
+
{highlight_spans(selected_page["text"], passim_spans)}
+
{selected_page["text"]}
+
+
""" + ), + mo.ui.table(page_annotations), + ] + ) + return + + +@app.cell(hide_code=True) +def _(): + import pathlib + + import marimo as mo + import polars as pl + + return mo, pathlib, pl + + +@app.cell(hide_code=True) +def _(mo): + # apply stylesheet to customize highlighting + # NOTE: we apply styles here because molab doesn't support setting app-level custom css + css_content = (mo.notebook_dir() / "highlight.css").read_text() + + mo.Html(f""" + + """) + return + + +@app.function(hide_code=True) +def highlight_spans(text: str, spans: list[tuple[int]]) -> str: + # method to add highlighting for one or more spans within a text string + # takes text and string with one or more spans in a format that can be parsed by intspan + # returns the text with tags around the highlighted regions + previous_end = 0 + text_parts = [] + for i, (start, end, label) in enumerate(spans): + if previous_end >= end: + continue + # text up to the next the mark (if not overlapping / not already started) + if previous_end < start: + text_parts.append(text[previous_end:start]) + else: + # if previous end is greater than current span start, + # pick up where we left off + + start = previous_end + # text to be highlighted + # if this span ends before the next (no overlap), just output span + if i < len(spans) - 1: + next_span_start, next_span_end, next_label = spans[i + 1] + if end < next_span_start: + # no overlap - highlight span + text_parts.append(f"{text[start:end]}") + else: + # spans overlap + # highlight segment of current span before the next starts + text_parts.append( + f"{text[start:next_span_start]}" + ) + # does this one end before the next? + # highlight segment of current span overlapping with next span + if end < next_span_end: + text_parts.append( + f"{text[next_span_start:end]}" + ) + # set previous end to end of this span + previous_end = end + else: + # next span is entirely contained within this one + # output entirety of next span with both labels + text_parts.append( + f"{text[next_span_start:next_span_end]}" + ) + # output the rest of this span after the contained span + text_parts.append( + f"{text[next_span_end:end]}" + ) + previous_end = end + else: + # last segment + text_parts.append(f"{text[start:end]}") + # set previuos end to the portion after this span + previous_end = end + # append any text after the last highlighted portion + text_parts.append(text[previous_end:]) + return "".join(text_parts) + + +@app.cell +def _(pathlib, pl): + from corppa.config import get_config + from corppa.poetry_detection.polars_utils import load_excerpts_df + + excerpt_parquet_file = pathlib.Path("ppa_excerpts_annotatedpages.parquet") + annotated_pages_file = pathlib.Path("manually_annotated_pages.jsonl") + + def get_excerpt_data(): + config_opts = get_config() + data_dir = pathlib.Path(config_opts["compiled_dataset"]["data_dir"]) + if not data_dir.exists() or not data_dir.is_dir(): + raise ValueError( + f"Data directory {data_dir} not found. " + + "\nCheck your configuration file, and remember to use an absolute path for the poem dataset data directory." + ) + else: + print(f"Loading excerpt data from {data_dir}") + + # Create a dictionary of data files for lookup based on file base name without any extension + # so that excerpts data can be .csv or compressed .csv.gz + data_paths = { + data_file.stem.split(".", 1)[0]: data_file + for data_file in data_dir.iterdir() + } + + # load the excerpts into a polars dataframe + # we need ppa work id to generate file for creating filtered page subset + return load_excerpts_df( + data_paths["excerpts"], ppa_works_meta=data_paths["ppa_work_metadata"] + ) + + def get_excerpts_annotatedpages(): + # get data for excerpts on annotated pages + + # load precompiled data if present + if excerpt_parquet_file.exists(): + print(f"Loading precompiled data from {excerpt_parquet_file}") + return pl.read_parquet(excerpt_parquet_file) + else: + print( + f"Precompiled data file {excerpt_parquet_file} not found; loading and filtering excerpts." + ) + # load the excerpts into a polars dataframe, joining ppa and poetry data + excerpts_df = get_excerpt_data().filter( + # limit to adjudication excerpts + pl.col("detection_methods").list.contains("adjudication") + ) + # save for future runs + excerpts_df.write_parquet(excerpt_parquet_file) + return excerpts_df + + def get_annotatedpages(excerpts_annopages_df): + # load annotated page data + if annotated_pages_file.exists(): + print(f"Loading annotated page subset from {annotated_pages_file}") + return pl.read_ndjson(annotated_pages_file) + else: + # if file is not found, document how to create it + print( + f"\nPage data for manually annotated pages not found: {annotated_pages_file}" + ) + # generate a list of work & page ids to generate a page data subset + # load the excerpts into a polars dataframe, joining ppa + page_csvfilename = "manual_annotation_pages.csv" + excerpts_annopages_df.group_by("page_id").agg( + pl.col("ppa_work_id").first().alias("work_id"), + pl.col("page_num").first(), + # output in the format supported by corpppa-filter --pgfile + ).write_csv(page_csvfilename) + + print(f"""Use corppa-filter to generate the page subset file: + + corppa-filter --pgfile {page_csvfilename} path/to/ppa_corpus_2026-XX-XX/ppa_pages.jsonl.gz {annotated_pages_file} + """) + return None + + # load excerpts for annotated pages (previously or dynamically filtered) + excerpts_annopages_df = get_excerpts_annotatedpages() + + # load annotated pages + annotated_pages_df = get_annotatedpages(excerpts_annopages_df) + return annotated_pages_df, excerpts_annopages_df + + +if __name__ == "__main__": + app.run() diff --git a/pyproject.toml b/pyproject.toml index 2088c473..9841c140 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,14 +4,17 @@ build-backend = "hatchling.build" [project] name = "corppa" -description = "Utilities for working with Princeton Prosody Archive full-text corpus" -requires-python = ">=3.11" +description = "Software for working with Princeton Prosody Archive full-text corpus, including poetry detection" +authors = [ + {name = "Center for Digital Humanities at Princeton", email = "cdh-info@princeton.edu"}, +] +requires-python = ">=3.12" readme = "README.md" license = {text = "Apache-2"} classifiers = [ "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.13", "Development Status :: 3 - Alpha", "Intended Audience :: Science/Research", "Operating System :: OS Independent", @@ -25,7 +28,7 @@ dependencies = [ "intspan", "orjsonl", "tqdm", - "biopython", + "biopython>=1.86", "pyyaml", "polars", ] @@ -41,12 +44,11 @@ poem_id = [ "pyarrow", "unidecode", "rapidfuzz", - # Conditional - 'more-itertools; python_version < "3.12"', ] passim = ["passim @ git+https://github.com/dasmiq/passim.git"] chadwyck_healey = ["bs4", "lxml"] notebooks = ["jupyterlab", "itables", "treon", "polars"] +marimo = ["marimo", "marimo[lsp]"] dev = [ "pre-commit", "ruff", @@ -58,6 +60,7 @@ dev = [ "corppa[chadwyck_healey]", "corppa[doc]", "corppa[notebooks]", + "corppa[marimo]", ] test = [ "pytest", @@ -73,12 +76,13 @@ corppa-ocr = "corppa.ocr.gvision_ocr:main" collate-txt = "corppa.ocr.collate_txt:main" refmatcha = "corppa.poetry_detection.refmatcha:main" merge-excerpts = "corppa.poetry_detection.merge_excerpts:main" +corppa-compile-dataset = "corppa.poetry_detection.compile_dataset:main" [tool.hatch.version] path = "src/corppa/__init__.py" [tool.pytest.ini_options] -testpaths = ["test"] +testpaths = ["tests"] [tool.ruff] # configure src path so ruff import fixes can identify local imports @@ -94,3 +98,32 @@ exclude_lines = [ # skip command-line configuration for main method on scripts "if __name__ == .__main__.:" ] + +[tool.ruff.lint] +select = [ +# ruff defaults +"E4", +"E7", +"E9", +"F", +# isort +"I", +] +# Can use to ignore specific rules within above selection +ignore = [] + +[tool.marimo.display] +cell_output = "below" + +[tool.marimo.runtime] +# Add project source directory to Python path +pythonpath = ["src"] + +[tool.marimo.save] +format_on_save = true + +[tool.marimo.diagnostics] +enabled = true + +[tool.marimo.experimental] +lsp = true diff --git a/sample_config.yml b/sample_config.yml index 9d9895dc..7191e460 100644 --- a/sample_config.yml +++ b/sample_config.yml @@ -4,8 +4,50 @@ # corppa configuration # copy this file to corppa_config.yml and edit as needed +# to compile found poems v0.5 dataset, copy the following +# from tigerdata prosody project under poetry-detection/2026-05-foundpoems-v0.5/ +#. ref-corpora/ +# ppa-corpus_2026-01-07/ +#. excerpt-data/ +# Copy to a local directory, preserving relative paths. Set that local directory +# as your top-level base_dir below. + +# top-level path for excerpt dataset ingredients +base_dir: data/tigerdata-foundpoems-v0.5/ + # local path to compiled poem dataset files -poem_dataset: - # NOTE: currently requires an absolute path, since jupyter - # notebooks will try to load relative to the notebook - data_dir: "/path/to/ppa-found-poems/data/" +compiled_dataset_dir: /path/to/ppa-found-poems/data/ + +# source excerpt data; relative to base_dir unless absolute +excerpt_data_dir: excerpt-data + +# PPA full-text corpus and metadata; relative to base_dir unless absolute +ppa_corpus: + base_dir: ppa_corpus_20XX-XX-XX/ + +# poem cluster ids +# poem_clusters_path: https:// + +# paths to reference corpora metadata and text +reference_corpora: + # default base_dir is data_ingredients_dir / ref-corpora + # uncomment to override; assumed relative to base_dir if not absolute + # base_dir: ref-corpora/ + + # Paths for individual corpora are assumed relative to reference_corpora.base_dir + # unless absolute; uncomment to override. + # text_path can be a tar.gz directory of text files or expanded directory. + + internet_poems: + # base_dir: internet_poems + # text_path: internet_poems_texts.tar.gz + # metadata_path: internet-poems-metadata.csv + + chadwyck-healey: + # base_dir: chadwyck-healey + # text_path: "chadwyck-healey/chadwyck-healey_texts.tar.gz" + # metadata_path: "chadwyck-healey/chadwyck-healey.csv" + other: + # URL or local path to "Other Poems" metadata + # Use a URL if you want to pull directly from google sheets as CSV or + metadata_path: "https://docs.google.com/spreadsheets/d/..." # or "/path/to/other.csv" diff --git a/scripts/excerpts_to_grist.py b/scripts/excerpts_to_grist.py new file mode 100755 index 00000000..7b0e6854 --- /dev/null +++ b/scripts/excerpts_to_grist.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python +""" +This is an experimental script to test loading found poem +excerpt data into Grist database / spreadsheet tool (https://www.getgrist.com/). +It's preserved here for documentation purposes in case scripted +Grist import is useful for other projects. + +Requires installing Grist python API client: `pip install pygrister` + +Configure environment variables for Grist access: +- GRIST_API_KEY +- GRIST_DOC_ID +- GRIST_API_URL + +Uses data paths in corppa config and subset_excerpts.csv created by subset script. + +When experimenting, PPA metadata was imported via web upload, and then +references in excerpt data to PPA work and Poem id were converted to reference +fields manually. This only works with a smaller amount of data (limits unclear; +5000 is ok but the larger amounts tried were not. + +""" + +import os +import pathlib + +import polars as pl +import tqdm +from pygrister.api import GristApi + +from corppa.config import get_config +from corppa.poetry_detection.polars_utils import load_excerpts_df +from corppa.poetry_detection.ppa_works import extract_page_meta, load_ppa_works_df + +grist_api_key = os.environ.get("GRIST_API_KEY") +grist_doc_id = os.environ.get("GRIST_DOC_ID") +grist_api_url = os.environ.get("GRIST_API_URL") +if not grist_api_key or not grist_doc_id or not grist_api_url: + raise SystemExit( + "Must configure GRIST_API_KEY, GRIST_DOC_ID, and GRIST_API_URL as environment variables" + ) + +corppa_cfg = get_config() +data_dir = pathlib.Path(corppa_cfg["compiled_dataset"]["data_dir"]) + + +# load excerpt data +excerpts_df = load_excerpts_df("subset_excerpts.csv") +# extract ppa work id and page number from page id +excerpts_df = extract_page_meta(excerpts_df).with_columns( + # convert list fields back to delimited string + detection_methods=pl.col("detection_methods").list.join(";"), + identification_methods=pl.col("identification_methods").list.join(";"), +) + + +def pl_type_to_grist(pl_type) -> str: + # do a quick mapping from polars type to grist/python + match pl_type: + case int() | pl.Int64(): + return "Int" + case str() | pl.String(): + return "Text" + raise Exception(f"unsupported type: {pl_type}") + + +grist_columns = [] + +# subset fields to essentials to see if that will get us under grist limits +excerpts_df = excerpts_df.select( + "page_id", "ppa_span_text", "poem_id", "ref_span_text", "ppa_work_id", "page_num" +) + +ppa_meta_df = load_ppa_works_df(data_dir / "ppa_work_metadata.csv") + +# convert schema to python dict for simpler iteration +for field_name, field_type in excerpts_df.schema.to_python().items(): + grist_columns.append( + { + "id": field_name, + "fields": { + "label": field_name, + # use field name as key to get polars type + "type": pl_type_to_grist(excerpts_df.schema[field_name]), + }, + } + ) + + +grist = GristApi( + config={ + "GRIST_SELF_MANAGED": "Y", + "GRIST_SELF_MANAGED_HOME": grist_api_url, + "GRIST_API_KEY": grist_api_key, + "GRIST_WORKSPACE_ID": "7", # found via apiconsole, not sure how else to know + "GRIST_API_SERVER": grist_api_url, + "GRIST_SELF_MANAGED_SINGLE_ORG": "Y", + "GRIST_TEAM_SITE": "docs", + "GRIST_DOC_ID": grist_doc_id, + } +) + + +status_code, response = grist.list_tables() +table_ids = [record["id"] for record in response] +# create excerpt table if not present +if "Excerpts" not in table_ids: + print("Creating Excerpts table") + status_code, response = grist.add_tables( + [{"id": "Excerpts", "columns": grist_columns}] + ) + +# now add rows in batches +# default of 10k is too large; 1k also too large + +# get a subset for testing purposes. use existing order so it's replicable +# loading too much data crashes grist +excerpts_subset_df = excerpts_df.limit(5000) + +for chunk in tqdm.tqdm(excerpts_subset_df.iter_slices(n_rows=500)): + status_code, response = grist.add_records("Excerpts", records=chunk.to_dicts()) + +# only include poems referenced by excerpts +poems_df = pl.read_csv( + pathlib.Path(corppa_cfg["compiled_dataset"]["data_dir"]) / "poem_meta.csv" # .gz" +).filter(pl.col("poem_id").is_in(excerpts_df["poem_id"])) + +# create poem table if not present +if "Poems" not in table_ids: + poem_grist_columns = [] + # convert schema to python dict for simpler iteration + for field_name in poems_df.schema: + poem_grist_columns.append( + { + "id": field_name, + "fields": { + "label": field_name, + # use field name as key to get polars type + "type": pl_type_to_grist(poems_df.schema[field_name]), + }, + } + ) + + print("Creating Poems table") + status_code, response = grist.add_tables( + [{"id": "Poems", "columns": poem_grist_columns}] + ) + +for chunk in tqdm.tqdm(poems_df.iter_slices(n_rows=500)): + status_code, response = grist.add_records("Poems", records=chunk.to_dicts()) diff --git a/scripts/subset_excerpts.py b/scripts/subset_excerpts.py new file mode 100755 index 00000000..b2d83b25 --- /dev/null +++ b/scripts/subset_excerpts.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python +""" +This is a one-time / convenience script to subset the excerpt data +with the goal of limiting to a smaller set of the data to make it easier +to explore and see what we have. + +Excerpts are filtered as follows: + +- Analyze excerpt totals in PPA clusters to identify "low-variance" clusters, + where number of excerpts does not change significantly among the + works in the cluster. + - For low-variance clusters, limit excerpt data to the earliest work + in the cluster +- Filter excerpts to include only those in associated with works in the + Literary and Original Bibliography collections +- Exclude internet poems reference corpus (as a stop gap, not desirable ultimately) + +The script reports steps and numbers as it proceeds. + +NOTE: this script expects to be run on a compiled excerpt dataset +that includes aggregate counts (`num_excerpts` and `num_poems`) in the +PPA metadata. + +Results are saved to subset_excerpts.csv + +""" + +import pathlib + +import polars as pl + +from corppa.config import get_config +from corppa.poetry_detection.polars_utils import add_ref_poems_meta, load_excerpts_df +from corppa.poetry_detection.ppa_works import ( + add_ppa_works_meta, + extract_page_meta, + load_ppa_works_df, +) + + +def main(): + cfg = get_config() + data_dir = pathlib.Path(cfg["compiled_dataset"]["data_dir"]) + + ppa_meta_df = load_ppa_works_df(data_dir / "ppa_work_metadata.csv") + # filter to clustered works, then group by clusters + # count the works and sum the pages, then order by number of works + ppa_clusters_df = ( + ppa_meta_df.filter(pl.col("ppa_cluster_id").is_not_null()) + .group_by("ppa_cluster_id") + .agg( + [ + pl.col("ppa_work_id").count().alias("work_count"), + pl.col("ppa_num_excerpts").sum().alias("total_excerpts"), + pl.col("ppa_num_excerpts").min().alias("min_excerpts"), + pl.col("ppa_num_excerpts").max().alias("max_excerpts"), + pl.col("ppa_num_poems").sum().alias("total_poems"), + pl.col("ppa_num_poems").min().alias("min_poems"), + pl.col("ppa_num_poems").max().alias("max_poems"), + ] + ) + .with_columns( + excerpt_variance=pl.col("max_excerpts").sub(pl.col("min_excerpts")), + poem_variance=pl.col("max_poems").sub(pl.col("min_poems")), + ) + .sort("work_count", descending=True) + ) + + # identify clusters with low variance in excerpts found in clustered works + lowvariance_clusters_df = ppa_clusters_df.filter( + pl.col("excerpt_variance").le(15) | pl.col("poem_variance").le(10) + ) + + lowvariance_clusters = lowvariance_clusters_df["ppa_cluster_id"].to_list() + print( + f"{len(lowvariance_clusters)} of {ppa_clusters_df.height} clusters have low variance in excerpts" + ) + + # for works in low-variance clusters, choose first work in each cluster + ppa_lvcluster_works = ppa_meta_df.filter( + pl.col("ppa_cluster_id").is_in(lowvariance_clusters) + ) + ppa_lvcluster_exemplars = ( + ppa_lvcluster_works.sort("ppa_pub_year") + .group_by("ppa_cluster_id", maintain_order=True) + .agg(pl.first("ppa_work_id"), pl.first("ppa_pub_year")) + ) + # how many works are we omitting ? + num_omitted_works = ppa_lvcluster_works.height - len(lowvariance_clusters) + + # and collect all other works + other_works = ppa_meta_df.filter( + pl.col("ppa_cluster_id").is_null() + | ~pl.col("ppa_cluster_id").is_in(lowvariance_clusters) + ) + selected_works = other_works.select("ppa_work_id").extend( + ppa_lvcluster_exemplars.select("ppa_work_id") + ) + print( + f"Selecting {ppa_lvcluster_exemplars.height} exemplars from low-variance " + + f"clusters and {other_works.height:,} other works (omitted {num_omitted_works:,})" + ) + print( + f"PPA work subset is {selected_works.height:,} of {ppa_meta_df.height:,} total works." + ) + + # load excerpts and then filter based on identified works + excerpts_df = extract_page_meta( + load_excerpts_df( + data_dir / "excerpts.csv.gz", + # ppa_works_meta=data_dir / "ppa_work_metadata.csv", + # ref_poems_meta=data_dir / "poem_meta.csv", + ) + ) + # extract page meta for joining but otherwise don't modify + print(f"\nLoaded {excerpts_df.height:,} excerpts. ") + subset_excerpts_df = excerpts_df.filter( + pl.col("ppa_work_id").is_in(selected_works["ppa_work_id"].implode()) + ) + print(f"\tFilter by low-variance cluster: {subset_excerpts_df.height:,} excerpts.") + + # filtering by cluster still too large, so also subset by PPA collection + lit_ob_works = ppa_meta_df.filter( + pl.col("ppa_collections").list.contains("Literary") + | pl.col("ppa_collections").list.contains("Original Bibliography") + ) + subset_excerpts_df = subset_excerpts_df.filter( + pl.col("ppa_work_id").is_in(lit_ob_works["ppa_work_id"].implode()) + ) + print( + f"\tLimit to Literary and Original Bibliography collections: {subset_excerpts_df.height:,} excerpts." + ) + + # internet poems ref corpus is probably largely duplicative; omit for now + subset_excerpts_df = subset_excerpts_df.filter( + ~pl.col("ref_corpus").eq("internet_poems") + ) + print( + f"Exclude internet poems reference corpus: {subset_excerpts_df.height:,} excerpts." + ) + + # save the subset + print("Saving subset to subset_excerpts.csv") + subset_excerpts_df.with_columns( + # convert list fields back to delimited string + detection_methods=pl.col("detection_methods").list.join(";"), + identification_methods=pl.col("identification_methods").list.join(";"), + ).write_csv("subset_excerpts.csv") + + # combine with poem/ppa metadata + # TODO: need to subset ppa metadata + subset_excerpts_df = add_ppa_works_meta( + subset_excerpts_df, data_dir / "ppa_work_metadata.csv" + ) + subset_excerpts_df = add_ref_poems_meta( + subset_excerpts_df, data_dir / "poem_meta.csv" + ) + + # omit ppa metadata fields that we don't need + subset_excerpts_df = subset_excerpts_df.drop( + ["ppa_added", "ppa_updated", "ppa_num_excerpts", "ppa_num_poems"] + ) + subset_excerpts_df.with_columns( + # convert list fields back to delimited string + detection_methods=pl.col("detection_methods").list.join(";"), + identification_methods=pl.col("identification_methods").list.join(";"), + ppa_collections=pl.col("ppa_collections").list.join(";"), + ).write_csv("subset_excerpts_with_poem_ppa_metadata.csv") + + +if __name__ == "__main__": + main() diff --git a/src/corppa/__init__.py b/src/corppa/__init__.py index 2bd72754..d97fc07d 100644 --- a/src/corppa/__init__.py +++ b/src/corppa/__init__.py @@ -1,4 +1,4 @@ # Copyright (c) 2024-2025, Center for Digital Humanities, Princeton University # SPDX-License-Identifier: Apache-2.0 -__version__ = "0.4.0" +__version__ = "0.5.0" diff --git a/src/corppa/config.py b/src/corppa/config.py index 3f49977a..83b59b80 100644 --- a/src/corppa/config.py +++ b/src/corppa/config.py @@ -5,7 +5,9 @@ Load local configuration options """ -import pathlib +from dataclasses import dataclass, field +from pathlib import Path +from typing import ClassVar, Optional import yaml @@ -14,8 +16,24 @@ except ImportError: # pragma: no cover from yaml import Loader # pragma: no cover + +def resolve_path(path: str | Path | None, base_dir: Path) -> Path | str | None: + """Convert to Path and make relative to base_dir if not absolute.""" + if path is None: + return None + if isinstance(path, str): + # check for URL; don't convert to path but keep as-is + if path.startswith("http"): + return path + # otherwise, convert string to path and make relative + path = Path(path) + if not path.is_absolute() and not path.is_relative_to(base_dir): + path = base_dir / path + return path + + #: src dir relative to this file (assuming dev environment for now) -CORPPA_SRC_DIR = pathlib.Path(__file__).parent.parent.absolute() +CORPPA_SRC_DIR = Path(__file__).parent.parent.absolute() #: expected path for local config file (non-versioned) CORPPA_CONFIG_PATH = CORPPA_SRC_DIR.parent / "corppa_config.yml" @@ -23,8 +41,130 @@ SAMPLE_CONFIG_PATH = CORPPA_SRC_DIR.parent / "sample_config.yml" +@dataclass +class CorpusConfig: + """ + Configuration for a single corpus. Includes a name, base directory, + text path, metadata path, and optional relative directory. Only + name is required. If not specified, `base_dir` will be set based on + the corpus name; relative to `relative_dir` when present. Similarly, + when not specified text and metadata paths will be set relative to + the corpus `base_dir` based on the defined suffixes. + """ + + #: corpus name or id + name: str + #: base directory for corpus data + base_dir: Optional[Path] = None + #: path to texts for this corpus (e.g., tar.gz or directory) + text_path: Optional[Path] = None + #: path to metadata for this corpus + metadata_path: Optional[Path] = None + #: optional relative directory that :attr:`base_dir`should be relative to + relative_dir: Optional[Path] = None + + _path_suffix: ClassVar[dict[str, str]] = { + "text": "_texts.tar.gz", + "metadata": ".csv", + } + + def __post_init__(self): + if self.base_dir is None: + self.base_dir = Path(self.name) + # optionally make base dir relative to another dir + # (most important for case where default base dir is inferred from name) + if self.relative_dir is not None: + self.base_dir = resolve_path(self.base_dir, self.relative_dir) + + # if paths are not set, use name and default suffix; + # make paths relative to base dir + if self.text_path is None: + self.text_path = f"{self.name}{self._path_suffix['text']}" + self.text_path = resolve_path(self.text_path, self.base_dir) + + if self.metadata_path is None: + self.metadata_path = f"{self.name}{self._path_suffix['metadata']}" + self.metadata_path = resolve_path(self.metadata_path, self.base_dir) + + def validate(self, text=True, metadata=True) -> bool: + """Check that configured paths are valid. By default, checks both + text and metadata paths to confirm they are set and appropriate + supported file types (directory or .tar.gz for text path, file for metadata). + Raises ValueError for any configuration error. + """ + if text: + if self.text_path is None: + raise ValueError( + f"Configuration error: {self.name} text_path is not set" + ) + elif not self.text_path.exists(): + raise ValueError( + f"Configuration error: {self.name} text_path {self.text_path} does not exist" + ) + # Currently supports directory and tar.gz file + if not self.text_path.is_dir() and not ( + self.text_path.is_file() and self.text_path.name.endswith(".tar.gz") + ): + raise ValueError( + f"Configuration error: {self.name} text_path {self.text_path} is not a directory or a tar.gz" + ) + if metadata: + if not self.metadata_path: # check for None or empty string + raise ValueError( + f"Configuration error: {self.name} metadata_path is not set" + ) + if ( + isinstance(self.metadata_path, Path) + and not self.metadata_path.is_file() + ): + raise ValueError( + f"Configuration error: {self.name} metadata_path {self.metadata_path} does not exist" + ) + + return True + + +@dataclass +class PPACorpusConfig(CorpusConfig): + """ + PPA corpus config. Defines suffixes for text + and metadata to match the known filenames used in PPA full-text dataset. + """ + + name: str = "ppa" + _path_suffix: ClassVar[dict[str, str]] = { + "text": "_pages.jsonl.gz", + "metadata": "_metadata.csv", + } + + +@dataclass +class ConfigOpts: + """ + Configuration options for compiling found poems dataset, loading + reference corpora, loading excerpts, and optional poem clusters metadata. + """ + + #: base directory + base_dir: Path + #: directory for compiled found poems dataset + compiled_dataset_dir: Path + #: PPA corpus configuration + ppa_corpus: Optional[PPACorpusConfig] = None + #: reference corpus configurations + reference_corpora: dict[str, CorpusConfig] = field(default_factory=dict) # type: ignore[arg-type] + #: path to excerpt data to be included in compiled found poems dataset + excerpt_data_dir: Optional[Path] = None + #: path to poem clusters CSV; currently expects a URL rather than local path + poem_clusters_path: Optional[str] = None + + def __post_init__(self): + self.excerpt_data_dir = resolve_path(self.excerpt_data_dir, self.base_dir) + # compiled dataset dir is NOT assumed relative to base dir + + def get_config(): - # if the config file is not in place + # complain if the config file is not in place if not CORPPA_CONFIG_PATH.exists(): not_found_msg = ( "Config file not found.\n" @@ -34,6 +174,60 @@ def get_config(): with CORPPA_CONFIG_PATH.open() as cfg_file: try: - return yaml.load(cfg_file, Loader=Loader) + # configuration in the yaml file should override any defaults + config_values = yaml.load(cfg_file, Loader=Loader) except yaml.parser.ParserError as err: raise SystemExit(f"Error parsing config file: {err}") + + try: + # use direct access for required values to trigger a KeyError + base_dir = Path(config_values["base_dir"]) + ref_corpus_configs = {} + # allow ref corpora config to be optional + if "reference_corpora" in config_values: + ref_corpus_base_dir = ( + resolve_path( + config_values["reference_corpora"].get("base_dir"), base_dir + ) + or base_dir + ) + if "base_dir" in config_values["reference_corpora"]: + # remove base_dir from dict before iterating over sections + del config_values["reference_corpora"]["base_dir"] + + for section, values in config_values["reference_corpora"].items(): + # when section is empty, values is None; convert to empty dict + if values is None: + values = {} + + section_base_dir = resolve_path( + values.get("base_dir"), ref_corpus_base_dir + ) + ref_corpus_configs[section] = CorpusConfig( + name=section, + base_dir=section_base_dir, + text_path=values.get("text_path"), + metadata_path=values.get("metadata_path"), + relative_dir=ref_corpus_base_dir, + ) + # allow ppa corpus to be optional + ppa_corpus = None + if "ppa_corpus" in config_values: + ppa_corpus = PPACorpusConfig( + base_dir=Path(config_values["ppa_corpus"]["base_dir"]), + relative_dir=base_dir, + ) + return ConfigOpts( + base_dir=base_dir, + compiled_dataset_dir=Path(config_values["compiled_dataset_dir"]), + ppa_corpus=ppa_corpus, + reference_corpora=ref_corpus_configs, + excerpt_data_dir=resolve_path( + config_values.get("excerpt_data_dir"), base_dir + ), + poem_clusters_path=config_values.get("poem_clusters_path"), + ) + except KeyError as err: + raise SystemExit( + f"Config file is missing required configuration: {err.args[0]}" + ) diff --git a/src/corppa/ocr/gvision_ocr.py b/src/corppa/ocr/gvision_ocr.py index d6e10f18..883406b4 100755 --- a/src/corppa/ocr/gvision_ocr.py +++ b/src/corppa/ocr/gvision_ocr.py @@ -52,7 +52,7 @@ def ocr_image_via_gvision( sys.exit(1) # Load the image into memory - with io.open(input_image, "rb") as image_reader: + with io.open(input_image, "rb") as image_reader: # pragma: no cover content = image_reader.read() image = google_vision.Image(content=content) @@ -105,7 +105,7 @@ def ocr_images( if show_progress: desc = "OCRing images" maxinterval = 1 - if ocr_limit: + if ocr_limit: # pragma: no cover progress_bar = tqdm(desc=desc, total=ocr_limit, maxinterval=maxinterval) else: bar_format = "{desc}: {n:,} images OCR'd | elapsed: {elapsed}, {rate_fmt}" @@ -146,7 +146,7 @@ def ocr_images( if ocr_limit and ocr_count == ocr_limit: # TODO: Is there a better structuring to avoid this break break - except (Exception, KeyboardInterrupt): + except (Exception, KeyboardInterrupt): # pragma: no cover # Close progress bar before raising error progress_bar.close() print( @@ -175,7 +175,7 @@ def ocr_volumes( exts: Iterable[str], ocr_limit: int = 0, show_progress: bool = True, -) -> None: +) -> None: # pragma: no cover """ OCR images for volumes ``vol_ids`` with extension exts to ``out_dir``. Assumes ``in_dir`` follows the PPA directory conventions (see :py:mod:`corppa.utils.path_utils` for more @@ -242,7 +242,7 @@ def ocr_volumes( ) -def main(): +def main(): # pragma: no cover parser = argparse.ArgumentParser( description="Uses Google Vision API to OCR images." ) diff --git a/src/corppa/poetry_detection/annotation/process_adjudication_data.py b/src/corppa/poetry_detection/annotation/process_adjudication_data.py index f861fd18..55f0ccc4 100644 --- a/src/corppa/poetry_detection/annotation/process_adjudication_data.py +++ b/src/corppa/poetry_detection/annotation/process_adjudication_data.py @@ -147,7 +147,7 @@ def process_adjudication_data( orjsonl.append(output_pages, page_data) -def main(): +def main(): # pragma: no cover """ Extracts page- and excerpt-level data from a Prodigy data file (JSONL) and writes the page-level excerpt data to a JSONL (`output_pages`) and the diff --git a/src/corppa/poetry_detection/annotation/recipe.py b/src/corppa/poetry_detection/annotation/recipe.py index 212d00f7..f58f3884 100644 --- a/src/corppa/poetry_detection/annotation/recipe.py +++ b/src/corppa/poetry_detection/annotation/recipe.py @@ -136,7 +136,7 @@ def annotate_text_and_image( labels: LabelsType = [], image_prefix: str = None, fetch_media: bool = False, -) -> RecipeSettingsType: +) -> RecipeSettingsType: # pragma: no cover """Annotate text and image side by side: allows adding manual spans to both image and text. Intended for page-level annotation. """ @@ -205,7 +205,7 @@ def annotate_page_text( labels: LabelsType = [], image_prefix: str = None, fetch_media: bool = False, -) -> RecipeSettingsType: +) -> RecipeSettingsType: # pragma: no cover """Annotate text with manual spans; displays an image side by side with text for reference only (image cannot be annotated). Intended for page-level annotation. @@ -375,7 +375,7 @@ def __init__( def __len__(self) -> int: return len(self.data) - def __iter__(self) -> StreamType: + def __iter__(self) -> StreamType: # pragma: no cover for example in self.data: yield example @@ -478,7 +478,7 @@ def review_page_spans( image_prefix: str = None, fetch_media: bool = False, sessions: List[str] = [], -) -> RecipeSettingsType: +) -> RecipeSettingsType: # pragma: no cover """ Review input text span annotations and annotate with manual spans to create final, adjudicated annotations. Loads and displays input text span diff --git a/src/corppa/poetry_detection/chadwyck_healey/tml_parser.py b/src/corppa/poetry_detection/chadwyck_healey/tml_parser.py index a58ef9ae..a6c09c81 100644 --- a/src/corppa/poetry_detection/chadwyck_healey/tml_parser.py +++ b/src/corppa/poetry_detection/chadwyck_healey/tml_parser.py @@ -720,7 +720,7 @@ def final_return(text_str: str) -> str: print(traceback.format_exc()) return "" - def process_file(self, file_path: Path): + def process_file(self, file_path: Path): # pragma: no cover """ Process a single TML file: read it, parse metadata and poetry text, and return both. Parameters: @@ -801,7 +801,7 @@ def process_file(self, file_path: Path): self.current_file = None return metadata, poetry_text - def process_directory(self, num_files: Optional[int] = None): + def process_directory(self, num_files: Optional[int] = None): # pragma: no cover """ Processes the TML files within this parser's input directory of TML files. For each file, its metadata and poetry text are extracted with its metadata @@ -896,7 +896,7 @@ def process_directory(self, num_files: Optional[int] = None): for f in self.figure_only: print(" -", f) - def extract_metadata(self, soup: Tag) -> dict[str, str]: + def extract_metadata(self, soup: Tag) -> dict[str, str]: # pragma: no cover """ Extract metadata from the TML file's head section. Handles multiple authors (original and translator) as well as special cases @@ -1028,7 +1028,7 @@ def extract_metadata(self, soup: Tag) -> dict[str, str]: return metadata -def main(): +def main(): # pragma: no cover parser_arg = argparse.ArgumentParser( description="Process ChadwychTML Poetry Files and extract metadata and poetry text." ) diff --git a/src/corppa/poetry_detection/compile_dataset.py b/src/corppa/poetry_detection/compile_dataset.py new file mode 100644 index 00000000..058dedec --- /dev/null +++ b/src/corppa/poetry_detection/compile_dataset.py @@ -0,0 +1,301 @@ +""" +This script compiles the PPA Found Poems dataset. + +It depends on compiled_dataset and reference_corpora configuration +settings, as described in the project readme and seen in `sample_config.yml`. + +To run compilation with all steps (default behavior):: +```console +compile-dataset +``` + +To run one or more specific steps, specify which steps you want to run. +Any string that is distinct will be enough to select the step. + +```console +compile-dataset --merge +compile-dataset --poem-metadata +compile-dataset --poem-metadata --ppa-metadata +compile-dataset --m --poem -ppa +``` + +""" + +import argparse +import gzip +import pathlib +import shutil +import sys + +import polars as pl + +from corppa.config import get_config +from corppa.poetry_detection.merge_excerpts import merge_excerpt_files +from corppa.poetry_detection.polars_utils import add_ref_poems_meta +from corppa.poetry_detection.ppa_works import extract_page_meta +from corppa.poetry_detection.ref_corpora import save_poem_metadata + +DEFAULT_CONFIGS = { + "source_excerpt_data": "excerpt-data", + "source_ppa_metadata": "ppa-data/ppa_works.csv", +} + +#: compile script config options, for run_step method type hints +CompileOpts = dict[str, pathlib.Path] + + +def load_compilation_config(): + """Load configuration for dataset compilation, + validating that required configurations are present, paths exist, etc. + """ + config_opts = get_config() + + # output directory - required in config object initialization + output_data_dir = config_opts.compiled_dataset_dir + # NOTE: could shift validation to config object in future + if not output_data_dir.exists(): + raise ValueError( + f"Configuration error: compiled dataset path {output_data_dir} does not exist" + ) + if not output_data_dir.is_dir(): + raise ValueError( + f"Configuration error: compiled dataset path {output_data_dir} is not a directory" + ) + + # filenames where compiled data will be saved + # NOTE: perhaps in future this can be moved to a config object + compiled_excerpt_file = output_data_dir / "excerpts.csv" + compressed_excerpt_file = output_data_dir / "excerpts.csv.gz" + poem_metadata_file = output_data_dir / "poem_meta.csv" + ppa_metadata_file = output_data_dir / "ppa_work_metadata.csv" + + # optional in config, but already resolved to base dir; validate existence? + excerpt_data_dir = config_opts.excerpt_data_dir + + # ppa metadata - optional in config, ensure it is present + if config_opts.ppa_corpus is None: + raise ValueError( + "Configuration error: PPA corpus must be configured for dataset compilation" + ) + source_ppa_metadata = config_opts.ppa_corpus.metadata_path + if not source_ppa_metadata.exists() or not source_ppa_metadata.is_file(): + raise ValueError( + f"Configuration error: PPA metadata file {source_ppa_metadata} does not exist" + ) + + return { + # outputs + "output_data_dir": output_data_dir, + "compiled_excerpt_file": compiled_excerpt_file, + "compressed_excerpt_file": compressed_excerpt_file, + "poem_metadata_file": poem_metadata_file, + "ppa_metadata_file": ppa_metadata_file, + # sources + "source_excerpt_data": excerpt_data_dir, + "source_ppa_metadata": source_ppa_metadata, + # required? warn if not present? + "source_poem_clusters": config_opts.poem_clusters_path, + } + + +def load_compiled_excerpts(config: CompileOpts) -> pl.DataFrame: + """Load compiled excerpts from CSV or compressed CSV file + based on configured path, whichever file exists (uncompressed first). + Raises a ValuError if neither file exists. + """ + for datafile in [ + config["compiled_excerpt_file"], + config["compressed_excerpt_file"], + ]: + if datafile.exists(): + # extract ppa work id and page number (needed for both poem and ppa metadata) + return extract_page_meta(pl.read_csv(datafile)) + raise ValueError( + f"Excerpt data file not found (checked {config['compiled_excerpt_file']} and {config['compressed_excerpt_file']}" + ) + + +def get_excerpt_sources(excerpt_data_dir: pathlib.Path) -> list[pathlib.Path]: + """ + Find all CSV and compressed CSV files in a directory. + """ + return list(excerpt_data_dir.glob("**/*.csv")) + list( + excerpt_data_dir.glob("**/*.csv.gz") + ) + + +def save_ppa_metadata( + input_file: pathlib.Path, output_file: pathlib.Path, excerpts_df: pl.DataFrame +): + """ + Save PPA work metadata with work-level excerpt totals. + Takes a PPA metadata file as input, a path for the output file, + and a dataframe of merged excerpt data. + Raises a ValueError if metadata file is not a CSV. + """ + # copy as-is, do not rename or subset any fields + # NOTE: currently assumes and only supports PPA metadata in csv format + if input_file.suffix != ".csv": + raise ValueError( + f"PPA metadata must be loaded as CSV, got {input_file.suffix.lstrip('.')}" + ) + ppa_meta_df = pl.read_csv(input_file) + + # get work-level aggregate excerpt totals + excerpt_totals_df = excerpts_df.group_by("ppa_work_id").agg( + pl.col("excerpt_id").n_unique().alias("num_excerpts"), + pl.col("poem_id").n_unique().alias("num_poems"), + pl.col("poem_author").n_unique().alias("num_poets"), + ) + + # combine the totals with ppa work metadata + ppa_meta_df = ppa_meta_df.join( + excerpt_totals_df, left_on="work_id", right_on="ppa_work_id", how="left" + ).with_columns( + # fill any missing values with zeroes + pl.col("num_excerpts").fill_null(pl.lit(0)), + pl.col("num_poems").fill_null(pl.lit(0)), + pl.col("num_poets").fill_null(pl.lit(0)), + ) + + ppa_meta_df.write_csv(output_file) + + +def compress_file(uncompressed_file: pathlib.Path, compressed_file: pathlib.Path): + """ + Compress the `uncompressed_file` passed in with gzip, + saving it at the `compressed_file` path and deleting the original. + """ + with open(str(uncompressed_file), "rb") as inputfile: + with gzip.open(str(compressed_file), "wb") as output_file: + shutil.copyfileobj(inputfile, output_file) + # report sizes before/after? maybe return them? + # remove the uncompressed file + uncompressed_file.unlink() + + +def run_merge_step( + compile_opts: CompileOpts, excerpts_df: pl.DataFrame | None, compress_excerpts: bool +) -> pl.DataFrame: + """Run the merge excerpts step. Finds source excerpt files from the configured + path, merges excerpts, saves to CSV, and optionally compresses the CSV file. + """ + print("## Merging excerpts") + excerpt_sources = get_excerpt_sources(compile_opts["source_excerpt_data"]) + excerpts_df = merge_excerpt_files( + excerpt_sources, compile_opts["compiled_excerpt_file"] + ) + if compress_excerpts: + print( + f"Compressing excerpt data... {compile_opts['compiled_excerpt_file']} → {compile_opts['compressed_excerpt_file']}" + ) + compress_file( + compile_opts["compiled_excerpt_file"], + compile_opts["compressed_excerpt_file"], + ) + return excerpts_df + + +def run_poem_metadata_step( + compile_opts: CompileOpts, excerpts_df: pl.DataFrame | None = None +) -> None: + """Run the poem metadata compilation step. Uses excerpt data + (passed in or loaded from compile opts path) to calculate + poem excerpt totals. + """ + print("\n## Compiling reference corpora metadata") + if excerpts_df is None: + excerpts_df = load_compiled_excerpts(compile_opts) + else: + excerpts_df = extract_page_meta(excerpts_df) + + # load poem cluster id information if configured + poem_cluster_path = compile_opts.get("source_poem_clusters") + poem_clusters_df = None + if poem_cluster_path: + poem_clusters_df = pl.read_csv(poem_cluster_path) + + save_poem_metadata( + compile_opts["poem_metadata_file"], excerpts_df, poem_clusters_df + ) + + +def run_ppa_metadata_step( + compile_opts: CompileOpts, excerpts_df: pl.DataFrame | None = None +) -> None: + """Run the PPA metadata compilation step. Uses excerpt data (passed + in or loaded from compile opts path) to calculate work-level + excerpt totals. + """ + print("\n## PPA work-level metadata") + if excerpts_df is None: + excerpts_df = load_compiled_excerpts(compile_opts) + else: + excerpts_df = extract_page_meta(excerpts_df) + + excerpts_df = add_ref_poems_meta(excerpts_df, compile_opts["poem_metadata_file"]) + + save_ppa_metadata( + compile_opts["source_ppa_metadata"], + compile_opts["ppa_metadata_file"], + excerpts_df, + ) + + +def main(cmd_args=None) -> None: + """ + Main entry point for the dataset compilation script. Parses + arguments to determine which steps to run. + """ + # allow passing arguments in; if not specified, draw from sys.argv/command line + if cmd_args is None: + cmd_args = sys.argv[1:] + parser = argparse.ArgumentParser(description="Compile PPA found-poems dataset") + parser.add_argument( + "--compress-excerpts", + action=argparse.BooleanOptionalAction, + default=True, + ) + # add an argument group to allow easily specifying specific steps + step_arg_group = parser.add_argument_group( + "Step", + "Only run specific compilation steps", + ) + compilation_steps = { + "merge": "Merge excerpts", + "poem_metadata": "Compile reference corpus poetry metadata", + "ppa_metadata": "Compile filtered and renamed PPA work-level metadata", + } + for step, description in compilation_steps.items(): + step_arg_group.add_argument( + f"--{step}", + help=description, + metavar="", + dest="steps", + action="append_const", + const=step, + ) + args = parser.parse_args(cmd_args) + # if not specified, run all steps + compilation_steps = args.steps if args.steps else list(compilation_steps.keys()) + + compile_opts = load_compilation_config() + + excerpts_df = None + if "merge" in compilation_steps: + excerpts_df = run_merge_step(compile_opts, excerpts_df, args.compress_excerpts) + + if "poem_metadata" in compilation_steps: + run_poem_metadata_step(compile_opts, excerpts_df) + + if "ppa_metadata" in compilation_steps: + run_ppa_metadata_step(compile_opts, excerpts_df) + + # probably not relevant anymore, not using git-lfs for this data... + print(f"Output files in {compile_opts['output_data_dir']}") + # print("\nRemember to commit and push the updated data files") + # print(f"cd {compile_opts['output_data_dir'].parent} && git add data/*") + + +if __name__ == "__main__": + main() diff --git a/src/corppa/poetry_detection/core.py b/src/corppa/poetry_detection/core.py index 67320ff7..619a46e0 100644 --- a/src/corppa/poetry_detection/core.py +++ b/src/corppa/poetry_detection/core.py @@ -1,4 +1,4 @@ -# Copyright (c) 2024-2025, Center for Digital Humanities, Princeton University +# Copyright (c) 2024-2026, Center for Digital Humanities, Princeton University # SPDX-License-Identifier: Apache-2.0 """ @@ -12,7 +12,7 @@ from Bio.Align import PairwiseAligner -# Table of supported detection methods and their corresponding prefixes +#: Supported detection methods with corresponding prefixes DETECTION_METHODS = { "adjudication": "a", "manual": "m", @@ -27,8 +27,11 @@ class Span: Span object representing a Pythonic "closed open" interval """ + #: start index start: int + #: end index end: int + #: label for the span label: str def __post_init__(self): @@ -87,12 +90,13 @@ def overlap_factor(self, other: "Span", ignore_label: bool = False) -> float: def field_real_type(field_type) -> type: """Return the real type for a dataclass field type annotation. - For unions or optional values (e.g. `Optional[int]`), returns the first - non-None type; for type aliases (e.g. `set[str]`, returns the original type + For unions or optional values (e.g. ``Optional[int]``), returns the first + non-None type; for type aliases (e.g. ``set[str]``), returns the original type that was used to create the alias. For example: - - int -> int - - Optional[int] -> int - - set[str] -> set + + - ``int`` -> ``int`` + - ``Optional[int]`` -> ``int`` + - ``set[str]`` -> ``set`` """ # if it's a regular type, return unchanged if isinstance(field_type, type): @@ -105,7 +109,8 @@ def field_real_type(field_type) -> type: # if Optional or Union, return the first non-none type ftypes = get_args(field_type) if ftypes: - return [arg for arg in ftypes if arg != types.NoneType][0] + # union type could be another annotation, so return field type + return field_real_type([arg for arg in ftypes if arg != types.NoneType][0]) # if we get here, this is an input we can't handle raise TypeError(f"Cannot determine real type for '{field_type}'") @@ -128,6 +133,9 @@ def input_to_set(input_val: list | str | set) -> set: return set(input_val.split(MULTIVAL_DELIMITER)) case set(): return input_val + # None is possible for optional set fields + case None: + return None case _: raise ValueError(f"Unexpected value type '{type(input_val).__name__}'") @@ -139,16 +147,21 @@ class Excerpt: """ # PPA page related + #: page id page_id: str + #: ppa span start index ppa_span_start: int + #: ppa span end index ppa_span_end: int + #: ppa span text ppa_span_text: str - # Detection methods + #: Detection methods detection_methods: set[str] - # Optional notes field + #: Optional notes notes: Optional[str] = None # Excerpt id, set in post initialization # Note: Cannot be passed in at initialization + #: excerpt identifier excerpt_id: str = field(init=False) def __post_init__(self): @@ -158,7 +171,7 @@ def __post_init__(self): f"PPA span's start index {self.ppa_span_start} must be less than its end index {self.ppa_span_end}" ) - # Check that dectection method set is not empty + # Check that detection method set is not empty if not self.detection_methods: raise ValueError("Must specify at least one detection method") @@ -254,7 +267,7 @@ def from_dict(cls, d: dict) -> "Excerpt": set_fields = [k for k, v in cls_field_types.items() if v is set] for field_name in set_fields: try: - input_args[field_name] = input_to_set(input_args[field_name]) + input_args[field_name] = input_to_set(input_args.get(field_name)) except ValueError as err: raise ValueError(f"{err} for {field_name}") @@ -304,8 +317,8 @@ def correct_page_excerpt(self, page_text: str) -> Self: aligner = PairwiseAligner( mismatch_score=-0.5, gap_score=-0.5, - query_left_gap_score=0, # no penalty for gaps to the left of the excerpt - query_right_gap_score=0, # no penlty for gaps to the right of the excerpt + left_deletion_score=0, # no penalty for gaps to the left of the excerpt + right_deletion_score=0, # no penlty for gaps to the right of the excerpt ) # List of best alignments, there can be more than one results = aligner.align(page_text, self.ppa_span_text) @@ -332,13 +345,20 @@ class LabeledExcerpt(Excerpt): """ # Reference poem related + #: poem id poem_id: str + #: reference corpus id ref_corpus: str + #: reference span start index ref_span_start: Optional[int] = None + #: reference span end index ref_span_end: Optional[int] = None + #: reference span text ref_span_text: Optional[str] = None + #: set of alternate poem ids, for merged excerpts with multiple ids + alt_poem_ids: Optional[set[str]] = None - # Identification methods + #: Identification methods identification_methods: set[str] def __post_init__(self): @@ -347,7 +367,7 @@ def __post_init__(self): # Check that identification method set is not empty if not self.identification_methods: raise ValueError("Must specify at least one identification method") - # Check that both reference span indicies are set or unset + # Check that both reference span indices are set or unset if (self.ref_span_start is None) ^ (self.ref_span_end is None): raise ValueError("Reference span's start and end index must both be set") # Check reference span indices if set diff --git a/src/corppa/poetry_detection/merge_excerpts.py b/src/corppa/poetry_detection/merge_excerpts.py index 513f96bf..b923b958 100755 --- a/src/corppa/poetry_detection/merge_excerpts.py +++ b/src/corppa/poetry_detection/merge_excerpts.py @@ -1,11 +1,10 @@ #!/usr/bin/env python -# Copyright (c) 2024-2025, Center for Digital Humanities, Princeton University +# Copyright (c) 2024-2026, Center for Digital Humanities, Princeton University # SPDX-License-Identifier: Apache-2.0 """ -This script merges labeled and unlabeled poem excerpts, combining -notes for any merged excerpts, and merging duplicate poem identifications -in simple cases. +This script and associated method merges labeled and unlabeled poem excerpts +with matching spans in the PPA page text. It takes two or more input files of excerpt data (labeled or unlabeled) in CSV format, merges any excerpts that can be combined, and outputs a CSV with the updated excerpt data. @@ -15,237 +14,279 @@ Merging logic is as follows: -- Excerpts are grouped on the combination of page id and excerpt id, - and then merged if all reference fields match exactly, or where - reference fields are present in one excerpt and unset in the other. - - - If the same excerpt has different labels (different `poem_id` values), both - labeled excerpts will be included in the output - - If the same excerpt has duplicate labels (i.e., the same `poem_id` from two - different identification methods), they will be merged - into a single labeled excerpt; the `identification_methods` in the - resulting labeled excerpt will be the union of methods in the merged excerpts - -- When merging excerpts where both records have notes, notes content - will be combined. +- Excerpts are grouped based on exact span match in PPA text (i.e., the + combination of ``page_id``, ``ppa_span_start``, and ``ppa_span_end``) + even when poem identifications differ, and combined as follows: + + - Excerpts are sorted by ``poem_id``, ``ref_span_start``, and passim + match length with nulls last and longest passim match first. + Reference information (``poem_id``, ``ref_span_start``, ``ref_span_end``, + ``ref_span_text``, ``ref_corpus``) is taken from the first excerpt in + the group. + - When merged excerpts have different poem identifications, all unique + poem ids after the first are collected into ``alt_poem_ids`` + - The ``detection_methods`` and ``identification_methods`` fields are combined + to the unique set of methods used in the merged excerpts. + - The ``notes`` field is combined with the set of all unique content from + notes in merged excerpts with an additional note about the merge. Example usage: :: -./src/corppa/poetry_detection/merge_excerpts.py adjudication_excerpts.csv \ -labeled_excerpts.csv -o merged_excerpts.csv + merge-excerpts adjudication_excerpts.csv labeled_excerpts.csv -o merged_excerpts.csv Limitations: -- Labeled excerpts with the same poem_id but different reference data - will not be merged; supporting multiple identification methods that output - span information will likely require more sophisticated merge logic -- CSV input and output only (JSONL may be added in future) -- Notes are currently merged only with the first matching excerpt; if an - unlabeled excerpt with notes has multiple labels, only the first match - will have combined notes +- Merge logic collapses different poem ids that may not correspond; + they may be subsets of the same poem, different editions, or entirely + different poems. Alternate poem ids are preserved in ``alt_poem_ids``. +- Currently supports CSV input and output only. """ import argparse -import itertools import pathlib import sys import polars as pl -from tqdm import tqdm +from polars import col as c # for shorthand column notation from corppa.poetry_detection.core import MULTIVAL_DELIMITER from corppa.poetry_detection.polars_utils import load_excerpts_df, standardize_dataframe -def combine_duplicate_methods_notes(repeats_df: pl.DataFrame) -> pl.DataFrame: - """ - Takes a dataframe of repeated excerpts with duplicate information, - and updates all rows with the combined set of unique - detection_methods, identification_methods, and notes. Returns the - updated dataframe with the combined fields. - - Intended for use on grouped excerpts in :meth:`merge_excerpts`. +def merge_excerpt_groups( + grouped_df: pl.dataframe.group_by.GroupBy, merge_reason: str = "ppa exact span" +) -> pl.DataFrame: + """Takes a GroupBy dataframe of excerpts (created by calling `group_by`), and combines + groups of excerpts into merged excerpts. Fields are expected to correspond to + labeled excerpts (:class:`~corppa.poetry_detection.core.LabeledExcerpt`), and the + dataframe should be pre-sorted so the preferred excerpt comes first, + since in several cases the first value is the one preserved in the merge. + Merge logic is as follows: + + - first ``ppa_span_text``, ``poem_id``, reference corpus values (``ref_corpus``, + ``ref_span_start``, ``ref_span_end``, ``ref_span_text``) + - combined unique set of detection methods and identification methods + - combined unique set of notes + - updated excerpt id + - any additional poem ids are listed in ``alt_poem_ids`` + + After merging, the notes field is updated with text documenting the merge + with the specified reason (by default, exact span in PPA), and the + number of excerpts that were merged. """ - # get detection methods as a list of lists, use itertools to unwrap - # the lists; consume the itertools generator and use set to uniquify, - # then convert back to list to put back in the polars dataframe - detect_methods = repeats_df["detection_methods"].drop_nulls().to_list() - combined_detect_methods = list(set(itertools.chain.from_iterable(detect_methods))) - # id methods could be all unset even in a group - id_methods = repeats_df["identification_methods"].drop_nulls().to_list() - combined_id_methods = None - if id_methods: - combined_id_methods = list(set(itertools.chain.from_iterable(id_methods))) - # join all unique notes within this group; don't repeat notes - # preserve order (unlabeled excerpt notes first) - unique_notes = repeats_df["notes"].drop_nulls().unique(maintain_order=True) - combined_notes = "\n".join([n for n in unique_notes if n.strip()]) - - repeats_df = repeats_df.with_columns( - detection_methods=pl.lit(combined_detect_methods), - identification_methods=pl.lit(combined_id_methods), - notes=pl.lit(combined_notes), - ) - return repeats_df + return ( + grouped_df.agg( + # NOTE: does not handle overlapping spans; + # currently assumes text spans match or first in group is complete + pl.first("ppa_span_text"), + c.detection_methods.explode().unique(), # combine in a single list, no repeats + # combine notes but don't repeat duplicate info (like passim char match count) + c.notes.explode().unique().str.join("; "), + # construct merged excerpt id manually; c= prefix for combined + # (although strictly speaking should only be if > 1 detection method) + pl.concat_str( + pl.lit("c@"), + c.ppa_span_start.first(), + pl.lit(":"), + c.ppa_span_end.first(), + ).alias("excerpt_id"), + # pick the first poem id (relies on previous sorting) + c.poem_id.first(), + # and store all others in alt poem ids field + c.poem_id.unique().drop_nulls().slice(1).alias("alt_poem_ids"), + c.ref_corpus.first(), + # use first reference span and text so numbers are useful + c.ref_span_start.first(), + c.ref_span_end.first(), + c.ref_span_text.first(), + # combine unique list of unique id methods; ignore nulls (not identified before merging) + c.identification_methods.explode().unique().drop_nulls(), + pl.len().alias("group_size"), # count number in the group + ) + .with_columns( + notes=pl.concat_str( + c.notes, + pl.lit(f"; merge: {merge_reason}, "), + c.group_size, + pl.lit(" excerpts"), + ), + # if alt poem ids is empty, replace with None + alt_poem_ids=pl.when(c.alt_poem_ids.list.len() > 0) + .then(c.alt_poem_ids) + .otherwise(pl.lit(None)), + ) + .drop("group_size") + ) # drop group size column def merge_excerpts( df: pl.DataFrame, disable_progress=True, verbose=False ) -> pl.DataFrame: - """Takes a polars DataFrame that includes labeled or unlabeled excerpts, - and merges excerpts based primarily on `page_id` and `excerpt_id`. - For now, merging is only done on the simple cases where reference - fields match exactly, or where reference fields are present in one labeled - excerpt and unset in the other: - - unlabeled excerpts with matching labeled excerpts - - multiple labeled excerpts with matching `poem_id` and non-conflicting - reference information - - When excerpts are merged, the detection_methods, identification_methods, - and notes fields are all combined to preserve all information. + """Takes a polars DataFrame that includes labeled or unlabeled excerpts + (fields correspond to :class:`~corppa.poetry_detection.core.LabeledExcerpt`), + and merges excerpts based on ``page_id`` and ppa span (``ppa_span_start`` and + ``ppa_span_end``). For now, merging is only done on the simple cases where + PPA excerpt text bounds match exactly. The best match is prioritized, based + on passim match length; alternate poem ids are preserved in ``alt_poem_ids``. + + When excerpts are merged, the ``detection_methods``, ``identification_methods``, + and ``notes`` fields are all combined to preserve information. Returns a dataframe that contains all unique excerpts and merged - versions of duplicated excerpts. + versions of duplicate excerpts. """ - # TEMPORARY - make sure internet poem ref corpus ids match before merging - df = df.with_columns( - ref_corpus=pl.when(pl.col("ref_corpus").eq("internet-poems")) - .then(pl.lit("internet_poems")) - .otherwise(pl.col.ref_corpus) - ) - # group by page id and excerpt id to get potential matches # use aggregation to get the count of excerpts in each group, # then split input dataframe into singletons and merge candidates - grouped = df.group_by(["page_id", "excerpt_id"]).agg(pl.len().alias("group_size")) + # NOTE: span start/end to merge across systems, because excerpt id includes detection method + grouped = df.group_by(["page_id", "ppa_span_start", "ppa_span_end"]).agg( + pl.len().alias("group_size") + ) # any excerpts with group size one will not be merged; # add to output df and don't process further output_df = ( - df.join(grouped, on=["page_id", "excerpt_id"]) - .filter(pl.col("group_size").eq(1)) + df.join(grouped, on=["page_id", "ppa_span_start", "ppa_span_end"]) + .filter(c.group_size.eq(1)) .drop("group_size") ) if output_df.is_empty(): + # if none were found, create an empty output_df = df.clear() # any excerpts with group size > 1 are candidates for merging merge_candidates = ( - df.join(grouped, on=["page_id", "excerpt_id"]) - .filter(pl.col("group_size").gt(1)) + df.join(grouped, on=["page_id", "ppa_span_start", "ppa_span_end"]) + .filter(c.group_size.gt(1)) .drop("group_size") ) - merge_groups = merge_candidates.group_by(["page_id", "excerpt_id"]) + # sort by page then poem id, with nulls last, to ensure we select + # a non-null poem id and reference data; + # extract passim match length and sort longest passim matches first + merge_groups = ( + merge_candidates.with_columns( + # extract passim match length so we can prioritize longer matches + passim_match_len=c.notes.str.extract(r"passim: (\d+) char matches") + ) + .sort( + "page_id", + "passim_match_len", + "poem_id", + "ref_span_start", + nulls_last=True, + descending=[False, True, False, False], # sort longest passim matches first + ) + .group_by(["page_id", "ppa_span_start", "ppa_span_end"], maintain_order=True) + ) num_merge_groups = merge_groups.len().height if verbose: print( - f"Identified {merge_candidates.height:,} merge candidates in {num_merge_groups:,} groups.\n" + f"Identified {merge_candidates.height:,} merge candidates in {num_merge_groups:,} groups." ) - progress_groups = tqdm( - merge_groups, - total=num_merge_groups, - desc="Merging...", - disable=disable_progress, - ) - merge_count = 0 - for group, data in progress_groups: - # group is a tuple of values for page id, excerpt id, poem id - # data is a df of the grouped rows for this set - - # sort so any empty values for optional reference fields are first, - # then fill values backward - i.e., treat nulls as duplicates, - # but keep unlabeled excerpts first - data = data.sort( - "poem_id", - "ref_corpus", - "ref_span_start", - "ref_span_end", - "ref_span_text", - nulls_last=False, - ).select(pl.all().backward_fill()) - - # in case this set of excerpts has multiple different poem ids - # which should be merged with each other, group again on poem id - for poem_group, poem_data in data.group_by(["poem_id"]): - # group of 1 : no merge, add to the output - if poem_data.height == 1: - output_df.extend(poem_data) - # otherwise, look for repeats - else: - # combine if everything is the same but methods, and notes - # (other values must either be the same or don't conflict because they were unset) - repeat_counts = poem_data.with_columns( - duplicate=poem_data.drop( - "detection_methods", "identification_methods", "notes" - ).is_duplicated() - ) - # repeats will be consolidated - repeats = repeat_counts.filter(pl.col("duplicate")).drop("duplicate") - # any non-repeats should be included in output as-is - output_df.extend( - repeat_counts.filter(~pl.col("duplicate")).drop("duplicate") - ) + merged_output_df = merge_excerpt_groups(merge_groups) - if not repeats.is_empty(): - repeats = combine_duplicate_methods_notes(repeats) - # add one copy of the consolidated information to the merge df - output_df.extend(repeats[:1]) - merge_count += 1 - progress_groups.set_postfix_str(f"Merged {merge_count:,}") + if verbose: + multi_id = merged_output_df.filter(c.alt_poem_ids.list.len().gt(0)).height + print( + f"{merged_output_df.height:,} merged excerpts; {multi_id:,} with multiple poem ids." + ) + + # combined merged records with the output + # use a diagonal concat instead of vstack/extend + # to avoid having to reconcile columns first + output_df = pl.concat([output_df, merged_output_df], how="diagonal") return output_df -def main(): - parser = argparse.ArgumentParser( - description="Merge excerpts with labeled excerpts or notes" - ) - parser.add_argument( - "-o", - "--output", - help="Output filename for merged excerpts (CSV)", - type=pathlib.Path, - required=True, - ) - parser.add_argument( - "input_files", - nargs="+", - help="Two or more input files with excerpt or labeled excerpt data", - type=pathlib.Path, - ) +def identify_overlapping_excerpts( + excerpts_df: pl.DataFrame, + min_overlap_factor: float = 0.98, + min_overlap_chars: int = 10, +) -> pl.DataFrame: + """ + Takes a DataFrame of excerpts and identifies pairs of overlapping excerpts. + Overlapping excerpts are on the same page, with some shared span of text. + We exclude short overlaps based on the minimum character parameter, + and an overlap factor, which is calculated by the length of the shared + span divided by the length of the longer of the two spans. Note that + this will typically not return small excerpts completely inside another + larger span. + + Returns a DataFrame of excerpt pairs with columns for page id, + pairs of excerpt ids, overlap length, and overlap factor. + """ - args = parser.parse_args() - # output file should not exist - if args.output.exists(): - print( - f"Error: output file {args.output} already exists, not overwriting", - file=sys.stderr, + # identify excerpts with partial overlap + overlaps_df = ( + excerpts_df + # Filter to excerpts on pages with multiple excerpts + .filter(c.page_id.is_duplicated()) + .join_where( + excerpts_df, + # 1. Limit to excerpts on the same page + c.page_id == c.page_id_right, + # 2. Excerpts overlap: + # left span starts before right span ends + c.ppa_span_start < c.ppa_span_end_right, + # and right span starts before left span ends + c.ppa_span_start_right < c.ppa_span_end, + # 3. Exclude self-matches and reverse matches + c.excerpt_id < c.excerpt_id_right, ) - sys.exit(-1) - # we need at least two input files - if len(args.input_files) < 2: - print( - "Error: at least two input files are required for merging", file=sys.stderr + .with_columns( + # calculate length of the overlap: smaller end minus larger start + overlap_len=pl.min_horizontal(c.ppa_span_end, c.ppa_span_end_right).sub( + pl.max_horizontal(c.ppa_span_start, c.ppa_span_start_right) + ), ) - sys.exit(-1) - - # make sure input files exist - non_existent_input = [f for f in args.input_files if not f.exists()] - if non_existent_input: - print( - f"Error: input files not found: {', '.join([str(f) for f in non_existent_input])}", - file=sys.stderr, + .with_columns( + overlap_factor=c.overlap_len.truediv( + pl.max_horizontal( + c.ppa_span_end.sub(c.ppa_span_start), + c.ppa_span_end_right.sub(c.ppa_span_start_right), + ) + ) ) - sys.exit(-1) + # filter to requested overlap / length to limit to high confidence overlaps + .filter( + c.overlap_factor.ge(min_overlap_factor), + c.overlap_len.ge(min_overlap_chars), + ) + ) + # what fields are needed here? + return overlaps_df.select( + "page_id", + "excerpt_id", + "excerpt_id_right", + "overlap_len", + "overlap_factor", + # these are not strictly needed but may be helpful for investigating + "notes", + "notes_right", + "ppa_span_text", + "ppa_span_start", + "ppa_span_end", + "ppa_span_text_right", + "ppa_span_start_right", + "ppa_span_end_right", + "ref_span_text", + "ref_span_text_right", + ) + + +def merge_excerpt_files( + input_files: list[pathlib.Path], output_file: pathlib.Path +) -> pl.DataFrame: total_excerpts = 0 input_dfs = [] # load files and combine into a single excerpt dataframe - for input_file in args.input_files: + for input_file in input_files: try: input_dfs.append(load_excerpts_df(input_file)) except ValueError as err: @@ -262,10 +303,10 @@ def main(): total_excerpts = excerpts.height # use unique to drop exact duplicates excerpts = excerpts.unique() - initial_labeled_excerpts = excerpts.filter(pl.col("poem_id").is_not_null()).height + initial_labeled_excerpts = excerpts.filter(c.poem_id.is_not_null()).height # output summary information about input data print( - f"Loaded {total_excerpts:,} excerpts from {len(args.input_files)} files ({excerpts.height:,} unique; {initial_labeled_excerpts:,} labeled)." + f"Loaded {total_excerpts:,} excerpts from {len(input_files)} files ({excerpts.height:,} unique; {initial_labeled_excerpts:,} labeled)." ) # merge labeled + unlabeled excerpts AND duplicate labeled excerpts @@ -279,15 +320,14 @@ def main(): # convert list fields for output to csv and reporting excerpts = excerpts.with_columns( - detection_methods=pl.col("detection_methods") - .list.sort() - .list.join(MULTIVAL_DELIMITER), - identification_methods=pl.col("identification_methods") - .list.sort() - .list.join(MULTIVAL_DELIMITER), + detection_methods=c.detection_methods.list.sort().list.join(MULTIVAL_DELIMITER), + identification_methods=c.identification_methods.list.sort().list.join( + MULTIVAL_DELIMITER + ), + alt_poem_ids=c.alt_poem_ids.list.join(MULTIVAL_DELIMITER), ) - labeled_excerpts = excerpts.filter(pl.col("poem_id").is_not_null()) + labeled_excerpts = excerpts.filter(c.poem_id.is_not_null()) # summary information about the content and what as done print( @@ -304,7 +344,56 @@ def main(): # row is a tuple of value, count print(f"\t{row[0]}: {row[1]:,}") - excerpts.write_csv(args.output) + # polars supports compression; but not sure what version it + # was added in, and documentation says it is unstable. Use that in future + excerpts.write_csv(output_file) + # return excerpt data frame + return excerpts + + +def main(): + parser = argparse.ArgumentParser( + description="Merge excerpts with labeled excerpts or notes" + ) + parser.add_argument( + "-o", + "--output", + help="Output filename for merged excerpts (CSV)", + type=pathlib.Path, + required=True, + ) + parser.add_argument( + "input_files", + nargs="+", + help="Two or more input files with excerpt or labeled excerpt data", + type=pathlib.Path, + ) + + args = parser.parse_args() + # output file should not exist + if args.output.exists(): + print( + f"Error: output file {args.output} already exists, not overwriting", + file=sys.stderr, + ) + sys.exit(-1) + # we need at least two input files + if len(args.input_files) < 2: + print( + "Error: at least two input files are required for merging", file=sys.stderr + ) + sys.exit(-1) + + # make sure input files exist + non_existent_input = [f for f in args.input_files if not f.exists()] + if non_existent_input: + print( + f"Error: input files not found: {', '.join([str(f) for f in non_existent_input])}", + file=sys.stderr, + ) + sys.exit(-1) + + merge_excerpt_files(args.input_files, args.output) if __name__ == "__main__": diff --git a/src/corppa/poetry_detection/passim/get_passim_results.py b/src/corppa/poetry_detection/passim/get_passim_results.py index 477d590a..0c5e8ba2 100644 --- a/src/corppa/poetry_detection/passim/get_passim_results.py +++ b/src/corppa/poetry_detection/passim/get_passim_results.py @@ -98,7 +98,7 @@ def extract_passim_spans( disable_progress: bool = False, ) -> Generator[dict[str, Any]]: """ - Exctracts all span-level matches identified by passim returned as a generator + Extracts all span-level matches identified by passim returned as a generator """ align_dir = passim_dir.joinpath("align.json") if not align_dir.is_dir(): diff --git a/src/corppa/poetry_detection/passim/run_passim.py b/src/corppa/poetry_detection/passim/run_passim.py index 01cb7a73..4be4e652 100644 --- a/src/corppa/poetry_detection/passim/run_passim.py +++ b/src/corppa/poetry_detection/passim/run_passim.py @@ -18,6 +18,7 @@ import os import re import sys +from collections import namedtuple from collections.abc import Iterable from pathlib import Path from subprocess import CalledProcessError, run @@ -66,15 +67,27 @@ def build_input_string(ppa_corpus: Path, ref_corpora: Iterable[Path]) -> str: return f"{{{','.join(map(str, corpus_files))}}}" +PassimOptions = namedtuple( + "PassimOptions", ["ngram_size", "min_match", "min_align", "gap", "max_df"] +) +#: default options for running passim on PPA for poetry detection +PASSIM_DEFAULTS = PassimOptions( + ngram_size=15, min_match=5, min_align=25, gap=300, max_df=10_000 +) +# NOTE: these differ from the current passim defaults, which are: +# ngram size 25, min align 50, gap 600, max df 100 +# min_match 5 is passim default; included to be explicit + + def run_passim( ppa_corpus: Path, ref_corpora: Iterable[Path], output_dir: Path, - max_df: int = 100, - min_match: int = 5, - ngram_size: int = 25, - gap: int = 600, - min_align: int = 50, + max_df: int = PASSIM_DEFAULTS.max_df, + min_match: int = PASSIM_DEFAULTS.min_match, + ngram_size: int = PASSIM_DEFAULTS.ngram_size, + gap: int = PASSIM_DEFAULTS.gap, + min_align: int = PASSIM_DEFAULTS.min_align, floating_ngrams: bool = False, verbose: bool = False, ) -> bool: @@ -129,7 +142,11 @@ def run_passim( return True -def main(): +def main(cli_args=None): + """ + Takes an optional list of arguments to parse (list of strings); if not + specified, uses `argparse` default of `sys.argv.` + """ parser = argparse.ArgumentParser("Run passim to identify poetry excerpts") # Required arguments parser.add_argument( @@ -147,7 +164,7 @@ def main(): ) parser.add_argument( "--output-dir", - help="Pathnname to the top-level output directory where results will be written", + help="Path to the top-level output directory where results will be written", type=Path, required=True, ) @@ -156,19 +173,19 @@ def main(): "--max-df", help="Passim parameter (maxDF): upper limit on document frequency", type=int, - default=10000, + default=PASSIM_DEFAULTS.max_df, # 10k ) parser.add_argument( "--min-match", help="Passim parameter (min-match): minimum number of n-gram matches between documents", type=int, - default=5, + default=PASSIM_DEFAULTS.min_match, # 5, ) parser.add_argument( "--ngram-size", help="Passim parameter (n): n-gram order", type=int, - default=15, + default=PASSIM_DEFAULTS.ngram_size, # 15 ) parser.add_argument( "--floating-ngrams", @@ -179,17 +196,17 @@ def main(): "--gap", help="Passim parameter (gap): minimum size of gap that separates passage", type=int, - default=300, + default=PASSIM_DEFAULTS.gap, # 300 ) parser.add_argument( "--min-align", - help="Passim paramaeter (min-align): minimum length of alignment", + help="Passim parameter (min-align): minimum length of alignment", type=int, - default=25, + default=PASSIM_DEFAULTS.min_align, # 25 ) parser.add_argument("-v", "--verbose", action="store_true") - args = parser.parse_args() + args = parser.parse_args(args=cli_args) # Validate paths if not args.ppa_corpus.is_file(): diff --git a/src/corppa/poetry_detection/polars_utils.py b/src/corppa/poetry_detection/polars_utils.py index 3af05825..80cd16e3 100644 --- a/src/corppa/poetry_detection/polars_utils.py +++ b/src/corppa/poetry_detection/polars_utils.py @@ -1,15 +1,20 @@ -# Copyright (c) 2024-2025, Center for Digital Humanities, Princeton University +# Copyright (c) 2024-2026, Center for Digital Humanities, Princeton University # SPDX-License-Identifier: Apache-2.0 """ Polars methods for working with excerpt data """ +import logging import pathlib import polars as pl from corppa.poetry_detection.core import MULTIVAL_DELIMITER, Excerpt, LabeledExcerpt +from corppa.poetry_detection.ppa_works import add_ppa_works_meta + +logger = logging.getLogger(__name__) + #: List of required fields for excerpt data REQ_EXCERPT_FIELDS = set(Excerpt.fieldnames(required_only=True)) @@ -25,23 +30,18 @@ # override set types with list, since Polars does not have a set type FIELD_TYPES["detection_methods"] = pl.List FIELD_TYPES["identification_methods"] = pl.List +FIELD_TYPES["alt_poem_ids"] = pl.List -#: Table of included PPA work-level field names and their names for use downstream -PPA_FIELDS = { - "work_id": "ppa_work_id", - "source_id": "ppa_source_id", - "cluster_id": "ppa_cluster_id", - "title": "ppa_work_title", - "author": "ppa_work_author", - "pub_year": "ppa_work_year", - "source": "ppa_source", - "collections": "ppa_collections", -} #: Table of included reference poem field names and their names for use downstream POEM_FIELDS = { "poem_id": "poem_id", "author": "poem_author", "title": "poem_title", + "ref_corpus": "ref_corpus", + "num_lines": "poem_num_lines", + "num_words": "poem_num_words", + "char_len": "poem_char_len", + "cluster_id": "poem_cluster_id", } @@ -101,22 +101,10 @@ def standardize_dataframe(df: pl.DataFrame) -> pl.DataFrame: return df.select(LABELED_EXCERPT_FIELDS) -def extract_page_meta(excerpts_df: pl.DataFrame) -> pl.DataFrame: - """ - Extracts PPA page metadata (i.e., PPA work ID and page number) from each excerpt's - ``page_id`` and combines it with the input excerpts ``DataFrame``. - """ - out_df = excerpts_df.with_columns( - ppa_work_id=pl.col("page_id").str.extract(r"^(.*)\.\d+$", 1), - page_num=pl.col("page_id").str.extract(r"(\d+)$").cast(pl.Int64), - ) - return out_df - - def load_meta_df(file: pathlib.Path, fields_table: dict[str, str]) -> pl.DataFrame: """ Loads specified metadata file (``CSV``) as a polars DataFrame. The columns of the - resulting DataFrame are dictacted by the fields_table whose keys specify the + resulting DataFrame are dictated by the fields_table whose keys specify the metadata fields to be selected and whose values indicate what they should be renamed to. """ @@ -139,33 +127,21 @@ def load_meta_df(file: pathlib.Path, fields_table: dict[str, str]) -> pl.DataFra return df -def add_ppa_works_meta( - excerpts_df: pl.DataFrame, - ppa_works_csv: pathlib.Path, -) -> pl.DataFrame: - """ - Combines found poem excerpt data (:class:`polars.DataFrame`) with PPA - work-level metadata (``CSV``) and returns the resulting ``DataFrame``. - """ - # Check for ppa_work_id field - if "ppa_work_id" not in excerpts_df.columns: - raise ValueError( - "Missing ppa_work_id field; use extract_page_meta to extract it." - ) - ppa_works_meta = load_meta_df(ppa_works_csv, PPA_FIELDS) - return excerpts_df.join(ppa_works_meta, on="ppa_work_id", how="left") - - def add_ref_poems_meta( excerpts_df: pl.DataFrame, ref_poem_meta: pathlib.Path, + poem_id_field: str = "poem_id", + ref_corpus_field: str = "ref_corpus", + suffix: str | None = None, ) -> pl.DataFrame: """ Combines found poem excerpt data (:class:`polars.DataFrame`) with reference poem metadata (``CSV``, possibly compressed) and returns the resulting - ``DataFrame``. + ``DataFrame``. To join on alternate poem id or reference corpus fields + in the excerpt data (e.g., on `alt_poem_ids`), specify the field names, + and optionally specify a custom suffix when combining multiple poems. """ - join_fields = ["poem_id", "ref_corpus"] + join_fields = [poem_id_field, ref_corpus_field] # Check for required fields missing_fields = set(join_fields) - set(excerpts_df.columns) if missing_fields: @@ -174,7 +150,16 @@ def add_ref_poems_meta( f"Input DataFrame missing the following required fields: {missing_str}" ) poems_meta_df = load_meta_df(ref_poem_meta, POEM_FIELDS) - return excerpts_df.join(poems_meta_df, on=join_fields, how="left") + optional_args = {} + if suffix is not None: + optional_args["suffix"] = suffix + return excerpts_df.join( + poems_meta_df, + left_on=join_fields, + right_on=["poem_id", "ref_corpus"], + how="left", + **optional_args, # type: ignore[arg-type] + ) def load_excerpts_df( @@ -189,7 +174,7 @@ def load_excerpts_df( Optionally, combine PPA work-level and reference poem metadata to the returned DataFrame. - Currently, assume input file is a (possible compresed) `CSV` file. + Currently, assume input file is a (possibly compressed) `CSV` file. """ # Load input file as a polars dataframe df = pl.read_csv(excerpts_file) diff --git a/src/corppa/poetry_detection/ppa_works.py b/src/corppa/poetry_detection/ppa_works.py new file mode 100644 index 00000000..e86918e7 --- /dev/null +++ b/src/corppa/poetry_detection/ppa_works.py @@ -0,0 +1,57 @@ +""" +PPA work-level metadata utilities +""" + +import pathlib + +import polars as pl + + +def extract_page_meta(excerpts_df: pl.DataFrame) -> pl.DataFrame: + """ + Extracts PPA page metadata (i.e., PPA work ID and page number) from each excerpt's + ``page_id`` and combines it with the input excerpts ``DataFrame``. + """ + out_df = excerpts_df.with_columns( + ppa_work_id=pl.col("page_id").str.extract(r"^(.*)\.\d+$", 1), + page_num=pl.col("page_id").str.extract(r"(\d+)$").cast(pl.Int64), + ) + return out_df + + +def load_ppa_works_df(file: pathlib.Path) -> pl.DataFrame: + """ + Loads PPA work-level metadata (``CSV``) as a polars DataFrame; + must include `work_id` field. + """ + # Check that file exists + if not file.is_file(): + raise ValueError(f"Input file {file} does not exist") + # Load in CSV + ppa_works_df = pl.read_csv(file) + # We could check for expected fields, but the only + # field *required* for joining with excerpts is work_id + if "work_id" not in ppa_works_df.columns: + raise ValueError("Input CSV is missing required `work_id` field") + + # split delimited collection field to convert to list + ppa_works_df = ppa_works_df.with_columns( + collections=pl.col("collections").str.split(";") + ) + # Rename all fields to prefix with ppa_ + return ppa_works_df.rename(lambda column_name: f"ppa_{column_name}") + + +def add_ppa_works_meta( + excerpts_df: pl.DataFrame, + ppa_works_csv: pathlib.Path, +) -> pl.DataFrame: + """ + Combine found poem excerpt data (:class:`polars.DataFrame`) with PPA + work-level metadata (``CSV``) and returns the resulting ``DataFrame``. + """ + # Check for ppa_work_id field; if not present, extract it + if "ppa_work_id" not in excerpts_df.columns: + excerpts_df = extract_page_meta(excerpts_df) + ppa_works_meta = load_ppa_works_df(ppa_works_csv) + return excerpts_df.join(ppa_works_meta, on="ppa_work_id", how="left") diff --git a/src/corppa/poetry_detection/ref_corpora.py b/src/corppa/poetry_detection/ref_corpora.py new file mode 100644 index 00000000..cb3da029 --- /dev/null +++ b/src/corppa/poetry_detection/ref_corpora.py @@ -0,0 +1,334 @@ +import logging +import pathlib +from collections.abc import Generator +from typing import Optional + +import polars as pl + +from corppa.config import CorpusConfig, get_config +from corppa.utils.build_text_corpus import build_text_corpus, text_corpus_from_tarfile + +logger = logging.getLogger(__name__) + + +#: schema for reference corpora metadata :class:`pl.DataFrame` +METADATA_SCHEMA = { + "poem_id": pl.String, + "author": pl.String, + "title": pl.String, + "ref_corpus": pl.String, + "num_lines": pl.Int64, + "num_words": pl.Int64, + "char_len": pl.Int64, +} + + +class BaseReferenceCorpus: + """ + Base class for reference poetry corpora, with corpus identifier and + methods to access metadata and text content. + """ + + corpus_id: str + corpus_name: str + config: CorpusConfig + + @staticmethod + def calculate_poem_length(text: str) -> dict[str, int]: + """Calculate poem length metrics from text content. Takes the + text of the poem and returns a dictionary num_lines (non-blank lines), + num_words, and char_len. + """ + return { + "num_lines": len([line for line in text.splitlines() if line.strip()]), + "num_words": len(text.split()), + "char_len": len(text), + } + + def get_metadata_df(self, poem_length=False) -> pl.DataFrame: + """Minimal common poetry metadata for use across reference corpora. + Should return a :class:`pl.DataFrame` with poem_id, author, title, and + ref_corpus for each poem in this corpus. Optionally, should + return information about poem length (number of characters and lines + in the text).""" + raise NotImplementedError + + def get_text_corpus(self) -> Generator[dict[str, str]]: + """Minimal text record for reference corpora. + Should yield a dictionary with id and text for each poem in this + corpus.""" + raise NotImplementedError + + +class LocalTextCorpus(BaseReferenceCorpus): + """Base class for reference corpus where text content is + provided as a set of text files in a directory or tar.gz. + On initialization, configures data path based on + configured base dir and corpus default or any overrides, and validates + that the path exists and is a directory. + Provides :meth:`get_text_corpus` for generating text corpus from + the file system.""" + + def __init__(self, config_opts: CorpusConfig): + # get text directory for this reference corpus from corpus configuration + self.config = config_opts + # validate config file; for text-only corpus, metadata is optional + self.config.validate(metadata=False) + + def get_text_corpus( + self, disable_progress: bool = True + ) -> Generator[dict[str, str]]: + # validation is now handled by CorpusConfig.validate + if self.config.text_path.is_dir(): + corpus_method = build_text_corpus + elif self.config.text_path.name.endswith(".tar.gz"): + corpus_method = text_corpus_from_tarfile + + # build_text_corpus method returns id, so rename id to poem_id + yield from ( + {"poem_id": p["id"], "text": p["text"]} + for p in corpus_method( + self.config.text_path, disable_progress=disable_progress + ) + ) + + +class InternetPoems(LocalTextCorpus): + """Curated corpus of poems with plain text content sourced from + the internet, for high priority sources known to occur in excerpts, + including full text of Shakespeare's plays. Metadata was originally based on + filename (naming convention of `Firstname-Lastname_Poem-Title.txt`), + but has since been converted to a CSV file for correction and augmentation. + The text filename without extension is used as the `poem_id`. + """ + + #: id for this reference corpus: internet_poems + corpus_id: str = "internet_poems" + corpus_name: str = "Internet Poems" + # inherits config with text_path + + # no init/validation needed beyond that provided by LocalTextCorpus + + def get_metadata_df(self, poem_length=False) -> pl.DataFrame: + if ( + self.config.metadata_path is not None + and self.config.metadata_path.is_file() + ): + # load metadata and add reference corpus id + df = pl.read_csv( + self.config.metadata_path, schema_overrides=METADATA_SCHEMA + ).with_columns(ref_corpus=pl.lit(self.corpus_id)) + # if poem length is requested, get from the files and add to df + if poem_length: + length_df = self.get_metadata_from_files(poem_length=poem_length) + df = df.join( + length_df.select("poem_id", "num_lines", "num_words", "char_len"), + on="poem_id", + ) + else: + # fallback metadata: generate from filenames + df = self.get_metadata_from_files(poem_length=poem_length) + + return df + + def get_metadata_from_files(self, poem_length=False) -> pl.DataFrame: + metadata = [] + # returns a generator of dicts with id and text string + # NOTE: when called from compile script, might be nice to show progress bar + for poem in self.get_text_corpus(): + # filename format: + # Firstname-Lastname_Poem-Title.txt + # Replace - with spaces and split on - to separate author/title + author, title = poem["poem_id"].replace("-", " ").split("_", 1) + poem_metadata: dict[str, str | int] = { + "poem_id": poem["poem_id"], + "author": author, + "title": title, + "ref_corpus": self.corpus_id, + } + if poem_length: + poem_metadata.update(self.calculate_poem_length(poem["text"])) + + metadata.append(poem_metadata) + + return pl.from_dicts(metadata, schema=METADATA_SCHEMA) + + +class ChadwyckHealey(LocalTextCorpus): + """Reference corpus based on a filtered subset of Chadwyck-Healey + poetry collection. Requires a directory of plain text files and a + metadata csv file. Uses Chadwyck-Healey identifiers for `poem_id`. + """ + + #: id for this reference corpus: chadwyck-healey + corpus_id: str = "chadwyck-healey" + corpus_name: str = "Chadwyck-Healey" + # inherits config with text_path & metadata path + + def get_metadata_df(self, poem_length=False) -> pl.DataFrame: + # disable schema inference; the fields we care about are all strings + df = ( + pl.read_csv(self.config.metadata_path, infer_schema=False) + # rename fields + .rename({"title_main": "title", "id": "poem_id"}) + # construct author name from separate fields in the metadata + .with_columns( + author=pl.concat_str( + [pl.col("author_firstname"), pl.col("author_lastname")], + separator=" ", + ), + # set corpus id + ref_corpus=pl.lit(self.corpus_id), + ) + .select(["poem_id", "author", "title", "ref_corpus"]) + ) + + if poem_length: + poem_lengths = [] + # text corpus returns a generator of dicts with id and text string + # NOTE: when called from compile script, might be nice to show progress bar + for poem in self.get_text_corpus(): + poem_lengths.append( + { + "poem_id": poem["poem_id"], + **self.calculate_poem_length(poem["text"]), + } + ) + if poem_lengths: + poem_length_df = pl.from_dicts(poem_lengths) + df = df.join(poem_length_df, on="poem_id") + else: + logger.warning( + "Poem length requested but none calculated (no text files found?)" + ) + + return df + + +class OtherPoems(BaseReferenceCorpus): + """A metadata-only reference corpus with metadata for poems that have + been identified but for which we do not have full text. + Poem identifiers are constructed from author and title using the same + convention as :class:`InternetPoems`. + + Does not provide an implementation for :meth:`get_text_corpus`. + """ + + #: id for this reference corpus (currently "other") + corpus_id: str = "other" + corpus_name: str = "Other Poems" + #: config with metadata_path for URL or local path to metadata + config: CorpusConfig + + def __init__(self, config_opts: CorpusConfig): + # get configuration for this corpus + self.config = config_opts + # validate configuration - metadata only + self.config.validate(text=False) + + def get_metadata_df(self, poem_length=False) -> pl.DataFrame: + # polars can load csv directly from a url + return pl.read_csv( + self.config.metadata_path, schema=METADATA_SCHEMA + ).with_columns(ref_corpus=pl.lit(self.corpus_id)) + + # this is a metadata-only corpus; get_text_corpus is intentionally not implemented + + +def all_corpora() -> list[BaseReferenceCorpus]: + """Convenience access to all reference corpora, for generating + compiled versions of reference data.""" + config = get_config() + return [ + InternetPoems(config.reference_corpora["internet_poems"]), + ChadwyckHealey(config.reference_corpora["chadwyck-healey"]), + OtherPoems(config.reference_corpora["other_poems"]), + ] + + +def fulltext_corpora() -> list[BaseReferenceCorpus]: + """Convenience access to all full-text reference corpora, for generating + compiled metadata and text.""" + config = get_config() + return [ + InternetPoems(config.reference_corpora["internet_poems"]), + ChadwyckHealey(config.reference_corpora["chadwyck-healey"]), + ] + + +def compile_metadata_df(poem_length=False) -> pl.DataFrame: + """Compile poetry metadata from all reference corpora into a single + polars DataFrame with reference corpus ids.""" + # Combine poem metadata from all reference corpora + + # use a diagonal concat instead of vstack/extend + ref_corpora_dfs = [ + ref_corpus.get_metadata_df(poem_length=poem_length) + for ref_corpus in all_corpora() + ] + return pl.concat(ref_corpora_dfs, how="diagonal") + + +def save_poem_metadata( + output_file: pathlib.Path, + excerpts_df: Optional[pl.DataFrame] = None, + poem_clusters_df: Optional[pl.DataFrame] = None, +): + """Generate and save compiled poetry metadata as a data file in the + poem dataset. Loads and compiles metadata for all reference corpora, + including poem length calculations (:meth:`compile_metadata_df`) + and saves the result to the specified `output_file`. When the optional + `excerpts_df` is present, calculates work-level excerpt total for poems + based on primary poem id (number of excerpts, number of PPA works, + number of PPA pages). When the optional `poem_clusters_df` is provided, + adds a `cluster_id` field to poems known to be duplicates, near-duplicates + or subsets. + """ + # check & report if the file already exists + output_verb = "Creating" + if output_file.exists(): + output_verb = "Replacing" + print(f"{output_verb} {output_file}") + + df = compile_metadata_df(poem_length=True) + ref_corpus_names = { + ref_corpus.corpus_id: ref_corpus.corpus_name for ref_corpus in all_corpora() + } + + total_by_corpus = df["ref_corpus"].value_counts() + totals = [] + for value, count in total_by_corpus.iter_rows(): + # row is a tuple of value, count; convert reference corpus id to name + totals.append(f"{ref_corpus_names[value]}: {count:,}") + + # when excerpt data is present, calculate & include aggregate totals + if excerpts_df is not None: + # get work-level aggregate excerpt totals + # (only includes primary poem ids, not alt poem ids) + excerpt_totals_df = excerpts_df.group_by("poem_id").agg( + pl.col("excerpt_id").n_unique().alias("num_excerpts"), + pl.col("ppa_work_id").n_unique().alias("num_ppa_works"), + pl.col("page_id").n_unique().alias("num_ppa_pages"), + # number of unique ppa authors would be nice, but requires joining ppa metadata + ) + # combine the totals with poem metadata + df = df.join(excerpt_totals_df, on="poem_id", how="left").with_columns( + # fill any missing values with zeroes + pl.col("num_excerpts").fill_null(pl.lit(0)), + pl.col("num_ppa_works").fill_null(pl.lit(0)), + pl.col("num_ppa_pages").fill_null(pl.lit(0)), + ) + if poem_clusters_df is not None: + df = df.join( + poem_clusters_df.select("poem_id", "cluster_id"), on="poem_id", how="left" + ) + # report on cluster ids and number of unique clusters (don't include nulls) + df_with_cluster_ids = df.filter(pl.col.cluster_id.is_not_null()) + n_with_cluster_ids = df_with_cluster_ids.height + n_uniq_clusters = df_with_cluster_ids["cluster_id"].n_unique() + print( + f"{n_with_cluster_ids:,} poems with cluster ids ({n_uniq_clusters:,} unique cluster{'s' if n_uniq_clusters != 1 else ''})" + ) + + print(f"{df.height:,} poem metadata entries ({'; '.join(totals)})") + df.write_csv(output_file, include_bom=True) diff --git a/src/corppa/poetry_detection/refmatcha.py b/src/corppa/poetry_detection/refmatcha.py index 23f39488..0a607425 100755 --- a/src/corppa/poetry_detection/refmatcha.py +++ b/src/corppa/poetry_detection/refmatcha.py @@ -1,5 +1,5 @@ #!/usr/bin/env python -# Copyright (c) 2024-2025, Center for Digital Humanities, Princeton University +# Copyright (c) 2024-2026, Center for Digital Humanities, Princeton University # SPDX-License-Identifier: Apache-2.0 """ @@ -36,12 +36,7 @@ import re import sys from glob import iglob - -try: - from itertools import batched -except ImportError: - from more_itertools import batched # type: ignore[no-redef] - +from itertools import batched from time import perf_counter import polars as pl diff --git a/src/corppa/utils/build_text_corpus.py b/src/corppa/utils/build_text_corpus.py index 8ae288b9..4c56effa 100644 --- a/src/corppa/utils/build_text_corpus.py +++ b/src/corppa/utils/build_text_corpus.py @@ -26,6 +26,7 @@ import argparse import sys +import tarfile from pathlib import Path import orjsonl @@ -44,13 +45,13 @@ def get_text_record(text_file: Path) -> dict[str, str]: def build_text_corpus( - input_dir: Path, disable_progress: bool = False + input_path: Path, disable_progress: bool = False ) -> dict[str, str]: """ Generates text records for each text file within input directory """ progress_bar = tqdm( - input_dir.glob("**/*.txt"), + input_path.glob("**/*.txt"), bar_format="Read {n:,} pages{postfix} | elapsed: {elapsed}", disable=disable_progress, ) @@ -58,6 +59,34 @@ def build_text_corpus( yield get_text_record(text_file) +def text_corpus_from_tarfile( + input_path: Path, disable_progress: bool = False +) -> dict[str, str]: + """ + Generate text records for each text file within a tar.gz archive + """ + # NOTE: could make compression optional, currently assumes gz + # NOTE: currently does not support progressbar + with tarfile.open(str(input_path), "r:gz") as tar_archive: + for member in tqdm( + tar_archive.getmembers(), + bar_format="Read {n:,} files{postfix} | elapsed: {elapsed}", + disable=disable_progress, + ): + # skip any OSX metadata files included in the archive + if "._" in member.name: + continue + + # read contents of text files and yield filename and contents + if member.name.endswith(".txt"): + txtfile = tar_archive.extractfile(member) + if txtfile is not None: + yield { + "id": Path(member.name).stem, + "text": txtfile.read().decode("utf-8"), + } + + def save_text_corpus( input_dir: Path, output_file=Path, diff --git a/src/corppa/utils/path_utils.py b/src/corppa/utils/path_utils.py index daf0124f..3a999b11 100644 --- a/src/corppa/utils/path_utils.py +++ b/src/corppa/utils/path_utils.py @@ -5,11 +5,9 @@ General-purpose methods for working with paths, PPA identifiers, and directories """ -import os from collections.abc import Iterable, Iterator from pathlib import Path - _htid_encode_map = {":": "+", "/": "=", ".": ","} _htid_encode_table = str.maketrans(_htid_encode_map) _htid_decode_map = {v: k for k, v in _htid_encode_map.items()} @@ -190,25 +188,18 @@ def find_relative_paths( # Create lowercase extension set from passed in exts ext_set = {ext.lower() for ext in exts} - # Using pathlib.Path.walk / os.walk over glob because (1) it allows us to + # Using pathlib.Path.walk over glob because (1) it allows us to # find files with multiple extensions in a single walk of the directory # and (2) lets us leverage additional functionality of pathlib. - walk_generator: Iterator[tuple[str | Path, list[str], list[str]]] # for mypy - if hasattr(base_dir, "walk"): - # As of Python 3.12, Path.walk exists - walk_generator = base_dir.walk(follow_symlinks=follow_symlinks) - else: - # For Python 3.11, fall back to os.walk - walk_generator = os.walk(base_dir, followlinks=follow_symlinks) + walk_generator: Iterator[tuple[Path, list[str], list[str]]] # for mypy + # As of Python 3.12, Path.walk exists + walk_generator = base_dir.walk(follow_symlinks=follow_symlinks) for dirpath, dirnames, filenames in walk_generator: - if isinstance(dirpath, str): - # Convert str produced by os.walk to Path object - dirpath = Path(dirpath) # Create a generator of relevant files in the current directory include_files = ( dirpath.joinpath(file).relative_to(base_dir) for file in filenames - if os.path.splitext(file)[1].lower() in ext_set + if Path(file).suffix.lower() in ext_set ) # if group by dir is specified, yield dirpath and list of files, # but only if at least one relevant file is found diff --git a/test/test_config.py b/test/test_config.py deleted file mode 100644 index 2da3737b..00000000 --- a/test/test_config.py +++ /dev/null @@ -1,46 +0,0 @@ -# Copyright (c) 2024-2025, Center for Digital Humanities, Princeton University -# SPDX-License-Identifier: Apache-2.0 - -from unittest.mock import patch - -import pytest - -from corppa import config - - -def test_get_config_not_found(tmp_path): - test_config = tmp_path / "test.cfg" - # error should include directions about how to fix the problem - expected_error_msg = ( - "Config file not found.\n" - + f"Copy .*{config.SAMPLE_CONFIG_PATH.name} to .*{test_config.name} and configure for your environment." - ) - with patch.object(config, "CORPPA_CONFIG_PATH", new=test_config): - with pytest.raises(SystemExit, match=expected_error_msg): - config.get_config() - - -def test_get_config_parse_error(tmp_path): - test_config = tmp_path / "test.cfg" - # config in non-yaml format - test_config.write_text("""[poem_dataset] -data_dir=/tmp/p-p-poems/data -""") - with patch.object(config, "CORPPA_CONFIG_PATH", new=test_config): - with pytest.raises(SystemExit, match="Error parsing config file"): - config.get_config() - - -def test_get_config(tmp_path): - # create a test config file with one section and one value - test_config = tmp_path / "test.cfg" - test_config.write_text(""" -# local path to compiled poem dataset files -poem_dataset: - data_dir: "/tmp/p-p-poems/data" -""") - # use patch to override the config path and load our test file - with patch.object(config, "CORPPA_CONFIG_PATH", new=test_config): - config_opts = config.get_config() - assert "poem_dataset" in config_opts - assert config_opts["poem_dataset"]["data_dir"] == "/tmp/p-p-poems/data" diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 00000000..7af72400 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,279 @@ +# Copyright (c) 2024-2026, Center for Digital Humanities, Princeton University +# SPDX-License-Identifier: Apache-2.0 + +from pathlib import Path +from unittest.mock import patch + +import pytest + +from corppa import config + + +def test_get_config_not_found(tmp_path): + test_config = tmp_path / "test.cfg" + # error should include directions about how to fix the problem + expected_error_msg = ( + "Config file not found.\n" + + f"Copy .*{config.SAMPLE_CONFIG_PATH.name} to .*{test_config.name} and configure for your environment." + ) + with patch.object(config, "CORPPA_CONFIG_PATH", new=test_config): + with pytest.raises(SystemExit, match=expected_error_msg): + config.get_config() + + +def test_get_config_parse_error(tmp_path): + test_config = tmp_path / "test.cfg" + # config in non-yaml format + test_config.write_text("""[poem_dataset] +data_dir=/tmp/p-p-poems/data +""") + with patch.object(config, "CORPPA_CONFIG_PATH", new=test_config): + with pytest.raises(SystemExit, match="Error parsing config file"): + config.get_config() + + +def test_get_config_missing_required(tmp_path): + test_config = tmp_path / "test.cfg" + test_config.write_text("foo: bar") + with patch.object(config, "CORPPA_CONFIG_PATH", new=test_config): + with pytest.raises( + SystemExit, match="missing required configuration: base_dir" + ): + config.get_config() + + # if first required field is present, should error on the next one + test_config.write_text("base_dir: /tmp/data/") + with pytest.raises( + SystemExit, match="missing required configuration: compiled_dataset_dir" + ): + config.get_config() + + +def test_get_config(tmp_path): + # create a test config file with one section and one value + test_config = tmp_path / "test.cfg" + test_config.write_text(""" +base_dir: data/ +# local path to compiled poem dataset files +compiled_dataset_dir: "/tmp/p-p-poems/data" +""") + # use patch to override the config path and load our test file + with patch.object(config, "CORPPA_CONFIG_PATH", new=test_config): + config_opts = config.get_config() + assert isinstance(config_opts, config.ConfigOpts) + assert config_opts.base_dir == Path("data") + assert config_opts.compiled_dataset_dir == Path("/tmp/p-p-poems/data") + # other items are unset + assert config_opts.ppa_corpus is None + assert config_opts.reference_corpora == {} + + +def test_get_config_defaults(tmp_path): + # create a test config file with one section and one value + test_config = tmp_path / "test.cfg" + # override one portion of a nested config + override_text_dir = "/ch/text.tar.gz" + test_config.write_text(f""" +base_dir: data/ +compiled_dataset_dir: found-poems/ +# local path to compiled poem dataset files +reference_corpora: + chadwyck-healey: + text_path: "{override_text_dir}" +""") + # use patch to override the config path and load our test file + with patch.object(config, "CORPPA_CONFIG_PATH", new=test_config): + config_opts = config.get_config() + assert len(config_opts.reference_corpora) == 1 + ch_config = config_opts.reference_corpora["chadwyck-healey"] + assert ch_config.text_path == Path(override_text_dir) + + +def test_get_config_maximal(tmp_path): + # create and check a test config file with all possible values + test_config = tmp_path / "test.cfg" + poem_cluster_url = "http://example.com/poem_groups.csv" + test_config.write_text(f""" +base_dir: data/ +compiled_dataset_dir: found-poems/ +excerpt_data_dir: excerpt-data/ +poem_clusters_path: {poem_cluster_url} +ppa_corpus: + base_dir: ppa-corpus +reference_corpora: + base_dir: refs + chadwyck-healey: + internet_poems: + other_poems: +""") + # use patch to override the config path and load our test file + with patch.object(config, "CORPPA_CONFIG_PATH", new=test_config): + config_opts = config.get_config() + # compiled dataset dir should not be resolved relative to base dir + assert not config_opts.compiled_dataset_dir.is_relative_to(config_opts.base_dir) + assert config_opts.poem_clusters_path == "http://example.com/poem_groups.csv" + assert config_opts.excerpt_data_dir == Path("data/excerpt-data") + assert config_opts.poem_clusters_path == poem_cluster_url + assert len(config_opts.reference_corpora) == 3 + ref_corpora_names = [ + "chadwyck-healey", + "internet_poems", + "other_poems", + ] + assert list(config_opts.reference_corpora.keys()) == ref_corpora_names + ref_corpus_configs = config_opts.reference_corpora.values() + assert [rc.name for rc in ref_corpus_configs] == ref_corpora_names + # all ref-corpus base directories should be relative to base dir + assert all( + rc.base_dir.is_relative_to(config_opts.base_dir) + for rc in ref_corpus_configs + ) + # should be relative to top-level ref corpus dir + ref_corpus_dir = config_opts.base_dir / "refs" + assert all( + rc.base_dir.is_relative_to(ref_corpus_dir) for rc in ref_corpus_configs + ) + # ppa dir relative to top-level base_dir + assert config_opts.ppa_corpus.base_dir == config_opts.base_dir / "ppa-corpus" + + +## test module-level helpers + + +class TestResolvePath: + base = Path("/data/corpora") + + def test_none_returns_none(self): + assert config.resolve_path(None, self.base) is None + + def test_string_converted_to_path(self): + result = config.resolve_path("subdir/file.txt", self.base) + assert isinstance(result, Path) + assert result == self.base / "subdir/file.txt" + + def test_absolute_path_unchanged(self): + abs_path = Path("/other/location/file.csv") + assert config.resolve_path(abs_path, self.base) == abs_path + + def test_relative_path_prefixed_with_base(self): + result = config.resolve_path(Path("corpus/texts.tar.gz"), self.base) + assert result == self.base / "corpus/texts.tar.gz" + + def test_already_relative_to_base_unchanged(self): + already_relative = self.base / "corpus/texts.tar.gz" + assert config.resolve_path(already_relative, self.base) == already_relative + + +## test dataclass config objects + + +class TestCorpusConfig: + def test_init_defaults(self): + corpus_name = "internet_poems" + # specifying corpus name is enough to set defaults for the rest + ref_corpus = config.CorpusConfig(name=corpus_name) + assert ref_corpus.name == corpus_name + assert ref_corpus.base_dir == Path(corpus_name) + # default text file suffix + assert ( + ref_corpus.text_path == ref_corpus.base_dir / f"{corpus_name}_texts.tar.gz" + ) + # default metadata suffix + assert ref_corpus.metadata_path == ref_corpus.base_dir / f"{corpus_name}.csv" + + def test_init_override(self): + corpus_name = "other" + # specifying corpus name is enough to set defaults for the rest + ref_corpus = config.CorpusConfig( + name=corpus_name, + base_dir=Path("data/foo"), + text_path=Path("my_texts.tar.gz"), + metadata_path=Path("data.csv"), + ) + assert ref_corpus.name == corpus_name + assert ref_corpus.base_dir == Path("data/foo") + # provided paths are now relative to base dir + assert ref_corpus.text_path == ref_corpus.base_dir / "my_texts.tar.gz" + assert ref_corpus.metadata_path == ref_corpus.base_dir / "data.csv" + + # test absolute path is not changed + abs_path = Path("/path/to/my_texts.tar.gz") + ref_corpus = config.CorpusConfig(name=corpus_name, text_path=abs_path) + assert ref_corpus.text_path == abs_path + + def test_subclass(self): + base_dir = Path("ppa_corpus-2026-01-03") + ppa_corpus = config.PPACorpusConfig(base_dir=base_dir) + assert ppa_corpus.name == "ppa" + assert ppa_corpus.base_dir == base_dir + # default file names + assert ppa_corpus.text_path == base_dir / "ppa_pages.jsonl.gz" + assert ppa_corpus.metadata_path == base_dir / "ppa_metadata.csv" + + def test_validate(self, tmp_path): + # specifying corpus name is enough to set defaults for the rest + corpus_id = "text_corpus" + ref_corpus = config.CorpusConfig( + name=corpus_id, + base_dir=tmp_path / "foo", + ) + # neither text nor metadata exists + with pytest.raises(ValueError): + ref_corpus.validate() + + # text file only + ref_corpus.base_dir.mkdir() + ref_corpus.text_path.touch() + # text-only validation should pass without error + assert ref_corpus.validate(metadata=False) + # text and metadata should fail + with pytest.raises(ValueError): + ref_corpus.validate() + + # both text and metadata files present + ref_corpus.metadata_path.touch() + assert ref_corpus.validate() + + # metadata only + ref_corpus.text_path.unlink() + # metadata only should pass + assert ref_corpus.validate(text=False) + # text and metadata should fail + with pytest.raises(ValueError, match="does not exist"): + ref_corpus.validate() + + # wrong kind of text file + ref_corpus.text_path = ref_corpus.base_dir / "text_files.zip" + ref_corpus.text_path.touch() + with pytest.raises(ValueError): + ref_corpus.validate() + + # text path set to None (shouldn't happen normally) + ref_corpus.text_path = None + with pytest.raises( + ValueError, match=f"Configuration error: {corpus_id} text_path is not set" + ): + ref_corpus.validate() + + # metadata_path set to None (shouldn't happen normally) + ref_corpus.metadata_path = None + with pytest.raises( + ValueError, + match=f"Configuration error: {corpus_id} metadata_path is not set", + ): + ref_corpus.validate(text=False) + # similar for empty string + ref_corpus.metadata_path = "" + with pytest.raises( + ValueError, + match=f"Configuration error: {corpus_id} metadata_path is not set", + ): + ref_corpus.validate(text=False) + + def test_metadata_url(self): + metadata_url = "http://example.com/poetry/metadata.csv" + online_corpus = config.CorpusConfig(name="online", metadata_path=metadata_url) + # url should not be modified + assert online_corpus.metadata_path == metadata_url + # should not be considered invalid + assert online_corpus.validate(text=False) diff --git a/test/test_ocr/test_collate_text.py b/tests/test_ocr/test_collate_text.py similarity index 100% rename from test/test_ocr/test_collate_text.py rename to tests/test_ocr/test_collate_text.py diff --git a/test/test_ocr/test_gvision_ocr.py b/tests/test_ocr/test_gvision_ocr.py similarity index 97% rename from test/test_ocr/test_gvision_ocr.py rename to tests/test_ocr/test_gvision_ocr.py index 86d2da66..c48aed96 100644 --- a/test/test_ocr/test_gvision_ocr.py +++ b/tests/test_ocr/test_gvision_ocr.py @@ -76,4 +76,4 @@ def test_ocr_images( assert {"ocr_count": 2, "skip_count": 1} == reporting captured = capsys.readouterr() - assert "2 images OCR'd & 1 images skipped." in captured.err \ No newline at end of file + assert "2 images OCR'd & 1 images skipped." in captured.err diff --git a/test/test_poetry_detection/test_annotation/test_annotation_recipes.py b/tests/test_poetry_detection/test_annotation/test_annotation_recipes.py similarity index 100% rename from test/test_poetry_detection/test_annotation/test_annotation_recipes.py rename to tests/test_poetry_detection/test_annotation/test_annotation_recipes.py diff --git a/test/test_poetry_detection/test_annotation/test_process_adjudication_data.py b/tests/test_poetry_detection/test_annotation/test_process_adjudication_data.py similarity index 100% rename from test/test_poetry_detection/test_annotation/test_process_adjudication_data.py rename to tests/test_poetry_detection/test_annotation/test_process_adjudication_data.py diff --git a/test/test_poetry_detection/test_annotation/test_recipe.py b/tests/test_poetry_detection/test_annotation/test_recipe.py similarity index 100% rename from test/test_poetry_detection/test_annotation/test_recipe.py rename to tests/test_poetry_detection/test_annotation/test_recipe.py diff --git a/test/test_poetry_detection/test_chadwyck_healey/test_tml_parser.py b/tests/test_poetry_detection/test_chadwyck_healey/test_tml_parser.py similarity index 100% rename from test/test_poetry_detection/test_chadwyck_healey/test_tml_parser.py rename to tests/test_poetry_detection/test_chadwyck_healey/test_tml_parser.py diff --git a/tests/test_poetry_detection/test_compile_dataset.py b/tests/test_poetry_detection/test_compile_dataset.py new file mode 100644 index 00000000..483c13ff --- /dev/null +++ b/tests/test_poetry_detection/test_compile_dataset.py @@ -0,0 +1,332 @@ +# Copyright (c) 2026, Center for Digital Humanities, Princeton University +# SPDX-License-Identifier: Apache-2.0 + + +import gzip +from pathlib import Path +from unittest.mock import patch + +import polars as pl +import pytest + +from corppa.poetry_detection.compile_dataset import ( + compress_file, + get_excerpt_sources, + load_compiled_excerpts, + main, + run_merge_step, + run_poem_metadata_step, + run_ppa_metadata_step, + save_ppa_metadata, +) + + +def test_get_excerpt_sources_empty_dir(tmp_path): + result = get_excerpt_sources(tmp_path) + assert result == [] + + +def test_get_excerpt_sources_with_files(tmp_path): + subdir1 = tmp_path / "subdir1" + subdir2 = tmp_path / "subdir2" + subdir1.mkdir() + subdir2.mkdir() + + (tmp_path / "file1.csv").touch() + (tmp_path / "file2.csv.gz").touch() + (subdir1 / "nested.csv").touch() + (subdir2 / "nested.csv.gz").touch() + (tmp_path / "file3.txt").touch() + + result = get_excerpt_sources(tmp_path) + assert len(result) == 4 + + +def test_save_ppa_metadata(tmp_path): + input_file = tmp_path / "ppa_works.csv" + output_file = tmp_path / "output.csv" + + input_file.write_text("work_id,title,author\nW001,Test Work,Test Author\n") + + excerpts_df = pl.DataFrame( + { + "ppa_work_id": ["W001", "W001", "W001"], + "excerpt_id": ["e1", "e2", "e3"], + "poem_id": ["poem-1", "poem-1", "poem-2"], + "poem_author": ["Author A", "Author A", "Author B"], + } + ) + + save_ppa_metadata(input_file, output_file, excerpts_df) + + result = pl.read_csv(output_file) + assert "num_excerpts" in result.columns + assert "num_poems" in result.columns + assert "num_poets" in result.columns + + row = result.row(0, named=True) + assert row["work_id"] == "W001" + assert row["num_excerpts"] == 3 + assert row["num_poems"] == 2 + assert row["num_poets"] == 2 + + +def test_save_ppa_metadata_not_csv(tmp_path): + input_file = tmp_path / "ppa_works.json" + output_file = tmp_path / "output.csv" + + excerpts_df = pl.DataFrame( + { + "ppa_work_id": ["W001"], + "excerpt_id": ["e1"], + "poem_id": ["poem-1"], + "poem_author": ["Author A"], + } + ) + + with pytest.raises(ValueError, match="PPA metadata must be loaded as CSV"): + save_ppa_metadata(input_file, output_file, excerpts_df) + + +@patch("corppa.poetry_detection.compile_dataset.pl.read_csv") +@patch("corppa.poetry_detection.compile_dataset.extract_page_meta") +def test_load_compiled_excerpts_uncompressed( + mock_extract_page_meta, mock_read_csv, tmp_path +): + # config method populates both paths; + # load method will choose the first one that exists + excerpt_file = tmp_path / "excerpts.csv" + excerpt_file.touch() + excerpt_gz_file = tmp_path / "excerpts.csv.gz" + config = { + "compiled_excerpt_file": excerpt_file, + "compressed_excerpt_file": excerpt_gz_file, + } + + result = load_compiled_excerpts(config) + assert result == mock_extract_page_meta.return_value + + mock_extract_page_meta.assert_called_once_with(mock_read_csv.return_value) + mock_read_csv.assert_called_once_with(excerpt_file) + + # reset and remove the uncompressed, make the gz exist + mock_read_csv.reset_mock() + excerpt_file.unlink() + excerpt_gz_file.touch() + load_compiled_excerpts(config) + mock_read_csv.assert_called_once_with(excerpt_gz_file) + + +def test_load_compiled_excerpts_file_not_found(tmp_path): + config = { + "compiled_excerpt_file": tmp_path / "nonexistent.csv", + "compressed_excerpt_file": tmp_path / "nonexistent.csv.gz", + } + + with pytest.raises(ValueError, match="Excerpt data file not found"): + load_compiled_excerpts(config) + + +@pytest.mark.parametrize( + "args,expected_calls", + [ + ([], {"merge", "poem_metadata", "ppa_metadata"}), + (["--merge"], {"merge"}), + (["--poem_metadata"], {"poem_metadata"}), + (["--ppa_metadata"], {"ppa_metadata"}), + ], +) +@patch("corppa.poetry_detection.compile_dataset.run_ppa_metadata_step") +@patch("corppa.poetry_detection.compile_dataset.run_poem_metadata_step") +@patch("corppa.poetry_detection.compile_dataset.run_merge_step") +@patch("corppa.poetry_detection.compile_dataset.load_compilation_config") +def test_main( + mock_load_config, + mock_merge, + mock_poem, + mock_ppa, + args, + expected_calls, + tmp_path, +): + mock_load_config.return_value = { + "test": "config", + "output_data_dir": tmp_path, + } + + main(args) + + if "merge" in expected_calls: + mock_merge.assert_called_once() + else: + mock_merge.assert_not_called() + + if "poem_metadata" in expected_calls: + mock_poem.assert_called_once() + else: + mock_poem.assert_not_called() + + if "ppa_metadata" in expected_calls: + mock_ppa.assert_called_once() + else: + mock_ppa.assert_not_called() + + +@patch("corppa.poetry_detection.compile_dataset.compress_file") +@patch("corppa.poetry_detection.compile_dataset.merge_excerpt_files") +@patch("corppa.poetry_detection.compile_dataset.get_excerpt_sources") +def test_run_merge_step(mock_get_sources, mock_merge, mock_compress, tmp_path): + compile_opts = { + "source_excerpt_data": tmp_path / "/data/excerpts", + "compiled_excerpt_file": tmp_path / "/out/excerpts.csv", + "compressed_excerpt_file": tmp_path / "/out/excerpts.csv.gz", + } + + result = run_merge_step(compile_opts, None, compress_excerpts=True) + # returns result of merge + assert result == mock_merge.return_value + + # get sources is called on the configured path + mock_get_sources.assert_called_once_with(compile_opts["source_excerpt_data"]) + # merge is called with the result of get sources and compile option + mock_merge.assert_called_once_with( + mock_get_sources.return_value, compile_opts["compiled_excerpt_file"] + ) + # compress is called + mock_compress.assert_called_once_with( + Path("/out/excerpts.csv"), Path("/out/excerpts.csv.gz") + ) + + # call again with no compression + mock_compress.reset_mock() + run_merge_step(compile_opts, None, compress_excerpts=False) + mock_compress.assert_not_called() + + +@patch("corppa.poetry_detection.compile_dataset.save_poem_metadata") +@patch("corppa.poetry_detection.compile_dataset.extract_page_meta") +@patch("corppa.poetry_detection.compile_dataset.load_compiled_excerpts") +def test_run_poem_metadata_step_with_df(mock_load, mock_extract, mock_save, tmp_path): + input_df = pl.DataFrame({"id": [1]}) + mock_extract.return_value = pl.DataFrame({"id": [1], "page_id": ["p.1"]}) + + compile_opts = {"poem_metadata_file": tmp_path / "/out/poem_meta.csv"} + + run_poem_metadata_step(compile_opts, input_df) + + mock_load.assert_not_called() + mock_extract.assert_called_once_with(input_df) + mock_save.assert_called_once_with( + compile_opts["poem_metadata_file"], mock_extract.return_value, None + ) + + +@patch("corppa.poetry_detection.compile_dataset.save_poem_metadata") +@patch("corppa.poetry_detection.compile_dataset.extract_page_meta") +@patch("corppa.poetry_detection.compile_dataset.load_compiled_excerpts") +def test_run_poem_metadata_step(mock_load, mock_extract, mock_save, tmp_path): + mock_load.return_value = pl.DataFrame({"id": [1]}) + + compile_opts = {"poem_metadata_file": tmp_path / "/out/poem_meta.csv"} + + run_poem_metadata_step(compile_opts, None) + + mock_load.assert_called_once_with(compile_opts) + mock_extract.assert_not_called() + mock_save.assert_called_once_with( + compile_opts["poem_metadata_file"], mock_load.return_value, None + ) + + +@patch("corppa.poetry_detection.compile_dataset.pl") +@patch("corppa.poetry_detection.compile_dataset.save_poem_metadata") +@patch("corppa.poetry_detection.compile_dataset.extract_page_meta") +@patch("corppa.poetry_detection.compile_dataset.load_compiled_excerpts") +def test_run_poem_metadata_step_with_clusters( + mock_load, mock_extract, mock_save, mock_pl, tmp_path +): + mock_load.return_value = pl.DataFrame({"id": [1]}) + + compile_opts = { + "poem_metadata_file": tmp_path / "/out/poem_meta.csv", + "source_poem_clusters": "path/to/cluster_ids.csv", + } + + run_poem_metadata_step(compile_opts, None) + + mock_load.assert_called_once_with(compile_opts) + mock_extract.assert_not_called() + mock_pl.read_csv.assert_called_once_with(compile_opts["source_poem_clusters"]) + mock_save.assert_called_once_with( + compile_opts["poem_metadata_file"], + mock_load.return_value, + mock_pl.read_csv.return_value, + ) + + +@patch("corppa.poetry_detection.compile_dataset.save_ppa_metadata") +@patch("corppa.poetry_detection.compile_dataset.add_ref_poems_meta") +@patch("corppa.poetry_detection.compile_dataset.extract_page_meta") +@patch("corppa.poetry_detection.compile_dataset.load_compiled_excerpts") +def test_run_ppa_metadata_step( + mock_load, mock_extract, mock_add_ref_poems, mock_save, tmp_path +): + input_df = pl.DataFrame({"id": [1]}) + + compile_opts = { + "poem_metadata_file": tmp_path / "/out/poem_meta.csv", + "source_ppa_metadata": tmp_path / "/data/ppa_works.csv", + "ppa_metadata_file": tmp_path / "/out/ppa_meta.csv", + } + + # call with excerpt dataframe provided + run_ppa_metadata_step(compile_opts, input_df) + # doesn't load excerpts because provided + mock_load.assert_not_called() + # extracts page/work metadata + mock_extract.assert_called_once_with(input_df) + # loads reference poem metadata + mock_add_ref_poems.assert_called_once_with( + mock_extract.return_value, compile_opts["poem_metadata_file"] + ) + mock_save.assert_called_once_with( + compile_opts["source_ppa_metadata"], + compile_opts["ppa_metadata_file"], + mock_add_ref_poems.return_value, + ) + + # call without excerpt df + mock_extract.reset_mock() + mock_add_ref_poems.reset_mock() + mock_save.reset_mock() + run_ppa_metadata_step(compile_opts, None) + mock_load.assert_called_once_with(compile_opts) + mock_extract.assert_not_called() + mock_add_ref_poems.assert_called_once_with( + mock_load.return_value, compile_opts["poem_metadata_file"] + ) + mock_save.assert_called_once_with( + compile_opts["source_ppa_metadata"], + compile_opts["ppa_metadata_file"], + mock_add_ref_poems.return_value, + ) + + +def test_compress_file(tmp_path): + # integration test to confirm logic works as expected + uncompressed_file = tmp_path / "excerpts.csv" + compressed_file = tmp_path / "excerpts.csv.gz" + # write out content to test round-trip + file_contents = "excerpt_id,text\n1,hello\n" + uncompressed_file.write_text(file_contents) + + compress_file(uncompressed_file, compressed_file) + # uncompressed file should be removed + assert not uncompressed_file.exists() + # compressed file should now be present + assert compressed_file.exists() + + # uncompressed content should match what we wrote out + with gzip.open(compressed_file, "rt") as f: + content = f.read() + assert content == file_contents diff --git a/test/test_poetry_detection/test_core.py b/tests/test_poetry_detection/test_core.py similarity index 99% rename from test/test_poetry_detection/test_core.py rename to tests/test_poetry_detection/test_core.py index 0bb97a64..687a3722 100644 --- a/test/test_poetry_detection/test_core.py +++ b/tests/test_poetry_detection/test_core.py @@ -1,4 +1,4 @@ -# Copyright (c) 2024-2025, Center for Digital Humanities, Princeton University +# Copyright (c) 2024-2026, Center for Digital Humanities, Princeton University # SPDX-License-Identifier: Apache-2.0 from dataclasses import replace @@ -23,10 +23,10 @@ def test_init(self): # Invalid range: end index < start index error_message = "Start index must be less than end index" with pytest.raises(ValueError, match=error_message): - span = Span(9, 2, "label") + Span(9, 2, "label") # Invalid range: end index = < start index with pytest.raises(ValueError, match=error_message): - span = Span(2, 2, "label") + Span(2, 2, "label") def test_len(self): assert len(Span(2, 5, "label")) == 3 @@ -423,8 +423,8 @@ def test_page_excerpt(self, mock_pairwise_aligner): mock_pairwise_aligner.assert_called_once_with( mismatch_score=-0.5, gap_score=-0.5, - query_left_gap_score=0, - query_right_gap_score=0, + left_deletion_score=0, + right_deletion_score=0, ) mock_aligner.align.assert_called_once_with("page_text hello", "excerpt_text") # Check result @@ -643,6 +643,7 @@ def test_fieldnames(self): "ref_span_start", "ref_span_end", "ref_span_text", + "alt_poem_ids", "identification_methods", ] @@ -665,6 +666,7 @@ def test_field_types(self): "ref_span_end": int, "ref_span_text": str, "identification_methods": set, + "alt_poem_ids": set, } ) diff --git a/test/test_poetry_detection/test_evaluation/test_evaluate_poetry_spans.py b/tests/test_poetry_detection/test_evaluation/test_evaluate_poetry_spans.py similarity index 100% rename from test/test_poetry_detection/test_evaluation/test_evaluate_poetry_spans.py rename to tests/test_poetry_detection/test_evaluation/test_evaluate_poetry_spans.py diff --git a/test/test_poetry_detection/test_merge_excerpts.py b/tests/test_poetry_detection/test_merge_excerpts.py similarity index 57% rename from test/test_poetry_detection/test_merge_excerpts.py rename to tests/test_poetry_detection/test_merge_excerpts.py index df6d69b4..bd46de7d 100644 --- a/test/test_poetry_detection/test_merge_excerpts.py +++ b/tests/test_poetry_detection/test_merge_excerpts.py @@ -1,4 +1,4 @@ -# Copyright (c) 2024-2025, Center for Digital Humanities, Princeton University +# Copyright (c) 2024-2026, Center for Digital Humanities, Princeton University # SPDX-License-Identifier: Apache-2.0 import csv @@ -9,11 +9,13 @@ import pytest from test_polars_utils import _excerpts_to_csv -from corppa.poetry_detection.core import Excerpt, LabeledExcerpt +from corppa.poetry_detection.core import Excerpt, LabeledExcerpt, Span from corppa.poetry_detection.merge_excerpts import ( + identify_overlapping_excerpts, main, merge_excerpts, ) +from corppa.poetry_detection.polars_utils import standardize_dataframe excerpt1 = Excerpt( page_id="p.1", @@ -64,35 +66,68 @@ def test_merge_excerpts_1ex_1label(): - # excerpt + labeled excerpt (same id) + # excerpt + labeled excerpt (same id, same method, same span) df = pl.from_dicts([excerpt1.to_dict(), excerpt1_label1.to_dict()]) merged = merge_excerpts(df) - # expect one row + # expect one row (excerpts have been merged) assert len(merged) == 1 # should have all columns for labeled excerpt (order-agnostic) assert set(merged.columns) == set(LabeledExcerpt.fieldnames()) row = merged.row(0, named=True) merged_excerpt = LabeledExcerpt.from_dict(row) - # result should exactly match the labeled excerpt since all other fields are same - assert merged_excerpt == excerpt1_label1 + # existing notes should be present + assert excerpt1_label1.notes in merged_excerpt.notes + # merge info should be added to notes + assert "merge: ppa exact span, 2 excerpts" in merged_excerpt.notes + + # result should exactly match the labeled excerpt since all fields are same + # other than notes; override the notes to simplify the check + excerpt1_label1_notes = LabeledExcerpt.from_excerpt( + excerpt1_label1, notes=merged_excerpt.notes + ) + assert merged_excerpt == excerpt1_label1_notes def test_merge_excerpts_1ex_2labels(capsys): - # excerpt + two labeled excerpt (same excerpt id, two different ref ids) + # excerpt + two labeled excerpt (same excerpt id, two different poem ids) df = pl.from_dicts( [excerpt1.to_dict(), excerpt1_label1.to_dict(), excerpt1_label2.to_dict()] ) merged = merge_excerpts(df) - # expect two rows with two different labels - assert len(merged) == 2 - # original order is not guaranteed, so check presence in list - result_excerpts = [ - LabeledExcerpt.from_dict(row) for row in merged.iter_rows(named=True) - ] - # results should exactly match the labeled excerpts since all other fields are same - # input excerpts should both be present unchanged in the output - assert excerpt1_label1 in result_excerpts - assert excerpt1_label2 in result_excerpts + # expect one row with combined labels + assert len(merged) == 1 + merged_excerpt = LabeledExcerpt.from_dict(merged.row(0, named=True)) + # existing notes should be present + for excerpt in [excerpt1, excerpt1_label1, excerpt1_label2]: + if excerpt.notes: + assert excerpt.notes in merged_excerpt.notes + # merge info should be added to notes + assert "merge: ppa exact span, 3 excerpts" in merged_excerpt.notes + + # id methods should be combined + merged_excerpt.identification_methods == excerpt1_label1.identification_methods & excerpt1_label2.identification_methods + # first poem id is selected as primary + assert merged_excerpt.poem_id == excerpt1_label1.poem_id + # alternate poem ids collected in a separate field + assert merged_excerpt.alt_poem_ids == {excerpt1_label2.poem_id} + + # all other fields should be unchanged + for field in LabeledExcerpt.fieldnames(): + # all other fields should have the same content in the merged excerpt + if field not in ["notes", "poem_id", "identification_methods", "alt_poem_ids"]: + assert getattr(merged_excerpt, field) == getattr(excerpt1_label1, field) + + # check that works the same way with different initial order, + # since the method orders before grouping + df = pl.from_dicts( + [excerpt1_label2.to_dict(), excerpt1_label1.to_dict(), excerpt1.to_dict()] + ) + merged = merge_excerpts(df) + # expect one row with combined labels + assert len(merged) == 1 + merged_excerpt = LabeledExcerpt.from_dict(merged.row(0, named=True)) + # first poem id is selected as primary + assert merged_excerpt.poem_id == excerpt1_label1.poem_id def test_merge_excerpts_1ex_note_1label(): @@ -107,7 +142,12 @@ def test_merge_excerpts_1ex_note_1label(): merged_excerpt = LabeledExcerpt.from_dict(merged.row(0, named=True)) # result should match the labeled excerpt except for the updated notes field assert merged_excerpt != excerpt1_label1 - expected_notes = "\n".join([ex1_notes.notes, excerpt1_label1.notes]) + # notes should be combined, and merge info should be added + expected_merge_note = "merge: ppa exact span, 2 excerpts" + expected_notes = "; ".join( + [excerpt1_label1.notes, ex1_notes.notes, expected_merge_note] + ) + assert merged_excerpt.notes == expected_notes excerpt_with_notes = replace(excerpt1_label1, notes=expected_notes) assert merged_excerpt == excerpt_with_notes @@ -150,8 +190,25 @@ def test_merge_excerpts_two_different_labels(): assert excerpt2_label1 in result_excerpts +def test_merge_passim_match_len(): + # passim match length should take precedence over sort by poem id + long_excerpt1 = replace( + excerpt1_label1, poem_id="z", notes="passim: 442 char matches" + ) + shorter_excerpt2 = replace( + excerpt1_label1, poem_id="a", notes="passim: 213 char matches" + ) + df = pl.from_dicts([shorter_excerpt2.to_dict(), long_excerpt1.to_dict()]) + merged = merge_excerpts(df) + # expect one row + assert len(merged) == 1 + # longer match should take precedence + merged_excerpt = LabeledExcerpt.from_dict(merged.row(0, named=True)) + assert merged_excerpt.poem_id == long_excerpt1.poem_id + + def test_merge_excerpts_multiple_diff_labels(capsys): - # excerpt + two labeled excerpt (same excerpt id, two different ref ids) + # excerpt + two labeled excerpt (same excerpt id, two different poem ids) df = pl.from_dicts( [excerpt1.to_dict(), excerpt1_label1.to_dict(), excerpt1_label2.to_dict()] ) @@ -159,20 +216,33 @@ def test_merge_excerpts_multiple_diff_labels(capsys): # = two labeled excerpts each for the two poem_ids in label 1 and label 2 df = df.extend(df) merged = merge_excerpts(df) - # expect two rows with two different labels - assert len(merged) == 2 - # order is not guaranteed to match output, so check for presence in output - result_excerpts = [ - LabeledExcerpt.from_dict(row) for row in merged.iter_rows(named=True) - ] - # input excerpts should both be present unchanged in the output - assert excerpt1_label1 in result_excerpts - assert excerpt1_label2 in result_excerpts + # expect one rows with combined poem id + assert len(merged) == 1 + merged_excerpt = LabeledExcerpt.from_dict(merged.row(0, named=True)) + # notes should be combined, and merge info should be added + expected_merge_note = "merge: ppa exact span, 6 excerpts" + # excerpt1 has no notes + expected_notes = "; ".join( + [excerpt1_label1.notes, excerpt1_label2.notes, expected_merge_note] + ) + assert merged_excerpt.notes == expected_notes + + # identification methods should be combined + merged_excerpt.identification_methods == excerpt1_label1.identification_methods & excerpt1_label2.identification_methods + + # first poem id chosen as primary; others collected as alternate + assert merged_excerpt.poem_id == excerpt1_label1.poem_id + assert merged_excerpt.alt_poem_ids == {excerpt1_label2.poem_id} + + for field in LabeledExcerpt.fieldnames(): + # all other fields should have the same content in the merged excerpt + if field not in ["notes", "poem_id", "identification_methods", "alt_poem_ids"]: + assert getattr(merged_excerpt, field) == getattr(excerpt1_label1, field) def test_merge_excerpts_1ex_2labels_diffmethod(): # unlabeled excerpt + two matching labeled excerpts - # - same excerpt id, two labels with same ref ids but different method + # - same excerpt id, two labels with same poem ids but different method # combine method does not merge these # everything the same except for the method (unlikely!) @@ -191,13 +261,18 @@ def test_merge_excerpts_1ex_2labels_diffmethod(): def test_merge_different_labels(): - # combine should NOT merge labeled excerpts with different poem id - excerpt1_diff_label = replace(excerpt1_label1, poem_id="Z1234") + # revised merge logic SHOULD merge labeled excerpts with different poem id + alt_poem_id = "Z1234" + excerpt1_diff_label = replace(excerpt1_label1, poem_id=alt_poem_id) df = pl.from_dicts([excerpt1_label1.to_dict(), excerpt1_diff_label.to_dict()]) - # distinct poem ids should NOT be merged + # distinct poem ids combined when span matches exactly merged = merge_excerpts(df) - assert len(merged) == 2 + assert len(merged) == 1 + merged_result = merged.row(0, named=True) + # based on current sort logic, alt poem id will be chosen as primary poem id + assert merged_result["poem_id"] == alt_poem_id + assert merged_result["alt_poem_ids"] == [excerpt1_label1.poem_id] # revise to merge labeled + unlabeled excerpts @@ -208,8 +283,11 @@ def test_merge_unlabeled_labeled_excerpts(): # we expect a single row assert len(merged) == 1 excerpt = LabeledExcerpt.from_dict(merged.row(0, named=True)) - # should match the labeled excerpt, since everything else was the same - assert excerpt == excerpt1_label1 + # merge info added to notes + expected_merge_note = "merge: ppa exact span, 2 excerpts" + assert expected_merge_note in excerpt.notes + # should match the labeled excerpt, other than notes; everything else was the same + assert replace(excerpt, notes=None) == replace(excerpt1_label1, notes=None) # excerpt + excerpt with notes excerpt_with_notes = replace(excerpt1, notes="could not identify") @@ -221,9 +299,8 @@ def test_merge_unlabeled_labeled_excerpts(): # should not match the labeled excerpt, since notes should be combined assert excerpt != excerpt1_label1 # notes contents from both merged excerpts should be present - assert excerpt_with_notes.notes in excerpt.notes - assert excerpt1_label1.notes in excerpt.notes - assert excerpt.notes == f"{excerpt_with_notes.notes}\n{excerpt1_label1.notes}" + for note in [excerpt_with_notes.notes, excerpt1_label1.notes, expected_merge_note]: + assert note in excerpt.notes # excerpt with notes and two labeled excerpts that can't be merged # - notes are merged to the first matching labeled excerpt @@ -236,19 +313,26 @@ def test_merge_unlabeled_labeled_excerpts(): ] ) merged = merge_excerpts(df) - # we expect two rows - assert len(merged) == 2 - # order is not guaranteed; test against a list of merged note contents - merged_notes = merged["notes"].to_list() - # unlabeled excerpt and excerpt1 label 1 are combined - assert f"{excerpt_with_notes.notes}\n{excerpt1_label1.notes}" in merged_notes - # second labeled excerpt does not currently get unlabeled excerpt notes - assert excerpt1_label2.notes in merged_notes + # we expect one row with combined poem ids + assert len(merged) == 1 + merged_excerpt = merged.row(0, named=True) + expected_merge_note = "merge: ppa exact span, 3 excerpts" + # notes contents from both merged excerpts should be present + for note in [ + excerpt_with_notes.notes, + excerpt1_label1.notes, + excerpt1_label2.notes, + expected_merge_note, + ]: + assert note in merged_excerpt["notes"] + + assert merged_excerpt["poem_id"] == excerpt1_label1.poem_id + assert merged_excerpt["alt_poem_ids"] == [excerpt1_label2.poem_id] def test_merge_excerpts(): # excerpt + two matching labeled excerpts - # - same excerpt id, two labels with same ref ids but different method + # - same excerpt id, two labels with same poem ids but different method # everything the same except for the method (unlikely!) excerpt1_label1_method2 = replace( @@ -383,7 +467,7 @@ def test_main_invalid_input(capsys, tmp_path): def test_main_successful(capsys, tmp_path): - # test a succesful run + # test a successful run excerpt_datafile = tmp_path / "excerpts.csv" _excerpts_to_csv(excerpt_datafile, [excerpt1, excerpt2]) # valid excerpt data @@ -437,3 +521,123 @@ def test_main_successful(capsys, tmp_path): assert merged_ex1.ref_span_text == excerpt1_label1.ref_span_text # id methods combined assert merged_ex1.identification_methods == {"manual", "refmatcha"} + + +### test for identify_overlapping_excerpts + +# test scenarios that should result in no overlapping pairs +no_overlap_inputs = [ + # list of excerpts, reason this example has no overlapping pairs + ([excerpt2], "single excerpt"), + ([excerpt2, excerpt1_label1], "excerpts on different page"), + # construct a second excerpt on the same page by using replace and relative offset span start/end + ( + [ + excerpt1, + replace( + excerpt1, + ppa_span_start=excerpt1.ppa_span_end + 100, + ppa_span_end=excerpt1.ppa_span_end + 120, + ), + ], + "same page, no overlap", + ), + # construct a short second excerpt on the same page with minimal overlap + ( + [ + excerpt1, + replace( + excerpt1, + ppa_span_start=excerpt1.ppa_span_end - 1, + ppa_span_end=excerpt1.ppa_span_end + 3, + ), + ], + "very small overlap", + ), + ([excerpt1, excerpt1], "full overlap, short text"), + ( + [ + replace(excerpt1, ppa_span_end=100), + replace(excerpt1, ppa_span_start=80, ppa_span_end=200), + ], + "long overlap, low overlap factor", + ), +] + + +@pytest.mark.parametrize("excerpts, reason", no_overlap_inputs) +def test_identify_overlapping_excerpts_no_pairs(excerpts, reason): + # construct a standardized dataframe from the list of excerpts given + excerpts_df = standardize_dataframe( + pl.from_dicts([ex.to_dict() for ex in excerpts]) + ) + pairs_df = identify_overlapping_excerpts(excerpts_df) + assert pairs_df.height == 0, f"expected 0 overlapping pairs: {reason}" + + +def test_identify_overlapping_excerpts(): + # create a pair with high overlap starting with fixture 1 + # for convenience, we use the existing Span object to construct + # an overlapping span and check the overlap length / factor logic + ppa_span1 = Span(start=excerpt1.ppa_span_start, end=excerpt1.ppa_span_end, label="") + # create a second span; offset start by 1/9 the length of the first span + ppa_span2 = Span( + start=int(ppa_span1.start + len(ppa_span1) / 9), end=ppa_span1.end + 1, label="" + ) + excerpt1_overlap = replace( + excerpt1, ppa_span_start=ppa_span2.start, ppa_span_end=ppa_span2.end + ) + # use existing span logic as coherence check for new method + overlap_len = ppa_span1.overlap_length(ppa_span2) + assert overlap_len >= 9 + overlap_factor = ppa_span1.overlap_factor(ppa_span2, ignore_label=True) + assert overlap_factor >= 0.9 + # construct a standardized dataframe from the two test excerpts + excerpts = [excerpt1, excerpt1_overlap] + excerpts_df = standardize_dataframe( + pl.from_dicts([ex.to_dict() for ex in excerpts]) + ) + pairs_df = identify_overlapping_excerpts( + excerpts_df, min_overlap_chars=9, min_overlap_factor=0.9 + ) + # we expect one pair + assert pairs_df.height == 1 + # inspect the fields in the one returned pair + pair_result = pairs_df.row(0, named=True) + assert pair_result["page_id"] == excerpt1.page_id + # both excerpt ids present (order agnostic) + pair_exc_ids = set([pair_result["excerpt_id"], pair_result["excerpt_id_right"]]) + assert pair_exc_ids == set([excerpt1.excerpt_id, excerpt1_overlap.excerpt_id]) + assert pair_result["overlap_len"] == overlap_len + assert pair_result["overlap_factor"] == overlap_factor + + # confirm that if we adjust the parameters, this pair is not returned + assert ( + identify_overlapping_excerpts( + excerpts_df, min_overlap_chars=10, min_overlap_factor=0.9 + ).height + == 0 + ) + assert ( + identify_overlapping_excerpts( + excerpts_df, min_overlap_chars=9, min_overlap_factor=0.95 + ).height + == 0 + ) + # defaults options exclude this pair + assert identify_overlapping_excerpts(excerpts_df).height == 0 + + # check results when input is given in the alternate order + excerpts = [excerpt1_overlap, excerpt1] + excerpts_df = standardize_dataframe( + pl.from_dicts([ex.to_dict() for ex in excerpts]) + ) + pairs_df = identify_overlapping_excerpts( + excerpts_df, min_overlap_chars=9, min_overlap_factor=0.9 + ) + # we expect one pair + assert pairs_df.height == 1 + # check that pair is ordered as expected + pair_result = pairs_df.row(0, named=True) + assert pair_result["excerpt_id"] == excerpt1.excerpt_id + assert pair_result["excerpt_id_right"] == excerpt1_overlap.excerpt_id diff --git a/test/test_poetry_detection/test_passim/test_get_passim_results.py b/tests/test_poetry_detection/test_passim/test_get_passim_results.py similarity index 98% rename from test/test_poetry_detection/test_passim/test_get_passim_results.py rename to tests/test_poetry_detection/test_passim/test_get_passim_results.py index bc7089e9..38f36eec 100644 --- a/test/test_poetry_detection/test_passim/test_get_passim_results.py +++ b/tests/test_poetry_detection/test_passim/test_get_passim_results.py @@ -1,7 +1,6 @@ # Copyright (c) 2024-2025, Center for Digital Humanities, Princeton University # SPDX-License-Identifier: Apache-2.0 -import pathlib from unittest.mock import call, patch import pytest @@ -129,7 +128,8 @@ def test_extract_passim_spans(mock_orjsonl, mock_get_span, tmp_path): results = list(extract_passim_spans(passim_dir, disable_progress=True)) assert results == ["r1", "r2", "r3"] assert mock_orjsonl.stream.call_count == 2 - mock_orjsonl.stream.assert_has_calls([call(input_a), call(input_b)]) + # NOTE: passes locally in this order but fails on GitHub due to ordering + mock_orjsonl.stream.assert_has_calls([call(input_a), call(input_b)], any_order=True) assert mock_get_span.call_count == 3 mock_get_span.assert_has_calls([call("a1"), call("b1"), call("b2")]) diff --git a/test/test_poetry_detection/test_passim/test_run_passim.py b/tests/test_poetry_detection/test_passim/test_run_passim.py similarity index 75% rename from test/test_poetry_detection/test_passim/test_run_passim.py rename to tests/test_poetry_detection/test_passim/test_run_passim.py index d5eeef82..fceb3568 100644 --- a/test/test_poetry_detection/test_passim/test_run_passim.py +++ b/tests/test_poetry_detection/test_passim/test_run_passim.py @@ -9,8 +9,10 @@ import pytest from corppa.poetry_detection.passim.run_passim import ( + PASSIM_DEFAULTS, build_input_string, get_java_version, + main, run_passim, set_spark_env_vars, ) @@ -198,3 +200,76 @@ def test_run_passim(mock_java, mock_build_input_str, mock_run, tmp_path, capsys) ) captured_stderr = capsys.readouterr().err assert captured_stderr == "ERROR: An error occurred while running passim\n" + + +@patch("corppa.poetry_detection.passim.run_passim.set_spark_env_vars") +@patch("corppa.poetry_detection.passim.run_passim.run_passim") +def test_main(mock_run_passim, mock_set_spark_env_vars, tmp_path): + # required parameters + ppa_corpus = tmp_path / "ppa_pages.jsonl" + ppa_corpus.touch() + ref_corpus = tmp_path / "ref_corpus.jsonl" + ref_corpus.touch() + output_dir = tmp_path / "passim-output" + + args = [ + "--ppa-corpus", + str(ppa_corpus), + "--ref-corpus", + str(ref_corpus), + "--output-dir", + str(output_dir), + ] + main(args) + mock_set_spark_env_vars.assert_called_once() + mock_run_passim.assert_called_once_with( + ppa_corpus, + [ref_corpus], + output_dir, + # defaults from passim defaults object + max_df=PASSIM_DEFAULTS.max_df, + min_match=PASSIM_DEFAULTS.min_match, + ngram_size=PASSIM_DEFAULTS.ngram_size, + gap=PASSIM_DEFAULTS.gap, + min_align=PASSIM_DEFAULTS.min_align, + floating_ngrams=False, # off by default + verbose=False, # off by default + ) + + # test with non-default parameters + max_df = 50 + min_match = 7 + gap = 21 + min_align = 19 + ngram_size = 5 + args.extend( + [ + "--max-df", + str(max_df), + "--min-match", + str(min_match), + "--ngram-size", + str(ngram_size), + "--floating-ngrams", + "--gap", + str(gap), + "--min-align", + str(min_align), + "-v", + ] + ) + mock_run_passim.reset_mock() + main(args) + mock_run_passim.assert_called_once_with( + ppa_corpus, + [ref_corpus], + output_dir, + # uses values specified + max_df=max_df, + min_match=min_match, + ngram_size=ngram_size, + gap=gap, + min_align=min_align, + floating_ngrams=True, + verbose=True, + ) diff --git a/test/test_poetry_detection/test_polars_utils.py b/tests/test_poetry_detection/test_polars_utils.py similarity index 69% rename from test/test_poetry_detection/test_polars_utils.py rename to tests/test_poetry_detection/test_polars_utils.py index 5732dad8..4aedea88 100644 --- a/test/test_poetry_detection/test_polars_utils.py +++ b/tests/test_poetry_detection/test_polars_utils.py @@ -1,4 +1,4 @@ -# Copyright (c) 2024-2025, Center for Digital Humanities, Princeton University +# Copyright (c) 2024,2025 Center for Digital Humanities, Princeton University # SPDX-License-Identifier: Apache-2.0 import csv @@ -10,10 +10,7 @@ from corppa.poetry_detection.core import MULTIVAL_DELIMITER, Excerpt, LabeledExcerpt from corppa.poetry_detection.polars_utils import ( POEM_FIELDS, - PPA_FIELDS, - add_ppa_works_meta, add_ref_poems_meta, - extract_page_meta, fix_data_types, has_poem_ids, load_excerpts_df, @@ -194,92 +191,46 @@ def test_load_excerpts_df(mock_add_ppa_meta, mock_add_poem_meta, tmp_path): load_excerpts_df(datafile) -def test_extract_page_meta(): - ppa_page_ids = ["A01224.100", "yale.39002088447587.00000050", "CW0111540239.0092"] - excerpts_df = pl.DataFrame( - [ - {"page_id": page_id, "excerpt_id": f"excerpt_{i}"} - for i, page_id in enumerate(ppa_page_ids) - ] - ) - results = extract_page_meta(excerpts_df) - # Check column names - assert set(results.columns) == set(excerpts_df.columns) | { - "ppa_work_id", - "page_num", - } - # Check row contents - for i, page_id in enumerate(ppa_page_ids): - work_id, page_num = page_id.rsplit(".", maxsplit=1) - expected_row = excerpts_df.row(i, named=True) | { - "ppa_work_id": work_id, - "page_num": int(page_num), - } - assert results.row(i, named=True) == expected_row - - def test_load_meta_df(tmp_path): - # Prepare metadata file - ppa_meta = tmp_path / "ppa_meta.csv" - csv_fields = [ - "work_id", - "source_id", - "cluster_id", - "title", - "author", - "pub_year", - "collections", - "work_type", - "source", - ] + # Prepare test metadata file with arbitrary fields + test_data_file = tmp_path / "test_meta.csv" + fields = ["foo", "bar", "baz"] + rows = [ - { - "work_id": "work_a", - "source_id": "work_a", - "cluster_id": "cluster_a", - "title": "title_a", - "author": "author_a", - "pub_year": "1899", - "collections": "['Linguistic', 'Literary']", - "work_type": "full-work", - "source": "source_a", - }, - { - "work_id": "work_b-p7", - "source_id": "work_b", - "cluster_id": "cluster_b", - "title": "title_b", - "author": "author_b", - "pub_year": "1507", - "collections": "['Uncategorized']", - "work_type": "excerpt", - "source": "source_b", - }, + {"foo": "a", "bar": "one", "baz": "zed"}, + {"foo": "b", "bar": "two", "baz": "omega"}, ] - with ppa_meta.open("w", encoding="utf-8") as file: - csv_writer = csv.DictWriter(file, fieldnames=csv_fields) + with test_data_file.open("w", encoding="utf-8") as file: + csv_writer = csv.DictWriter(file, fieldnames=fields) csv_writer.writeheader() csv_writer.writerows(rows) - # Typical Case: - result_df = load_meta_df(ppa_meta, PPA_FIELDS) + # Typical Case: rename + field_names = {f: f"pfx_{f}" for f in fields} + result_df = load_meta_df(test_data_file, field_names) # Check column names - assert result_df.columns == list(PPA_FIELDS.values()) + assert result_df.columns == list(field_names.values()) # Check row contents for i, row in enumerate(rows): - row_dict = {v: row[k] for k, v in PPA_FIELDS.items()} + row_dict = {f"pfx_{f}": row[f] for f in fields} assert result_df.row(i, named=True) == row_dict + # Subset of fields + subset_fields = {"foo": "my_foo"} + result_df = load_meta_df(test_data_file, subset_fields) + # Check column names + assert result_df.columns == list(subset_fields.values()) + # Error Case: Input file does not exist missing_csv = tmp_path / "missing.csv" with pytest.raises(ValueError, match=f"Input file {missing_csv} does not exist"): - load_meta_df(missing_csv, PPA_FIELDS) + load_meta_df(missing_csv, fields) # Error Case: Input file is missing required field - for missing_fields in [["author"], ["pub_year", "source"]]: + for missing_fields in [["foo"], ["bar", "foo"]]: bad_csv = tmp_path / "missing_fields.csv" with bad_csv.open("w", encoding="utf-8") as file: - bad_fields = list(set(csv_fields) - set(missing_fields)) + bad_fields = list(set(fields) - set(missing_fields)) csv_writer = csv.DictWriter( file, fieldnames=bad_fields, extrasaction="ignore" ) @@ -289,67 +240,7 @@ def test_load_meta_df(tmp_path): missing_str = ", ".join(missing_fields) err_msg = f"Input CSV is missing the following required fields: {missing_str}" with pytest.raises(ValueError, match=err_msg): - load_meta_df(bad_csv, PPA_FIELDS) - - -@patch("corppa.poetry_detection.polars_utils.load_meta_df") -def test_add_ppa_works_meta(mock_load_meta_df): - # Construct test inputs - excerpts_df = pl.DataFrame( - [ - { - "page_id": "page_a", - "excerpt_id": "ex_1", - "ppa_work_id": "work_a", - }, - { - "page_id": "page_b", - "excerpt_id": "ex_1", - "ppa_work_id": "work_b", - }, - { - "page_id": "page_a", - "excerpt_id": "ex_2", - "ppa_work_id": "work_a", - }, - ] - ) - ppa_meta_rows = [] - for i in ["a", "b", "c"]: - ppa_meta_rows.append( - { - "ppa_work_id": f"work_{i}", - "ppa_author": f"author_{i}", - "ppa_title": f"title_{i}", - } - ) - ppa_meta_df = pl.DataFrame(ppa_meta_rows) - # Set-up mock object - mock_load_meta_df.return_value = ppa_meta_df - - results = add_ppa_works_meta(excerpts_df, "ppa_meta") - mock_load_meta_df.assert_called_once_with("ppa_meta", PPA_FIELDS) - # Check columns - assert set(results.columns) == set(excerpts_df.columns) | set(ppa_meta_df.columns) - # Check row contents - assert results.height == 3 - assert results.row(0, named=True) == ( - excerpts_df.row(0, named=True) | ppa_meta_rows[0] - ) - assert results.row(1, named=True) == ( - excerpts_df.row(1, named=True) | ppa_meta_rows[1] - ) - assert results.row(2, named=True) == ( - excerpts_df.row(2, named=True) | ppa_meta_rows[0] - ) - - # Error case: missing `ppa_work_id` field - mock_load_meta_df.reset_mock() - err_msg = "Missing ppa_work_id field; use extract_page_meta to extract it." - with pytest.raises(ValueError, match=err_msg): - bad_df = pl.DataFrame([{"excerpt_id": "a"}, {"excerpt_id": "b"}]) - add_ppa_works_meta(bad_df, "ppa_meta") - mock_load_meta_df.assert_not_called() + load_meta_df(bad_csv, field_names) @patch("corppa.poetry_detection.polars_utils.load_meta_df") @@ -435,8 +326,30 @@ def test_add_poems_meta(mock_load_meta_df): row[field] = "value" bad_df = pl.DataFrame(bad_rows) # Test error case - err_msg = f"Input DataFrame missing the following required fields: " + err_msg = "Input DataFrame missing the following required fields: " err_msg += ", ".join(missing_fields) with pytest.raises(ValueError, match=err_msg): add_ref_poems_meta(bad_df, "poem_meta") mock_load_meta_df.assert_not_called() + + # join on alternate field names + alt_excerpts_df = excerpts_df.with_columns( + alt_poem_id=pl.col("poem_id"), alt_ref_corpus=pl.col("ref_corpus") + ) + with patch.object(alt_excerpts_df, "join") as mock_join: + add_ref_poems_meta( + alt_excerpts_df, "poem_meta", "alt_poem_id", "alt_ref_corpus", "_alt" + ) + mock_join.assert_called_once_with( + poem_meta_df, + left_on=["alt_poem_id", "alt_ref_corpus"], + right_on=["poem_id", "ref_corpus"], + how="left", + suffix="_alt", + ) + + # validation on custom fields + with pytest.raises(ValueError, match="required fields: alt_poem_id"): + add_ref_poems_meta(excerpts_df, "poem_meta", "alt_poem_id") + with pytest.raises(ValueError, match="required fields: alt_ref_corpus"): + add_ref_poems_meta(excerpts_df, "poem_meta", ref_corpus_field="alt_ref_corpus") diff --git a/tests/test_poetry_detection/test_ppa_works.py b/tests/test_poetry_detection/test_ppa_works.py new file mode 100644 index 00000000..19df5730 --- /dev/null +++ b/tests/test_poetry_detection/test_ppa_works.py @@ -0,0 +1,178 @@ +import csv +from unittest.mock import patch + +import polars as pl +import pytest + +from corppa.poetry_detection.ppa_works import ( + add_ppa_works_meta, + extract_page_meta, + load_ppa_works_df, +) + + +def test_extract_page_meta(): + ppa_page_ids = ["A01224.100", "yale.39002088447587.00000050", "CW0111540239.0092"] + excerpts_df = pl.DataFrame( + [ + {"page_id": page_id, "excerpt_id": f"excerpt_{i}"} + for i, page_id in enumerate(ppa_page_ids) + ] + ) + results = extract_page_meta(excerpts_df) + # Check column names + assert set(results.columns) == set(excerpts_df.columns) | { + "ppa_work_id", + "page_num", + } + # Check row contents + for i, page_id in enumerate(ppa_page_ids): + work_id, page_num = page_id.rsplit(".", maxsplit=1) + expected_row = excerpts_df.row(i, named=True) | { + "ppa_work_id": work_id, + "page_num": int(page_num), + } + assert results.row(i, named=True) == expected_row + + +def test_load_ppa_works_df(tmp_path): + # Prepare metadata file + ppa_meta = tmp_path / "ppa_meta.csv" + csv_fields = [ + "work_id", + "source_id", + "cluster_id", + "title", + "author", + "pub_year", + "collections", + "work_type", + "source", + ] + rows = [ + { + "work_id": "work_a", + "source_id": "work_a", + "cluster_id": "cluster_a", + "title": "title_a", + "author": "author_a", + "pub_year": 1899, + "collections": "Linguistic;Literary", + "work_type": "full-work", + "source": "source_a", + }, + { + "work_id": "work_b-p7", + "source_id": "work_b", + "cluster_id": "cluster_b", + "title": "title_b", + "author": "author_b", + "pub_year": 1507, + "collections": "Uncategorized", + "work_type": "excerpt", + "source": "source_b", + }, + ] + with ppa_meta.open("w", encoding="utf-8") as file: + csv_writer = csv.DictWriter(file, fieldnames=csv_fields) + csv_writer.writeheader() + csv_writer.writerows(rows) + + result_df = load_ppa_works_df(ppa_meta) + # Check column names are prefixed + prefixed_fields = [f"ppa_{field}" for field in csv_fields] + assert result_df.columns == prefixed_fields + # Check row contents + for i, row in enumerate(rows): + # the input dict with prefixed field name should + # be equivalent to the dataframe row + row_dict = {f"ppa_{k}": v for k, v in row.items()} + + result_dict = result_df.row(i, named=True) + # collections field is converted to list; test separately + assert result_dict["ppa_collections"] == row_dict["ppa_collections"].split(";") + # delete to compare + del result_dict["ppa_collections"] + copy_dict = row_dict.copy() + del copy_dict["ppa_collections"] + assert result_dict == copy_dict + + # Error Case: Input file does not exist + missing_csv = tmp_path / "missing.csv" + with pytest.raises(ValueError, match=f"Input file {missing_csv} does not exist"): + load_ppa_works_df(missing_csv) + + # Error Case: Input file is missing required work_id field + bad_csv = tmp_path / "missing_fields.csv" + with bad_csv.open("w", encoding="utf-8") as file: + # subset of fields; does not include work_id + bad_fields = ["author", "title"] + csv_writer = csv.DictWriter(file, fieldnames=bad_fields, extrasaction="ignore") + csv_writer.writeheader() + csv_writer.writerows(rows) + + # close the file to write content to disk before loading + err_msg = "Input CSV is missing required `work_id` field" + with pytest.raises(ValueError, match=err_msg): + load_ppa_works_df(bad_csv) + + +@patch("corppa.poetry_detection.ppa_works.extract_page_meta") +@patch("corppa.poetry_detection.ppa_works.load_ppa_works_df") +def test_add_ppa_works_meta(mock_load_ppa_df, mock_extract_page_meta): + # Construct test inputs + excerpts_df = pl.DataFrame( + [ + { + "page_id": "page_a", + "excerpt_id": "ex_1", + "ppa_work_id": "work_a", + }, + { + "page_id": "page_b", + "excerpt_id": "ex_1", + "ppa_work_id": "work_b", + }, + { + "page_id": "page_a", + "excerpt_id": "ex_2", + "ppa_work_id": "work_a", + }, + ] + ) + ppa_meta_rows = [] + for i in ["a", "b", "c"]: + ppa_meta_rows.append( + { + "ppa_work_id": f"work_{i}", + "ppa_author": f"author_{i}", + "ppa_title": f"title_{i}", + } + ) + ppa_meta_df = pl.DataFrame(ppa_meta_rows) + # Set-up mock object + mock_load_ppa_df.return_value = ppa_meta_df + + results = add_ppa_works_meta(excerpts_df, "ppa_meta") + mock_load_ppa_df.assert_called_once_with("ppa_meta") + # Check columns + assert set(results.columns) == set(excerpts_df.columns) | set(ppa_meta_df.columns) + # Check row contents + assert results.row(0, named=True) == ( + excerpts_df.row(0, named=True) | ppa_meta_rows[0] + ) + assert results.row(1, named=True) == ( + excerpts_df.row(1, named=True) | ppa_meta_rows[1] + ) + assert results.row(2, named=True) == ( + excerpts_df.row(2, named=True) | ppa_meta_rows[0] + ) + mock_extract_page_meta.assert_not_called() + + # If missing `ppa_work_id` field, automatically calls + # extract_page_meta before joining + mock_load_ppa_df.reset_mock() + unextracted_page_id_df = pl.DataFrame([{"excerpt_id": "a"}, {"excerpt_id": "b"}]) + add_ppa_works_meta(unextracted_page_id_df, "ppa_meta") + mock_extract_page_meta.assert_called_once() + mock_load_ppa_df.assert_called() diff --git a/tests/test_poetry_detection/test_ref_corpora.py b/tests/test_poetry_detection/test_ref_corpora.py new file mode 100644 index 00000000..e2e07969 --- /dev/null +++ b/tests/test_poetry_detection/test_ref_corpora.py @@ -0,0 +1,606 @@ +import tarfile +from collections.abc import Generator +from unittest.mock import patch + +import polars as pl +import pytest + +from corppa import config +from corppa.poetry_detection.ref_corpora import ( + METADATA_SCHEMA, + BaseReferenceCorpus, + ChadwyckHealey, + InternetPoems, + OtherPoems, + all_corpora, + compile_metadata_df, + fulltext_corpora, + save_poem_metadata, +) + + +@pytest.fixture +def corppa_test_config(tmp_path): + # test fixture to create and use a temporary config file + # uses explicit, non-default paths + compiled_dataset_dir = tmp_path / "found-poems-data" + ref_base_dir = tmp_path / "ref-corpora" + ref_base_dir.mkdir() + + ref_corpus_names = ["internet_poems", "chadwyck-healey", "other_poems"] + + ref_corpus_configs = { + name: config.CorpusConfig(name=name, relative_dir=ref_base_dir) + for name in ref_corpus_names + } + + config_opts = config.ConfigOpts( + base_dir=tmp_path, + compiled_dataset_dir=compiled_dataset_dir, + # ppa_corpus=None, + reference_corpora=ref_corpus_configs, + # excerpt_data_dir=config_values.get("excerpt_data_dir"), + # poem_clusters_path=config_values.get("poem_clusters_path"), + ) + + # validation requires the files to exist, so create them + config_opts.reference_corpora["internet_poems"].base_dir.mkdir() + config_opts.reference_corpora["internet_poems"].text_path.touch() + config_opts.reference_corpora["chadwyck-healey"].base_dir.mkdir() + config_opts.reference_corpora["chadwyck-healey"].text_path.touch() + config_opts.reference_corpora["chadwyck-healey"].metadata_path.touch() + config_opts.reference_corpora["other_poems"].base_dir.mkdir() + config_opts.reference_corpora["other_poems"].metadata_path.touch() + + with patch.object(config, "get_config") as mock_get_config: + # this patches calls in the current test file + mock_get_config.return_value = config_opts + # this patches calls in ref_corpora + with patch( + "corppa.poetry_detection.ref_corpora.get_config" + ) as ref_corppa_config: + ref_corppa_config.return_value = config_opts + yield config_opts + + +class TestBaseReferenceCorpus: + def test_not_implemented(self): + with pytest.raises(NotImplementedError): + BaseReferenceCorpus().get_metadata_df() + + with pytest.raises(NotImplementedError): + BaseReferenceCorpus().get_text_corpus() + + def test_calculate_poem_length(self): + # Test single line text + result = BaseReferenceCorpus.calculate_poem_length("Hello world test") + assert result == {"num_lines": 1, "num_words": 3, "char_len": 16} + + # Test multi-line text with blank lines + text = "Line one here\nLine two here\n\nLine three" + result = BaseReferenceCorpus.calculate_poem_length(text) + assert result == {"num_lines": 3, "num_words": 8, "char_len": len(text)} + + # Test empty text + result = BaseReferenceCorpus.calculate_poem_length("") + assert result == {"num_lines": 0, "num_words": 0, "char_len": 0} + + # Test text with only blank lines + result = BaseReferenceCorpus.calculate_poem_length(" \n\n ") + assert result == {"num_lines": 0, "num_words": 0, "char_len": 8} + + +# fixture data for internet poems +INTERNETPOEMS_TEXTS = [ + { + "id": "King-James-Bible_Psalms", + "text": "He hath made his wonderful works to be remembered", + }, + { + "id": "Robert-Burns_Mary", + "text": "Powers celestial! whose protection Ever guards the virtuous fair,", + }, +] + + +@pytest.fixture +def internetpoems_data_dir(tmp_path, corppa_test_config): + # test fixture to create internet poems data directory with sample text files + config_opts = config.get_config() + # update the configured text path in fixture config to be a directory + data_dir = config_opts.reference_corpora["internet_poems"].base_dir / "text_files" + data_dir.mkdir(exist_ok=True) + config_opts.reference_corpora["internet_poems"].text_path = data_dir + for sample in INTERNETPOEMS_TEXTS: + text_file = data_dir / f"{sample['id']}.txt" + text_file.write_text(sample["text"]) + return data_dir + + +@pytest.fixture +def internetpoems_tarball(tmp_path, corppa_test_config): + # test fixture to create tar.gzip of internet poems data directory with sample text files + config_opts = config.get_config() + internetpoems_data_dir = tmp_path / "internet_poems_texts" + internetpoems_data_dir.mkdir(exist_ok=True) + for sample in INTERNETPOEMS_TEXTS: + text_file = internetpoems_data_dir / f"{sample['id']}.txt" + text_file.write_text(sample["text"]) + + tarfile_path = config_opts.reference_corpora["internet_poems"].text_path + + with tarfile.open(tarfile_path, "w:gz") as tar: + for text_file in internetpoems_data_dir.glob("*.txt"): + tar.add(text_file) + + return tarfile_path + + +class TestInternetPoems: + def test_init(self, corppa_test_config): + config_opts = config.get_config() + ip_config = config_opts.reference_corpora["internet_poems"] + internet_poems = InternetPoems(ip_config) + assert internet_poems.config == ip_config + assert isinstance(internet_poems.config, config.CorpusConfig) + + def test_get_metadata_df( + self, tmp_path, corppa_test_config, internetpoems_data_dir + ): + config_opts = config.get_config() + internet_poems = InternetPoems(config_opts.reference_corpora["internet_poems"]) + meta_df = internet_poems.get_metadata_df(poem_length=True) + assert isinstance(meta_df, pl.DataFrame) + assert meta_df.schema == METADATA_SCHEMA + assert meta_df.height == len(INTERNETPOEMS_TEXTS) + # get the first row as a dict; sort by id so order matches input + meta_row = meta_df.sort("poem_id").row(0, named=True) + assert meta_row["poem_id"] == INTERNETPOEMS_TEXTS[0]["id"] + assert meta_row["author"] == "King James Bible" + assert meta_row["title"] == "Psalms" + assert meta_row["ref_corpus"] == internet_poems.corpus_id + # check poem length calculations (non-blank lines, word count, char length) + assert meta_row["num_lines"] == 1 + assert meta_row["num_words"] == 9 + assert meta_row["char_len"] == len(INTERNETPOEMS_TEXTS[0]["text"]) + + def test_get_metadata_df_no_poem_length( + self, tmp_path, corppa_test_config, internetpoems_data_dir + ): + # Test that poem_length=False sets length fields to null + config_opts = config.get_config() + internet_poems = InternetPoems(config_opts.reference_corpora["internet_poems"]) + meta_df = internet_poems.get_metadata_df(poem_length=False) + assert isinstance(meta_df, pl.DataFrame) + # Length fields should be present but null + assert "num_lines" in meta_df.columns + assert "num_words" in meta_df.columns + assert "char_len" in meta_df.columns + # All length values should be null + assert ( + meta_df.select("num_lines", "num_words", "char_len").drop_nulls().height + == 0 + ) + + def test_get_metadata_df_csv_and_poem_length( + self, tmp_path, corppa_test_config, internetpoems_data_dir, capsys + ): + # test case where we load metadata from CSV and add poem length info + + config_opts = config.get_config() + ipoem_cfg = config_opts.reference_corpora["internet_poems"] + # create a very simple metadata file based on fixture dictionary ids + ip_meta_csv = ipoem_cfg.metadata_path + # split id into poem id, author, title + csv_rows = [ + (poem["id"], poem["id"].split("_")[0], poem["id"].split("_")[1]) + for poem in INTERNETPOEMS_TEXTS + ] + # combine header row and one row for each poem, then write out to the meta path + csv_text = "poem_id,author,title\n" + "\n".join( + ",".join(row) for row in csv_rows + ) + ip_meta_csv.write_text(csv_text) + + internet_poems = InternetPoems(ipoem_cfg) + meta_df = internet_poems.get_metadata_df(poem_length=True) + assert isinstance(meta_df, pl.DataFrame) + # Length fields should be present but null + assert "num_lines" in meta_df.columns + assert "num_words" in meta_df.columns + assert "char_len" in meta_df.columns + + # get the first row as a dict; sort by id so order matches input + meta_row = meta_df.sort("poem_id").row(0, named=True) + assert meta_row["poem_id"] == INTERNETPOEMS_TEXTS[0]["id"] + assert meta_row["ref_corpus"] == internet_poems.corpus_id + # check poem length calculations (non-blank lines, word count, char length) + assert meta_row["num_lines"] == 1 + assert meta_row["num_words"] == 9 + assert meta_row["char_len"] == len(INTERNETPOEMS_TEXTS[0]["text"]) + + def test_get_metadata_df_tarball( + self, + tmp_path, + corppa_test_config, + internetpoems_tarball, + ): + config_opts = config.get_config() + internet_poems = InternetPoems(config_opts.reference_corpora["internet_poems"]) + meta_df = internet_poems.get_metadata_df() + assert isinstance(meta_df, pl.DataFrame) + assert meta_df.schema == METADATA_SCHEMA + assert meta_df.height == len(INTERNETPOEMS_TEXTS) + # get the first row as a dict; sort by id so order matches input + meta_row = meta_df.sort("poem_id").row(0, named=True) + assert meta_row["poem_id"] == INTERNETPOEMS_TEXTS[0]["id"] + assert meta_row["author"] == "King James Bible" + assert meta_row["title"] == "Psalms" + assert meta_row["ref_corpus"] == internet_poems.corpus_id + + def test_get_text_corpus_tarball( + self, + tmp_path, + corppa_test_config, + internetpoems_tarball, + ): + config_opts = config.get_config() + internet_poems = InternetPoems(config_opts.reference_corpora["internet_poems"]) + # returns a generator; use list to get to actually run + # convert to list, sort to ensure order matches fixture data + text_data = sorted( + list(internet_poems.get_text_corpus()), key=lambda x: x["poem_id"] + ) + assert len(text_data) == len(INTERNETPOEMS_TEXTS) + assert text_data[0]["poem_id"] == INTERNETPOEMS_TEXTS[0]["id"] + assert text_data[0]["text"] == INTERNETPOEMS_TEXTS[0]["text"] + + def test_get_text_corpus( + self, + tmp_path, + corppa_test_config, + internetpoems_data_dir, + ): + config_opts = config.get_config() + internet_poems = InternetPoems(config_opts.reference_corpora["internet_poems"]) + text_data = internet_poems.get_text_corpus() + assert isinstance(text_data, Generator) + # turn the generator into a list; sort by id so order matches input + text_data = sorted(text_data, key=lambda x: x["poem_id"]) + assert len(text_data) == len(INTERNETPOEMS_TEXTS) + assert text_data[0]["poem_id"] == INTERNETPOEMS_TEXTS[0]["id"] + assert text_data[0]["text"] == INTERNETPOEMS_TEXTS[0]["text"] + + +@pytest.fixture +def chadwyck_healey_csv(tmp_path, corppa_test_config): + "fixture to create a test version of the chadwyck-healey metadata csv file" + config_opts = config.get_config() + ch_config = config_opts.reference_corpora[ChadwyckHealey.corpus_id] + # unlink fixture file and make text path a directory + ch_config.text_path.unlink() + data_dir = ch_config.base_dir / "text_files" + data_dir.mkdir(exist_ok=True) + ch_config.text_path = data_dir + ch_meta_csv = ch_config.metadata_path + + ch_meta_csv.write_text("""id,author_lastname,author_firstname,author_birth,author_death,author_period,transl_lastname,transl_firstname,transl_birth,transl_death,title_id,title_main,title_sub,edition_id,edition_text,period,genre,rhymes +Z300475611,Robinson,Mary,1758,1800,,,,,,Z300475611,THE CAVERN OF WOE.,,Z000475579,The Poetical Works (1806),Later Eighteenth-Century 1750-1799,,y""") + return ch_meta_csv + + +class TestChadwyckHealey: + def test_init(self, corppa_test_config, chadwyck_healey_csv): + config_opts = config.get_config() + ch = ChadwyckHealey(config_opts.reference_corpora[ChadwyckHealey.corpus_id]) + assert isinstance(ch.config, config.CorpusConfig) + assert ch.config.metadata_path == chadwyck_healey_csv + + def test_get_metadata_df(self, tmp_path, corppa_test_config, chadwyck_healey_csv): + config_opts = config.get_config() + chadwyck_healey = ChadwyckHealey( + config_opts.reference_corpora[ChadwyckHealey.corpus_id] + ) + meta_df = chadwyck_healey.get_metadata_df() + assert isinstance(meta_df, pl.DataFrame) + # schema is a subset because we don't include poem lengths + assert all(key in METADATA_SCHEMA for key in meta_df.schema.keys()) + # csv fixture data currently has one row + assert meta_df.height == 1 + # get the first row as a dict and check values + meta_row = meta_df.row(0, named=True) + assert meta_row["poem_id"] == "Z300475611" + assert meta_row["author"] == "Mary Robinson" + assert meta_row["title"] == "THE CAVERN OF WOE." + assert meta_row["ref_corpus"] == chadwyck_healey.corpus_id + + def test_get_metadata_df_with_poem_length( + self, tmp_path, corppa_test_config, chadwyck_healey_csv + ): + # Create a text file for the poem to test poem length calculation + config_opts = config.get_config() + chadwyck_healey = ChadwyckHealey( + config_opts.reference_corpora[ChadwyckHealey.corpus_id] + ) + text_dir = chadwyck_healey.config.text_path + # three lines, eight words + text_content = "Line one here\nLine two here\nLine three" + text_file = text_dir / "Z300475611.txt" + text_file.write_text(text_content) + + meta_df = chadwyck_healey.get_metadata_df(poem_length=True) + assert isinstance(meta_df, pl.DataFrame) + # Should include length fields + assert "num_lines" in meta_df.columns + assert "num_words" in meta_df.columns + assert "char_len" in meta_df.columns + + meta_row = meta_df.row(0, named=True) + # 3 non-blank lines + assert meta_row["num_lines"] == 3 + # 8 words total + assert meta_row["num_words"] == 8 + # character length (including newlines) + assert meta_row["char_len"] == len(text_content) + + # get_text_corpus method is not tested here because it is inherited; + # logic is shared with InternetPoems and tested there + + +# text fixture data for other poems corpus +OTHERPOEM_METADATA = [ + # poem ids + ["Joseph-Addison_Cato", "John-Ogilvie_Ode-to-Time", "John-Dryden_Amphitryon"], + # authors + ["Joseph Addison", "John Ogilvie", "John Dryden"], + # titles + ["Cato", "Ode to Time", "Amphitryon"], +] + + +@pytest.fixture +def otherpoems_metadata_df(): + # create and return polars dataframe from fixture data above + # does NOT include ref_corpus field, to simulate other poem spreadsheet + return pl.from_records(OTHERPOEM_METADATA, schema=["poem_id", "author", "title"]) + + +class TestOtherPoems: + @patch("corppa.poetry_detection.ref_corpora.pl.read_csv") + def test_get_metadata_df( + self, mock_pl_read_csv, corppa_test_config, otherpoems_metadata_df + ): + mock_pl_read_csv.return_value = otherpoems_metadata_df + config_opts = config.get_config() + opoems = OtherPoems(config_opts.reference_corpora["other_poems"]) + meta_df = opoems.get_metadata_df() + assert isinstance(meta_df, pl.DataFrame) + # schema is a subset because we don't include poem lengths + assert all(key in METADATA_SCHEMA for key in meta_df.schema.keys()) + assert meta_df.height == len(OTHERPOEM_METADATA) + # check values on the first row + meta_row = meta_df.row(0, named=True) + assert meta_row["poem_id"] == OTHERPOEM_METADATA[0][0] + assert meta_row["author"] == OTHERPOEM_METADATA[1][0] + assert meta_row["title"] == OTHERPOEM_METADATA[2][0] + assert meta_row["ref_corpus"] == opoems.corpus_id + + mock_pl_read_csv.assert_called_with( + opoems.config.metadata_path, schema=METADATA_SCHEMA + ) + + +# because this method instantiates the ref_corpus objects, +# data directories must pass validation checks + + +def test_all_corpora(corppa_test_config): + all_ref_corpora = all_corpora() + assert all( + isinstance(ref_corpus, BaseReferenceCorpus) for ref_corpus in all_ref_corpora + ) + corpus_classes = [ref_corpus.__class__ for ref_corpus in all_ref_corpora] + # order indicates priority, so check both presence and order + assert corpus_classes == [InternetPoems, ChadwyckHealey, OtherPoems] + + +def test_fulltext_corpora(corppa_test_config): + fulltext_ref_corpora = fulltext_corpora() + assert all( + isinstance(ref_corpus, BaseReferenceCorpus) + for ref_corpus in fulltext_ref_corpora + ) + corpus_classes = [ref_corpus.__class__ for ref_corpus in fulltext_ref_corpora] + # other poems is currently our only metadata-only reference corpus + assert OtherPoems not in corpus_classes + + +def test_compile_metadata_df( + tmp_path, + corppa_test_config, + internetpoems_data_dir, + chadwyck_healey_csv, + otherpoems_metadata_df, +): + # data fixtures should ensure that all the expected directories exist + + # add corpus id to other poems data frame and patch it to be returned + otherpoems_metadata_df = otherpoems_metadata_df.with_columns( + ref_corpus=pl.lit(OtherPoems.corpus_id) + ) + with patch.object( + OtherPoems, "get_metadata_df", return_value=otherpoems_metadata_df + ): + compiled_metadata = compile_metadata_df() + + assert isinstance(compiled_metadata, pl.DataFrame) + assert compiled_metadata.schema == METADATA_SCHEMA + assert ( + compiled_metadata.height + == len(INTERNETPOEMS_TEXTS) + len(OTHERPOEM_METADATA) + 1 + ) + assert set(compiled_metadata["ref_corpus"].unique().to_list()) == { + InternetPoems.corpus_id, + ChadwyckHealey.corpus_id, + OtherPoems.corpus_id, + } + + +def test_save_poem_metadata( + tmp_path, + capsys, + corppa_test_config, + internetpoems_data_dir, + chadwyck_healey_csv, + otherpoems_metadata_df, +): + # data fixtures should ensure that all the expected directories exist + + # add corpus id to other poems data frame and patch it to be returned + otherpoems_metadata_df = otherpoems_metadata_df.with_columns( + ref_corpus=pl.lit(OtherPoems.corpus_id) + ) + with patch.object( + OtherPoems, "get_metadata_df", return_value=otherpoems_metadata_df + ): + # create a path reference for the file we want to create + output_file = tmp_path / "poem_meta.csv" + save_poem_metadata(output_file) + assert output_file.exists() + # check output + captured = capsys.readouterr() + # create vs replace + assert "Creating" in captured.out + # output currently includes summary numbers + assert "6 poem metadata entries" in captured.out + + # run again when the file already exists + save_poem_metadata(output_file) + captured = capsys.readouterr() + assert "Replacing" in captured.out + + +def test_save_poem_metadata_with_cluster_ids( + tmp_path, + capsys, + corppa_test_config, + internetpoems_data_dir, + otherpoems_metadata_df, + chadwyck_healey_csv, +): + # test compiling in poem cluster ids; + # confirm left join works as desired, adding clusters or nulls + # for poems that do not have a cluster id + + # made up cluster for testing purposes, with ids from fixtures + cluster_id_df = pl.DataFrame( + data={ + "poem_id": ["Robert-Burns_Mary", "Z300475611"], + "cluster_id": ["mary", "mary"], + } + ) + # add corpus id to other poems data frame and patch it to be returned + otherpoems_metadata_df = otherpoems_metadata_df.with_columns( + ref_corpus=pl.lit(OtherPoems.corpus_id) + ) + with patch.object( + OtherPoems, "get_metadata_df", return_value=otherpoems_metadata_df + ): + # create a path reference for the file we want to create + output_file = tmp_path / "poem_meta.csv" + save_poem_metadata(output_file, poem_clusters_df=cluster_id_df) + assert output_file.is_file() + # check output + df = pl.read_csv(output_file) + # should still have all rows + assert df.height == 6 + # 2 poems should have cluster id + assert "cluster_id" in df.columns + assert df.filter(pl.col.cluster_id.is_not_null()).height == 2 + # others should be null + assert df.filter(pl.col.cluster_id.is_null()).height == 4 + + captured = capsys.readouterr() + # check output reporting on cluster ids + assert "2 poems with cluster ids" in captured.out + assert "(1 unique cluster)" in captured.out + + +def test_save_poem_metadata_with_excerpts( + tmp_path, + capsys, + corppa_test_config, + internetpoems_data_dir, + chadwyck_healey_csv, + otherpoems_metadata_df, +): + # Test the case where excerpts_df is provided - tests aggregation logic + + # add corpus id to other poems data frame and patch it to be returned + otherpoems_metadata_df = otherpoems_metadata_df.with_columns( + ref_corpus=pl.lit(OtherPoems.corpus_id) + ) + + # Create sample excerpts dataframe with poem data + # Use poem IDs from the INTERNETPOEMS_TEXTS global variable + excerpts_df = pl.from_dicts( + [ + # two excerpts for poem 0 from the same work, two different pages + { + "poem_id": INTERNETPOEMS_TEXTS[0]["id"], + "excerpt_id": "p@1:10", + "ppa_work_id": "work1", + "page_id": "page1", + }, + { + "poem_id": INTERNETPOEMS_TEXTS[0]["id"], + "excerpt_id": "p@3:30", + "ppa_work_id": "work1", + "page_id": "page2", + }, + # one excerpt for poem 2 + { + "poem_id": INTERNETPOEMS_TEXTS[1]["id"], + "excerpt_id": "ex3", + "ppa_work_id": "work2", + "page_id": "page3", + }, + ] + ) + + aggregation_fields = ["num_excerpts", "num_ppa_works", "num_ppa_pages"] + + with patch.object( + OtherPoems, "get_metadata_df", return_value=otherpoems_metadata_df + ): + output_file = tmp_path / "poem_meta.csv" + save_poem_metadata(output_file, excerpts_df=excerpts_df) + assert output_file.exists() + + # Read the output CSV and check for aggregate columns + result_df = pl.read_csv(output_file) + # all fields should be present + for field in aggregation_fields: + assert field in result_df.columns + + # Check that poem with 2 excerpts has correct counts + psalms_row = result_df.filter( + pl.col("poem_id") == INTERNETPOEMS_TEXTS[0]["id"] + ).row(0, named=True) + # two excerpts from one work, different pages + assert psalms_row["num_excerpts"] == 2 + assert psalms_row["num_ppa_works"] == 1 + assert psalms_row["num_ppa_pages"] == 2 + + # Check that poem with 1 excerpt has correct counts + mary_row = result_df.filter(pl.col("poem_id") == INTERNETPOEMS_TEXTS[1]["id"]).row( + 0, named=True + ) + # one excerpt, all counts are 1 + assert all(mary_row[value] == 1 for value in aggregation_fields) + + # Check that poems without excerpts (from otherpoems) have zero counts + for poem_info in result_df.filter( + pl.col("poem_id").is_in(OTHERPOEM_METADATA[0]) + ).iter_rows(named=True): + assert all(poem_info[value] == 0 for value in aggregation_fields) diff --git a/test/test_poetry_detection/test_refmatcha.py b/tests/test_poetry_detection/test_refmatcha.py similarity index 100% rename from test/test_poetry_detection/test_refmatcha.py rename to tests/test_poetry_detection/test_refmatcha.py diff --git a/test/test_utils/test_build_text_corpus.py b/tests/test_utils/test_build_text_corpus.py similarity index 74% rename from test/test_utils/test_build_text_corpus.py rename to tests/test_utils/test_build_text_corpus.py index d02202b9..69b991fb 100644 --- a/test/test_utils/test_build_text_corpus.py +++ b/tests/test_utils/test_build_text_corpus.py @@ -1,16 +1,15 @@ # Copyright (c) 2024-2025, Center for Digital Humanities, Princeton University # SPDX-License-Identifier: Apache-2.0 +import tarfile from inspect import isgenerator -from pathlib import Path from unittest.mock import call, patch -import pytest - from corppa.utils.build_text_corpus import ( build_text_corpus, get_text_record, save_text_corpus, + text_corpus_from_tarfile, ) @@ -54,6 +53,25 @@ def test_build_text_corpus(mock_get_text_record, tmp_path): mock_get_text_record.assert_has_calls([call(txt_b), call(txt_c)]) +def test_build_text_corpus_from_tarfile(tmp_path): + # create tar.gzip of text files to test + tarfile_path = tmp_path / "texts.tar.gz" + textfile = tmp_path / "foo.txt" + textfile.write_text("some texty text") + osx_meta_file = tmp_path / "._meta" + osx_meta_file.touch() + + with tarfile.open(tarfile_path, "w:gz") as tar: + tar.add(textfile) + tar.add(osx_meta_file) + + # should ignore the meta file and result in a corpus with one entry + corpus = list(text_corpus_from_tarfile(tarfile_path, disable_progress=True)) + assert len(corpus) == 1 + assert corpus[0]["id"] == "foo" + assert corpus[0]["text"] == "some texty text" + + @patch("corppa.utils.build_text_corpus.build_text_corpus") @patch("corppa.utils.build_text_corpus.orjsonl") def test_save_text_corpus(mock_orjsonl, mock_build_text_corpus): diff --git a/test/test_utils/test_filter.py b/tests/test_utils/test_filter.py similarity index 100% rename from test/test_utils/test_filter.py rename to tests/test_utils/test_filter.py diff --git a/test/test_utils/test_path_utils.py b/tests/test_utils/test_path_utils.py similarity index 100% rename from test/test_utils/test_path_utils.py rename to tests/test_utils/test_path_utils.py