pdacek 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pdacek/__init__.py +116 -0
- pdacek/__main__.py +6 -0
- pdacek/cli.py +98 -0
- pdacek/compat.py +129 -0
- pdacek/programs/__init__.py +107 -0
- pdacek/programs/pl01_preprocessing.py +80 -0
- pdacek/programs/pl02_ngrams.py +66 -0
- pdacek/programs/pl03_edit_distance.py +89 -0
- pdacek/programs/pl04_parsing.py +150 -0
- pdacek/programs/pl05_naive_bayes.py +95 -0
- pdacek/py.typed +0 -0
- pdacek/scripts/pl01_preprocessing.py +62 -0
- pdacek/scripts/pl02_ngrams.py +46 -0
- pdacek/scripts/pl03_edit_distance.py +47 -0
- pdacek/scripts/pl04_parsing.py +95 -0
- pdacek/scripts/pl05_naive_bayes.py +53 -0
- pdacek-0.1.0.dist-info/METADATA +19 -0
- pdacek-0.1.0.dist-info/RECORD +21 -0
- pdacek-0.1.0.dist-info/WHEEL +4 -0
- pdacek-0.1.0.dist-info/entry_points.txt +2 -0
- pdacek-0.1.0.dist-info/licenses/LICENSE +21 -0
pdacek/__init__.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""pdacek - a plain, notebook-friendly NLP lab toolkit.
|
|
2
|
+
|
|
3
|
+
Five self-contained experiments, each available three ways:
|
|
4
|
+
|
|
5
|
+
* run it inline -> ``pdacek.p3()``
|
|
6
|
+
* read the code -> ``print(pdacek.source(3))``
|
|
7
|
+
* write it to disk -> ``pdacek.save(3)``
|
|
8
|
+
|
|
9
|
+
The functions are also importable individually, e.g.
|
|
10
|
+
``from pdacek import min_edit_distance``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Dict, List, Optional
|
|
17
|
+
|
|
18
|
+
from .programs import PROGRAMS, numbers, run, source, titles
|
|
19
|
+
from .programs.pl01_preprocessing import (
|
|
20
|
+
filtration,
|
|
21
|
+
preprocess,
|
|
22
|
+
stopword_removal,
|
|
23
|
+
stemming,
|
|
24
|
+
tokenization,
|
|
25
|
+
validation,
|
|
26
|
+
)
|
|
27
|
+
from .programs.pl02_ngrams import build_corpus, ngram_counts, ngram_probabilities
|
|
28
|
+
from .programs.pl03_edit_distance import edit_table, med, min_edit_distance, trace_operations
|
|
29
|
+
from .programs.pl04_parsing import Parser
|
|
30
|
+
from .programs.pl05_naive_bayes import NaiveBayes
|
|
31
|
+
|
|
32
|
+
__version__ = "0.1.0"
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"__version__",
|
|
36
|
+
"programs",
|
|
37
|
+
"run",
|
|
38
|
+
"source",
|
|
39
|
+
"save",
|
|
40
|
+
"p1",
|
|
41
|
+
"p2",
|
|
42
|
+
"p3",
|
|
43
|
+
"p4",
|
|
44
|
+
"p5",
|
|
45
|
+
# helpers
|
|
46
|
+
"tokenization",
|
|
47
|
+
"filtration",
|
|
48
|
+
"validation",
|
|
49
|
+
"stopword_removal",
|
|
50
|
+
"stemming",
|
|
51
|
+
"preprocess",
|
|
52
|
+
"build_corpus",
|
|
53
|
+
"ngram_counts",
|
|
54
|
+
"ngram_probabilities",
|
|
55
|
+
"edit_table",
|
|
56
|
+
"med",
|
|
57
|
+
"min_edit_distance",
|
|
58
|
+
"trace_operations",
|
|
59
|
+
"Parser",
|
|
60
|
+
"NaiveBayes",
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def programs() -> Dict[int, str]:
|
|
65
|
+
"""Return ``{number: title}`` for every bundled experiment."""
|
|
66
|
+
return titles()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def save(number: int, directory: "str | Path" = ".") -> Path:
|
|
70
|
+
"""Write a program listing to ``directory`` and return its path."""
|
|
71
|
+
program = PROGRAMS[int(number)]
|
|
72
|
+
target = Path(directory) / program.script_name
|
|
73
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
target.write_text(source(number), encoding="utf-8")
|
|
75
|
+
return target
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _run_all() -> List[int]:
|
|
79
|
+
for number in numbers():
|
|
80
|
+
run(number)
|
|
81
|
+
print()
|
|
82
|
+
return numbers()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def p1() -> None:
|
|
86
|
+
"""Program 1 - text preprocessing pipeline."""
|
|
87
|
+
run(1)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def p2() -> None:
|
|
91
|
+
"""Program 2 - unigram, bigram, trigram probabilities."""
|
|
92
|
+
run(2)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def p3() -> None:
|
|
96
|
+
"""Program 3 - minimum edit distance."""
|
|
97
|
+
run(3)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def p4() -> None:
|
|
101
|
+
"""Program 4 - top-down and bottom-up parsing."""
|
|
102
|
+
run(4)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def p5() -> None:
|
|
106
|
+
"""Program 5 - add-one smoothed Naive Bayes."""
|
|
107
|
+
run(5)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def __getattr__(name: str) -> Optional[object]: # pragma: no cover - tiny shim
|
|
111
|
+
"""Allow ``pdacek.p6`` style access to fail with a clear message."""
|
|
112
|
+
if name.startswith("p") and name[1:].isdigit():
|
|
113
|
+
raise AttributeError(
|
|
114
|
+
f"no program {name[1:]} in this release; available: {numbers()}"
|
|
115
|
+
)
|
|
116
|
+
raise AttributeError(f"module 'pdacek' has no attribute {name!r}")
|
pdacek/__main__.py
ADDED
pdacek/cli.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Command line entry point: ``pdacek 3``, ``pdacek --list``, ..."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from typing import List, Optional
|
|
8
|
+
|
|
9
|
+
from . import __version__, numbers, programs, run, save, source
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
13
|
+
parser = argparse.ArgumentParser(
|
|
14
|
+
prog="pdacek",
|
|
15
|
+
description="Run the bundled NLP lab experiments without opening an editor.",
|
|
16
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
17
|
+
epilog=(
|
|
18
|
+
"examples:\n"
|
|
19
|
+
" pdacek run every experiment\n"
|
|
20
|
+
" pdacek 3 run experiment 3\n"
|
|
21
|
+
" pdacek p3 same thing\n"
|
|
22
|
+
" pdacek 3 --source print the listing instead of running it\n"
|
|
23
|
+
" pdacek 3 --save write the listing to the current directory\n"
|
|
24
|
+
),
|
|
25
|
+
)
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
"program",
|
|
28
|
+
nargs="?",
|
|
29
|
+
help="experiment number (1-5) or name such as p3",
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument("--list", "-l", action="store_true", help="list experiments and exit")
|
|
32
|
+
parser.add_argument("--source", "-s", action="store_true", help="print the listing")
|
|
33
|
+
parser.add_argument(
|
|
34
|
+
"--save",
|
|
35
|
+
nargs="?",
|
|
36
|
+
const=".",
|
|
37
|
+
metavar="DIR",
|
|
38
|
+
help="write the listing to DIR (default: current directory)",
|
|
39
|
+
)
|
|
40
|
+
parser.add_argument("--version", "-V", action="version", version=f"pdacek {__version__}")
|
|
41
|
+
return parser
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _parse_number(raw: Optional[str]) -> Optional[int]:
|
|
45
|
+
if raw is None:
|
|
46
|
+
return None
|
|
47
|
+
cleaned = raw.strip().lower().lstrip("p").replace("program", "").strip(" _-")
|
|
48
|
+
try:
|
|
49
|
+
return int(cleaned)
|
|
50
|
+
except ValueError:
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def list_programs() -> None:
|
|
55
|
+
print(f"pdacek {__version__} - bundled experiments\n")
|
|
56
|
+
for number, title in programs().items():
|
|
57
|
+
print(f" {number}. {title}")
|
|
58
|
+
print("\nRun one with: pdacek <number>")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
62
|
+
args = build_parser().parse_args(argv)
|
|
63
|
+
|
|
64
|
+
if args.list:
|
|
65
|
+
list_programs()
|
|
66
|
+
return 0
|
|
67
|
+
|
|
68
|
+
number = _parse_number(args.program)
|
|
69
|
+
if args.program is not None and number is None:
|
|
70
|
+
print(f"error: could not read a program number from {args.program!r}", file=sys.stderr)
|
|
71
|
+
print(f"available: {', '.join(str(n) for n in numbers())}", file=sys.stderr)
|
|
72
|
+
return 2
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
if number is None:
|
|
76
|
+
for n in numbers():
|
|
77
|
+
run(n)
|
|
78
|
+
print()
|
|
79
|
+
return 0
|
|
80
|
+
|
|
81
|
+
if args.save is not None:
|
|
82
|
+
path = save(number, args.save)
|
|
83
|
+
print(f"wrote {path}")
|
|
84
|
+
return 0
|
|
85
|
+
|
|
86
|
+
if args.source:
|
|
87
|
+
print(source(number))
|
|
88
|
+
return 0
|
|
89
|
+
|
|
90
|
+
run(number)
|
|
91
|
+
return 0
|
|
92
|
+
except KeyError as exc:
|
|
93
|
+
print(f"error: {exc.args[0]}", file=sys.stderr)
|
|
94
|
+
return 2
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
if __name__ == "__main__": # pragma: no cover
|
|
98
|
+
raise SystemExit(main())
|
pdacek/compat.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Shared helpers: tokenisation and NLTK corpus access with safe fallbacks.
|
|
2
|
+
|
|
3
|
+
The lab machines this toolkit targets are often offline, so every helper here
|
|
4
|
+
degrades to a pure-``re`` implementation instead of raising when an NLTK
|
|
5
|
+
resource (``punkt``, ``stopwords``) has not been downloaded yet.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from typing import List
|
|
12
|
+
|
|
13
|
+
try: # NLTK is a declared dependency, but keep the package importable without it.
|
|
14
|
+
import nltk
|
|
15
|
+
except ImportError: # pragma: no cover - defensive
|
|
16
|
+
nltk = None # type: ignore[assignment]
|
|
17
|
+
|
|
18
|
+
__all__ = ["tokenize", "stopwords_en", "stemmer", "ensure_punkt", "nltk_available"]
|
|
19
|
+
|
|
20
|
+
_TOKEN_RE = re.compile(r"[A-Za-z]+(?:'[A-Za-z]+)?|\d+|[^\sA-Za-z\d]")
|
|
21
|
+
|
|
22
|
+
# Small built-in list, used only when the NLTK corpus is unavailable.
|
|
23
|
+
_FALLBACK_STOPWORDS = frozenset(
|
|
24
|
+
"""
|
|
25
|
+
a about above after again against all am an and any are as at be because been
|
|
26
|
+
before being below between both but by can did do does doing down during each
|
|
27
|
+
few for from further had has have having he her here hers herself him himself
|
|
28
|
+
his how i if in into is it its itself just me more most my myself no nor not
|
|
29
|
+
now of off on once only or other our ours ourselves out over own same she
|
|
30
|
+
should so some such than that the their theirs them themselves then there
|
|
31
|
+
these they this those through to too under until up very was we were what
|
|
32
|
+
when where which while who whom why will with you your yours yourself
|
|
33
|
+
yourselves
|
|
34
|
+
""".split()
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def nltk_available() -> bool:
|
|
39
|
+
"""Return ``True`` when NLTK can be imported."""
|
|
40
|
+
return nltk is not None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _resource_present(resource: str, package: str) -> bool:
|
|
44
|
+
if nltk is None:
|
|
45
|
+
return False
|
|
46
|
+
try:
|
|
47
|
+
nltk.data.find(f"{package}/{resource}")
|
|
48
|
+
return True
|
|
49
|
+
except LookupError:
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def ensure_punkt() -> bool:
|
|
54
|
+
"""Make sure a sentence/word tokenizer model exists; return availability.
|
|
55
|
+
|
|
56
|
+
NLTK 3.9 renamed the tokenizer data to ``punkt_tab``; both names are tried
|
|
57
|
+
and any download failure (offline machine) is swallowed on purpose.
|
|
58
|
+
"""
|
|
59
|
+
if nltk is None:
|
|
60
|
+
return False
|
|
61
|
+
for resource in ("punkt", "punkt_tab"):
|
|
62
|
+
if _resource_present(resource, "tokenizers"):
|
|
63
|
+
return True
|
|
64
|
+
try:
|
|
65
|
+
nltk.download(resource, quiet=True)
|
|
66
|
+
except Exception: # pragma: no cover - network/permission problems
|
|
67
|
+
pass
|
|
68
|
+
if _resource_present(resource, "tokenizers"):
|
|
69
|
+
return True
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def tokenize(text: str) -> List[str]:
|
|
74
|
+
"""Split ``text`` into word and punctuation tokens.
|
|
75
|
+
|
|
76
|
+
Uses NLTK's ``word_tokenize`` when the model is available, otherwise a
|
|
77
|
+
regex tokenizer that keeps punctuation as its own token, which is what the
|
|
78
|
+
filtration and script-validation steps in Program 1 need.
|
|
79
|
+
"""
|
|
80
|
+
if ensure_punkt():
|
|
81
|
+
try:
|
|
82
|
+
return nltk.word_tokenize(text) # type: ignore[union-attr]
|
|
83
|
+
except LookupError: # pragma: no cover - stale resource index
|
|
84
|
+
pass
|
|
85
|
+
return _TOKEN_RE.findall(text)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def stopwords_en() -> frozenset:
|
|
89
|
+
"""Return the English stop-word set (NLTK corpus when available)."""
|
|
90
|
+
if nltk is not None:
|
|
91
|
+
try:
|
|
92
|
+
return frozenset(nltk.corpus.stopwords.words("english")) # type: ignore[union-attr]
|
|
93
|
+
except LookupError:
|
|
94
|
+
try:
|
|
95
|
+
nltk.download("stopwords", quiet=True) # type: ignore[union-attr]
|
|
96
|
+
return frozenset(nltk.corpus.stopwords.words("english")) # type: ignore[union-attr]
|
|
97
|
+
except Exception:
|
|
98
|
+
pass
|
|
99
|
+
return _FALLBACK_STOPWORDS
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class _SimpleStemmer:
|
|
103
|
+
"""Very small suffix stripper used when NLTK is not importable."""
|
|
104
|
+
|
|
105
|
+
_SUFFIXES = (
|
|
106
|
+
"ational", "ization", "iveness", "ingly", "edly", "ments",
|
|
107
|
+
"ing", "ies", "ied", "ed", "es", "s",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def stem(self, word: str) -> str:
|
|
111
|
+
low = word.lower()
|
|
112
|
+
for suffix in self._SUFFIXES:
|
|
113
|
+
if len(low) > len(suffix) + 2 and low.endswith(suffix):
|
|
114
|
+
if suffix == "ies":
|
|
115
|
+
return low[: -len(suffix)] + "y"
|
|
116
|
+
return low[: -len(suffix)]
|
|
117
|
+
return low
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def stemmer():
|
|
121
|
+
"""Return a Porter stemmer (NLTK) or a minimal fallback stemmer."""
|
|
122
|
+
if nltk is not None:
|
|
123
|
+
try:
|
|
124
|
+
from nltk.stem import PorterStemmer
|
|
125
|
+
|
|
126
|
+
return PorterStemmer()
|
|
127
|
+
except Exception: # pragma: no cover
|
|
128
|
+
pass
|
|
129
|
+
return _SimpleStemmer()
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Program modules and the registry that maps numbers to them."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from importlib import resources
|
|
7
|
+
from types import ModuleType
|
|
8
|
+
from typing import Dict, List
|
|
9
|
+
|
|
10
|
+
from . import (
|
|
11
|
+
pl01_preprocessing,
|
|
12
|
+
pl02_ngrams,
|
|
13
|
+
pl03_edit_distance,
|
|
14
|
+
pl04_parsing,
|
|
15
|
+
pl05_naive_bayes,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = ["Program", "PROGRAMS", "get", "run", "source", "titles", "numbers"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class Program:
|
|
23
|
+
"""Metadata for one lab program."""
|
|
24
|
+
|
|
25
|
+
number: int
|
|
26
|
+
slug: str
|
|
27
|
+
title: str
|
|
28
|
+
module: ModuleType
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def script_name(self) -> str:
|
|
32
|
+
return f"{self.slug}.py"
|
|
33
|
+
|
|
34
|
+
def script(self) -> str:
|
|
35
|
+
"""Return the standalone, paste-into-a-notebook listing."""
|
|
36
|
+
return (
|
|
37
|
+
resources.files("pdacek")
|
|
38
|
+
.joinpath("scripts", self.script_name)
|
|
39
|
+
.read_text(encoding="utf-8")
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def run(self, *args, **kwargs) -> None:
|
|
43
|
+
self.module.main(*args, **kwargs)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
PROGRAMS: Dict[int, Program] = {
|
|
47
|
+
1: Program(
|
|
48
|
+
1,
|
|
49
|
+
"pl01_preprocessing",
|
|
50
|
+
"Text preprocessing: tokenisation, filtration, script validation, "
|
|
51
|
+
"stop-word removal, stemming",
|
|
52
|
+
pl01_preprocessing,
|
|
53
|
+
),
|
|
54
|
+
2: Program(
|
|
55
|
+
2,
|
|
56
|
+
"pl02_ngrams",
|
|
57
|
+
"Unigram, bigram and trigram probability estimates",
|
|
58
|
+
pl02_ngrams,
|
|
59
|
+
),
|
|
60
|
+
3: Program(
|
|
61
|
+
3,
|
|
62
|
+
"pl03_edit_distance",
|
|
63
|
+
"Minimum edit distance with test cases and a DP table",
|
|
64
|
+
pl03_edit_distance,
|
|
65
|
+
),
|
|
66
|
+
4: Program(
|
|
67
|
+
4,
|
|
68
|
+
"pl04_parsing",
|
|
69
|
+
"Top-down and bottom-up parsing from a context-free grammar",
|
|
70
|
+
pl04_parsing,
|
|
71
|
+
),
|
|
72
|
+
5: Program(
|
|
73
|
+
5,
|
|
74
|
+
"pl05_naive_bayes",
|
|
75
|
+
"Add-one smoothed Naive Bayes movie-review classification",
|
|
76
|
+
pl05_naive_bayes,
|
|
77
|
+
),
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def numbers() -> List[int]:
|
|
82
|
+
"""Available program numbers, ascending."""
|
|
83
|
+
return sorted(PROGRAMS)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def titles() -> Dict[int, str]:
|
|
87
|
+
"""Mapping of program number to one-line title."""
|
|
88
|
+
return {n: p.title for n, p in sorted(PROGRAMS.items())}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def get(number: int) -> Program:
|
|
92
|
+
"""Look up a program by number, with a helpful error message."""
|
|
93
|
+
try:
|
|
94
|
+
return PROGRAMS[int(number)]
|
|
95
|
+
except (KeyError, TypeError, ValueError):
|
|
96
|
+
available = ", ".join(str(n) for n in numbers())
|
|
97
|
+
raise KeyError(f"unknown program {number!r}; available: {available}") from None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def run(number: int) -> None:
|
|
101
|
+
"""Execute one program and print its output."""
|
|
102
|
+
get(number).run()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def source(number: int) -> str:
|
|
106
|
+
"""Return the standalone script for one program."""
|
|
107
|
+
return get(number).script()
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Program 1 - text preprocessing pipeline.
|
|
2
|
+
|
|
3
|
+
Tokenisation -> filtration -> script validation -> stop-word removal ->
|
|
4
|
+
stemming, exposed as small composable steps plus a one-call ``preprocess``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from typing import Dict, List
|
|
11
|
+
|
|
12
|
+
from ..compat import stemmer, stopwords_en, tokenize
|
|
13
|
+
|
|
14
|
+
SAMPLE_TEXT = (
|
|
15
|
+
"Natural Language Processing is a fascinating field of AI.\n"
|
|
16
|
+
"It involves processing human languages, like English!"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
_NON_ALPHA = re.compile(r"[^a-zA-Z]")
|
|
20
|
+
_ALPHA_ONLY = re.compile(r"^[a-zA-Z]+$")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def tokenization(text: str = SAMPLE_TEXT) -> List[str]:
|
|
24
|
+
"""Step 1 - split raw text into tokens, punctuation included."""
|
|
25
|
+
return tokenize(text)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def filtration(tokens: List[str]) -> List[str]:
|
|
29
|
+
"""Step 2 - drop non-alphabetic characters, then drop empty strings."""
|
|
30
|
+
stripped = [_NON_ALPHA.sub("", w) for w in tokens]
|
|
31
|
+
return [w for w in stripped if w]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def validation(tokens: List[str]) -> List[str]:
|
|
35
|
+
"""Step 3 - keep only tokens that are pure ASCII English letters."""
|
|
36
|
+
return [w for w in tokens if _ALPHA_ONLY.match(w)]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def stopword_removal(tokens: List[str]) -> List[str]:
|
|
40
|
+
"""Step 4 - remove English stop words (case-insensitive)."""
|
|
41
|
+
stop = stopwords_en()
|
|
42
|
+
return [w for w in tokens if w.lower() not in stop]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def stemming(tokens: List[str]) -> List[str]:
|
|
46
|
+
"""Step 5 - Porter stemming."""
|
|
47
|
+
porter = stemmer()
|
|
48
|
+
return [porter.stem(w) for w in tokens]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def preprocess(text: str = SAMPLE_TEXT) -> Dict[str, List[str]]:
|
|
52
|
+
"""Run the full pipeline and return every intermediate stage."""
|
|
53
|
+
tokens = tokenization(text)
|
|
54
|
+
filtered = filtration(tokens)
|
|
55
|
+
validated = validation(filtered)
|
|
56
|
+
cleaned = stopword_removal(validated)
|
|
57
|
+
stemmed = stemming(cleaned)
|
|
58
|
+
return {
|
|
59
|
+
"tokens": tokens,
|
|
60
|
+
"filtered": filtered,
|
|
61
|
+
"validated": validated,
|
|
62
|
+
"cleaned": cleaned,
|
|
63
|
+
"stemmed": stemmed,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def main(text: str = SAMPLE_TEXT) -> None:
|
|
68
|
+
"""Reproduce the lab-sheet output."""
|
|
69
|
+
stages = preprocess(text)
|
|
70
|
+
|
|
71
|
+
print("Original Text:\n", text)
|
|
72
|
+
print("\nTokens:\n", stages["tokens"])
|
|
73
|
+
print("\nFiltered Tokens:\n", stages["filtered"])
|
|
74
|
+
print("\nValidated Tokens (English Only):\n", stages["validated"])
|
|
75
|
+
print("\nTokens after Stop Word Removal:\n", stages["cleaned"])
|
|
76
|
+
print("\nStemmed Tokens:\n", stages["stemmed"])
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
if __name__ == "__main__": # pragma: no cover
|
|
80
|
+
main()
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Program 2 - unigram, bigram and trigram probability estimates.
|
|
2
|
+
|
|
3
|
+
Unigram probabilities use maximum likelihood over the whole corpus; higher
|
|
4
|
+
orders use the conditional estimate ``C(w1..wn) / C(w1..wn-1)``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections import Counter
|
|
10
|
+
from typing import Dict, Iterable, List, Sequence, Tuple
|
|
11
|
+
|
|
12
|
+
from ..compat import tokenize
|
|
13
|
+
|
|
14
|
+
SENTENCES: List[str] = [
|
|
15
|
+
"The quick brown fox jumps over the lazy dog",
|
|
16
|
+
"The dog barks at the fox",
|
|
17
|
+
"The fox is quick and the dog is lazy",
|
|
18
|
+
"The quick dog jumps over the lazy fox",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _ngrams(sequence: Sequence[str], n: int) -> Iterable[Tuple[str, ...]]:
|
|
23
|
+
if n <= 0:
|
|
24
|
+
raise ValueError("n must be >= 1")
|
|
25
|
+
for i in range(len(sequence) - n + 1):
|
|
26
|
+
yield tuple(sequence[i : i + n])
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def build_corpus(sentences: Sequence[str] = SENTENCES) -> List[List[str]]:
|
|
30
|
+
"""Lower-case and tokenise every sentence."""
|
|
31
|
+
return [tokenize(s.lower()) for s in sentences]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def ngram_counts(corpus: Sequence[Sequence[str]], n: int) -> Counter:
|
|
35
|
+
"""Count every n-gram in the corpus."""
|
|
36
|
+
return Counter(g for sentence in corpus for g in _ngrams(sentence, n))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def ngram_probabilities(corpus: Sequence[Sequence[str]], n: int) -> Dict[Tuple[str, ...], float]:
|
|
40
|
+
"""Return ``{ngram: probability}`` for the requested order ``n``."""
|
|
41
|
+
counts = ngram_counts(corpus, n)
|
|
42
|
+
if n == 1:
|
|
43
|
+
total = sum(counts.values())
|
|
44
|
+
return {gram: count / total for gram, count in counts.items()}
|
|
45
|
+
|
|
46
|
+
previous = ngram_counts(corpus, n - 1)
|
|
47
|
+
probabilities: Dict[Tuple[str, ...], float] = {}
|
|
48
|
+
for gram, count in counts.items():
|
|
49
|
+
denominator = previous.get(gram[:-1], 0)
|
|
50
|
+
if denominator:
|
|
51
|
+
probabilities[gram] = count / denominator
|
|
52
|
+
return probabilities
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def main(sentences: Sequence[str] = SENTENCES) -> None:
|
|
56
|
+
"""Reproduce the lab-sheet output for n = 1, 2, 3."""
|
|
57
|
+
corpus = build_corpus(sentences)
|
|
58
|
+
for n in (1, 2, 3):
|
|
59
|
+
probabilities = ngram_probabilities(corpus, n)
|
|
60
|
+
print(f"\n{n}-Gram Probabilities:")
|
|
61
|
+
for gram, probability in probabilities.items():
|
|
62
|
+
print(f"{' '.join(gram)}: {probability:.4f}")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
if __name__ == "__main__": # pragma: no cover
|
|
66
|
+
main()
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Program 3 - minimum edit distance (Levenshtein, unit cost).
|
|
2
|
+
|
|
3
|
+
The dynamic-programming table is kept so it can be printed like the lab sheet.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import List, Sequence, Tuple
|
|
9
|
+
|
|
10
|
+
TESTS: List[Tuple[str, str]] = [
|
|
11
|
+
("kitten", "sitting"),
|
|
12
|
+
("flaw", "lawn"),
|
|
13
|
+
("intention", "execution"),
|
|
14
|
+
("apple", "aple"),
|
|
15
|
+
("", "hello"),
|
|
16
|
+
("hello", ""),
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def edit_table(a: str, b: str) -> List[List[int]]:
|
|
21
|
+
"""Return the full ``(len(a)+1) x (len(b)+1)`` distance matrix."""
|
|
22
|
+
m, n = len(a), len(b)
|
|
23
|
+
dp = [[0] * (n + 1) for _ in range(m + 1)]
|
|
24
|
+
|
|
25
|
+
for i in range(m + 1):
|
|
26
|
+
dp[i][0] = i
|
|
27
|
+
for j in range(n + 1):
|
|
28
|
+
dp[0][j] = j
|
|
29
|
+
|
|
30
|
+
for i in range(1, m + 1):
|
|
31
|
+
for j in range(1, n + 1):
|
|
32
|
+
if a[i - 1] == b[j - 1]:
|
|
33
|
+
dp[i][j] = dp[i - 1][j - 1]
|
|
34
|
+
else:
|
|
35
|
+
dp[i][j] = 1 + min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1])
|
|
36
|
+
return dp
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def min_edit_distance(a: str, b: str) -> int:
|
|
40
|
+
"""Levenshtein distance between ``a`` and ``b``."""
|
|
41
|
+
return edit_table(a, b)[len(a)][len(b)]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def med(a: str, b: str) -> Tuple[int, List[List[int]]]:
|
|
45
|
+
"""Lab-compatible wrapper returning ``(distance, table)``."""
|
|
46
|
+
dp = edit_table(a, b)
|
|
47
|
+
return dp[len(a)][len(b)], dp
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def display(dp: Sequence[Sequence[int]], a: str, b: str) -> None:
|
|
51
|
+
"""Print the matrix with source/target letters as headers."""
|
|
52
|
+
print("\nEdit Distance Table:")
|
|
53
|
+
print(" " + " ".join("#" + c for c in " " + b))
|
|
54
|
+
for i, row in enumerate(dp):
|
|
55
|
+
print(("#" if i == 0 else a[i - 1]), *row)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def trace_operations(a: str, b: str) -> List[str]:
|
|
59
|
+
"""Backtrace the table into edit operations (useful for the viva)."""
|
|
60
|
+
dp = edit_table(a, b)
|
|
61
|
+
i, j = len(a), len(b)
|
|
62
|
+
operations: List[str] = []
|
|
63
|
+
while i > 0 or j > 0:
|
|
64
|
+
if i > 0 and j > 0 and a[i - 1] == b[j - 1] and dp[i][j] == dp[i - 1][j - 1]:
|
|
65
|
+
operations.append(f"keep '{a[i - 1]}'")
|
|
66
|
+
i, j = i - 1, j - 1
|
|
67
|
+
elif i > 0 and j > 0 and dp[i][j] == dp[i - 1][j - 1] + 1:
|
|
68
|
+
operations.append(f"substitute '{a[i - 1]}' -> '{b[j - 1]}'")
|
|
69
|
+
i, j = i - 1, j - 1
|
|
70
|
+
elif i > 0 and dp[i][j] == dp[i - 1][j] + 1:
|
|
71
|
+
operations.append(f"delete '{a[i - 1]}'")
|
|
72
|
+
i -= 1
|
|
73
|
+
else:
|
|
74
|
+
operations.append(f"insert '{b[j - 1]}'")
|
|
75
|
+
j -= 1
|
|
76
|
+
return list(reversed(operations))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def main(tests: Sequence[Tuple[str, str]] = TESTS) -> None:
|
|
80
|
+
"""Reproduce the lab-sheet output."""
|
|
81
|
+
for a, b in tests:
|
|
82
|
+
distance, dp = med(a, b)
|
|
83
|
+
print(f"\nComparing: '{a}' -> '{b}'")
|
|
84
|
+
print("Minimum Edit Distance:", distance)
|
|
85
|
+
display(dp, a, b)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
if __name__ == "__main__": # pragma: no cover
|
|
89
|
+
main()
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Program 4 - top-down and bottom-up parsing from a context-free grammar.
|
|
2
|
+
|
|
3
|
+
``Parser`` takes the grammar as ``(lhs, rhs)`` pairs where ``rhs`` is a list of
|
|
4
|
+
symbols; symbols that never appear on a left-hand side are terminals.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Dict, List, Optional, Sequence, Tuple
|
|
10
|
+
|
|
11
|
+
Grammar = Sequence[Tuple[str, Sequence[str]]]
|
|
12
|
+
|
|
13
|
+
GRAMMAR: List[Tuple[str, List[str]]] = [
|
|
14
|
+
("S", ["NP", "VP"]),
|
|
15
|
+
("NP", ["Det", "N"]),
|
|
16
|
+
("VP", ["V", "NP"]),
|
|
17
|
+
("VP", ["V"]),
|
|
18
|
+
("Det", ["a"]),
|
|
19
|
+
("Det", ["the"]),
|
|
20
|
+
("N", ["cat"]),
|
|
21
|
+
("N", ["dog"]),
|
|
22
|
+
("V", ["chases"]),
|
|
23
|
+
("V", ["eats"]),
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
SENTENCE: List[str] = ["the", "cat", "chases", "a", "dog"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Parser:
|
|
30
|
+
"""A tiny CFG parser with both search directions."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, grammar: Grammar, start: str = "S") -> None:
|
|
33
|
+
self.rules: Dict[str, List[List[str]]] = {}
|
|
34
|
+
for lhs, rhs in grammar:
|
|
35
|
+
self.rules.setdefault(lhs, []).append(list(rhs))
|
|
36
|
+
self.start = start
|
|
37
|
+
|
|
38
|
+
# ------------------------------------------------------------------ top-down
|
|
39
|
+
def top_down_parse(self, sentence: Sequence[str]) -> Optional[List[str]]:
|
|
40
|
+
"""Leftmost recursive-descent expansion with backtracking.
|
|
41
|
+
|
|
42
|
+
Returns the resulting stack (``[start]``) on success, ``None`` on
|
|
43
|
+
failure - same contract as the lab sheet, but actually implemented.
|
|
44
|
+
"""
|
|
45
|
+
self._tokens = list(sentence)
|
|
46
|
+
self._pos = 0
|
|
47
|
+
if not self._expand(self.start):
|
|
48
|
+
return None
|
|
49
|
+
if self._pos != len(self._tokens):
|
|
50
|
+
return None
|
|
51
|
+
return [self.start]
|
|
52
|
+
|
|
53
|
+
def _expand(self, symbol: str) -> bool:
|
|
54
|
+
if symbol not in self.rules: # terminal
|
|
55
|
+
if self._pos < len(self._tokens) and self._tokens[self._pos] == symbol:
|
|
56
|
+
self._pos += 1
|
|
57
|
+
return True
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
for rhs in self.rules[symbol]:
|
|
61
|
+
mark = self._pos
|
|
62
|
+
matched = True
|
|
63
|
+
for child in rhs:
|
|
64
|
+
if not self._expand(child):
|
|
65
|
+
matched = False
|
|
66
|
+
break
|
|
67
|
+
if matched:
|
|
68
|
+
return True
|
|
69
|
+
self._pos = mark
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
# ------------------------------------------------------------------ bottom-up
|
|
73
|
+
def _rule_kinds(self):
|
|
74
|
+
"""Split the grammar into lexical, unary and binary rules (CNF view)."""
|
|
75
|
+
lexical: Dict[str, List[str]] = {}
|
|
76
|
+
unary: List[Tuple[str, str]] = []
|
|
77
|
+
binary: List[Tuple[str, str, str]] = []
|
|
78
|
+
for lhs, rhs_list in self.rules.items():
|
|
79
|
+
for rhs in rhs_list:
|
|
80
|
+
if len(rhs) == 1 and rhs[0] not in self.rules:
|
|
81
|
+
lexical.setdefault(rhs[0], []).append(lhs)
|
|
82
|
+
elif len(rhs) == 1:
|
|
83
|
+
unary.append((lhs, rhs[0]))
|
|
84
|
+
elif len(rhs) == 2:
|
|
85
|
+
binary.append((lhs, rhs[0], rhs[1]))
|
|
86
|
+
else:
|
|
87
|
+
raise ValueError(
|
|
88
|
+
f"rule {lhs} -> {' '.join(rhs)} is not supported by the "
|
|
89
|
+
"chart parser; split it into binary rules"
|
|
90
|
+
)
|
|
91
|
+
return lexical, unary, binary
|
|
92
|
+
|
|
93
|
+
def bottom_up_parse(self, sentence: Sequence[str]) -> List[str]:
|
|
94
|
+
"""Bottom-up chart (CYK-style) parse over spans of the sentence.
|
|
95
|
+
|
|
96
|
+
Terminals are lifted to their preterminals first, then spans of growing
|
|
97
|
+
length are combined - the standard bottom-up direction. Returns
|
|
98
|
+
``['S']`` when the whole sentence reduces to the start symbol,
|
|
99
|
+
otherwise the symbols left at the top of the chart.
|
|
100
|
+
"""
|
|
101
|
+
tokens = list(sentence)
|
|
102
|
+
if not tokens:
|
|
103
|
+
return []
|
|
104
|
+
|
|
105
|
+
lexical, unary, binary = self._rule_kinds()
|
|
106
|
+
|
|
107
|
+
def close(cell: set) -> set:
|
|
108
|
+
changed = True
|
|
109
|
+
while changed:
|
|
110
|
+
changed = False
|
|
111
|
+
for parent, child in unary:
|
|
112
|
+
if child in cell and parent not in cell:
|
|
113
|
+
cell.add(parent)
|
|
114
|
+
changed = True
|
|
115
|
+
return cell
|
|
116
|
+
|
|
117
|
+
chart: Dict[Tuple[int, int], set] = {}
|
|
118
|
+
for index, token in enumerate(tokens):
|
|
119
|
+
chart[(index, index + 1)] = close(set(lexical.get(token, ())))
|
|
120
|
+
|
|
121
|
+
for length in range(2, len(tokens) + 1):
|
|
122
|
+
for start in range(0, len(tokens) - length + 1):
|
|
123
|
+
end = start + length
|
|
124
|
+
cell: set = set()
|
|
125
|
+
for split in range(start + 1, end):
|
|
126
|
+
left = chart[(start, split)]
|
|
127
|
+
right = chart[(split, end)]
|
|
128
|
+
for parent, first, second in binary:
|
|
129
|
+
if first in left and second in right:
|
|
130
|
+
cell.add(parent)
|
|
131
|
+
chart[(start, end)] = close(cell)
|
|
132
|
+
|
|
133
|
+
top = chart.get((0, len(tokens)), set())
|
|
134
|
+
if self.start in top:
|
|
135
|
+
return [self.start]
|
|
136
|
+
return sorted(top) if top else tokens
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def main(
|
|
140
|
+
grammar: Grammar = GRAMMAR,
|
|
141
|
+
sentence: Sequence[str] = SENTENCE,
|
|
142
|
+
) -> None:
|
|
143
|
+
"""Reproduce the lab-sheet output."""
|
|
144
|
+
parser = Parser(grammar)
|
|
145
|
+
print("Top-Down Parse Result:", parser.top_down_parse(sentence))
|
|
146
|
+
print("Bottom-Up Parse Result:", parser.bottom_up_parse(sentence))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
if __name__ == "__main__": # pragma: no cover
|
|
150
|
+
main()
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Program 5 - add-one smoothed Naive Bayes movie-review classifier.
|
|
2
|
+
|
|
3
|
+
Posteriors are unnormalised class scores (prior x product of likelihoods),
|
|
4
|
+
which is all that is needed to compare classes.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections import defaultdict
|
|
10
|
+
from typing import Dict, Iterable, List, Sequence, Tuple
|
|
11
|
+
|
|
12
|
+
REVIEWS: List[Tuple[str, str]] = [
|
|
13
|
+
("fun, couple, love, love", "comedy"),
|
|
14
|
+
("fast, furious, shoot", "action"),
|
|
15
|
+
("couple, fly, fast, fun", "comedy"),
|
|
16
|
+
("furious, shoot, shoot, fun", "action"),
|
|
17
|
+
("fly, fast, shoot, love", "action"),
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
DOCUMENT = "fast, couple, shoot, fly"
|
|
21
|
+
SEPARATOR = ", "
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class NaiveBayes:
|
|
25
|
+
"""Multinomial Naive Bayes with add-one (Laplace) smoothing."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, alpha: float = 1.0) -> None:
|
|
28
|
+
self.alpha = alpha
|
|
29
|
+
self.counts: Dict[str, Dict[str, int]] = defaultdict(lambda: defaultdict(int))
|
|
30
|
+
self.class_totals: Dict[str, int] = {}
|
|
31
|
+
self.priors: Dict[str, float] = {}
|
|
32
|
+
self.vocabulary: set = set()
|
|
33
|
+
|
|
34
|
+
def fit(self, rows: Iterable[Tuple[str, str]], separator: str = SEPARATOR) -> "NaiveBayes":
|
|
35
|
+
classes: Dict[str, int] = defaultdict(int)
|
|
36
|
+
for review, label in rows:
|
|
37
|
+
classes[label] += 1
|
|
38
|
+
for word in review.split(separator):
|
|
39
|
+
word = word.strip()
|
|
40
|
+
if not word:
|
|
41
|
+
continue
|
|
42
|
+
self.counts[label][word] += 1
|
|
43
|
+
self.vocabulary.add(word)
|
|
44
|
+
|
|
45
|
+
total_documents = sum(classes.values())
|
|
46
|
+
self.priors = {label: n / total_documents for label, n in classes.items()}
|
|
47
|
+
self.class_totals = {label: sum(words.values()) for label, words in self.counts.items()}
|
|
48
|
+
return self
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def vocabulary_size(self) -> int:
|
|
52
|
+
return len(self.vocabulary)
|
|
53
|
+
|
|
54
|
+
def likelihood(self, label: str, word: str) -> float:
|
|
55
|
+
"""P(word | label) with add-one smoothing."""
|
|
56
|
+
numerator = self.counts[label][word] + self.alpha
|
|
57
|
+
denominator = self.class_totals.get(label, 0) + self.alpha * self.vocabulary_size
|
|
58
|
+
return numerator / denominator
|
|
59
|
+
|
|
60
|
+
def scores(self, words: Sequence[str]) -> Dict[str, float]:
|
|
61
|
+
"""Unnormalised posterior score per class."""
|
|
62
|
+
result: Dict[str, float] = {}
|
|
63
|
+
for label, prior in self.priors.items():
|
|
64
|
+
score = prior
|
|
65
|
+
for word in words:
|
|
66
|
+
score *= self.likelihood(label, word)
|
|
67
|
+
result[label] = score
|
|
68
|
+
return result
|
|
69
|
+
|
|
70
|
+
def predict(self, words: Sequence[str]) -> str:
|
|
71
|
+
scores = self.scores(words)
|
|
72
|
+
return max(scores, key=scores.get) # type: ignore[arg-type]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def main(rows: Sequence[Tuple[str, str]] = REVIEWS, document: str = DOCUMENT) -> None:
|
|
76
|
+
"""Reproduce the lab-sheet output (including the capitalised labels)."""
|
|
77
|
+
model = NaiveBayes().fit(rows)
|
|
78
|
+
words = [w for w in document.split(SEPARATOR) if w]
|
|
79
|
+
|
|
80
|
+
comedy = model.priors.get("comedy", 0.0)
|
|
81
|
+
action = model.priors.get("action", 0.0)
|
|
82
|
+
for word in words:
|
|
83
|
+
comedy *= model.likelihood("comedy", word)
|
|
84
|
+
action *= model.likelihood("action", word)
|
|
85
|
+
|
|
86
|
+
print("Posterior for comedy:", comedy)
|
|
87
|
+
print("Posterior for action:", action)
|
|
88
|
+
print(
|
|
89
|
+
"\nThe most likely class for the document is:",
|
|
90
|
+
"Comedy" if comedy > action else "Action",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
if __name__ == "__main__": # pragma: no cover
|
|
95
|
+
main()
|
pdacek/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Program 1 - text preprocessing: tokenisation, filtration, script
|
|
2
|
+
validation, stop-word removal and stemming.
|
|
3
|
+
|
|
4
|
+
Self-contained listing: paste it into any notebook or run it directly with
|
|
5
|
+
``python pl01_preprocessing.py``. No download is attempted unless NLTK is
|
|
6
|
+
already installed and the corpus files are missing.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
import nltk
|
|
13
|
+
from nltk.corpus import stopwords
|
|
14
|
+
from nltk.stem import PorterStemmer
|
|
15
|
+
from nltk.tokenize import word_tokenize
|
|
16
|
+
except ImportError: # keep working without NLTK
|
|
17
|
+
nltk = None
|
|
18
|
+
|
|
19
|
+
text = """Natural Language Processing is a fascinating field of AI.
|
|
20
|
+
It involves processing human languages, like English!"""
|
|
21
|
+
|
|
22
|
+
print("Original Text:\n", text)
|
|
23
|
+
|
|
24
|
+
# 1. Tokenization
|
|
25
|
+
if nltk is not None:
|
|
26
|
+
try:
|
|
27
|
+
tokens = word_tokenize(text)
|
|
28
|
+
except LookupError:
|
|
29
|
+
for resource in ("punkt", "punkt_tab"):
|
|
30
|
+
nltk.download(resource, quiet=True)
|
|
31
|
+
tokens = word_tokenize(text)
|
|
32
|
+
else:
|
|
33
|
+
tokens = re.findall(r"[A-Za-z]+(?:'[A-Za-z]+)?|\d+|[^\sA-Za-z\d]", text)
|
|
34
|
+
|
|
35
|
+
print("\nTokens:\n", tokens)
|
|
36
|
+
|
|
37
|
+
# 2. Filtration
|
|
38
|
+
filtered = [re.sub(r"[^a-zA-Z]", "", w) for w in tokens]
|
|
39
|
+
filtered = [w for w in filtered if w]
|
|
40
|
+
print("\nFiltered Tokens:\n", filtered)
|
|
41
|
+
|
|
42
|
+
# 3. Script Validation
|
|
43
|
+
validated = [w for w in filtered if re.match(r"^[a-zA-Z]+$", w)]
|
|
44
|
+
print("\nValidated Tokens (English Only):\n", validated)
|
|
45
|
+
|
|
46
|
+
# 4. Stop Word Removal
|
|
47
|
+
if nltk is not None:
|
|
48
|
+
try:
|
|
49
|
+
english_stopwords = stopwords.words("english")
|
|
50
|
+
except LookupError:
|
|
51
|
+
nltk.download("stopwords", quiet=True)
|
|
52
|
+
english_stopwords = stopwords.words("english")
|
|
53
|
+
else:
|
|
54
|
+
english_stopwords = ["a", "an", "the", "is", "of", "and", "it", "like", "in", "to"]
|
|
55
|
+
|
|
56
|
+
cleaned = [w for w in validated if w.lower() not in english_stopwords]
|
|
57
|
+
print("\nTokens after Stop Word Removal:\n", cleaned)
|
|
58
|
+
|
|
59
|
+
# 5. Stemming
|
|
60
|
+
stemmer = PorterStemmer() if nltk is not None else None
|
|
61
|
+
stemmed = [stemmer.stem(w) if stemmer else w.lower() for w in cleaned]
|
|
62
|
+
print("\nStemmed Tokens:\n", stemmed)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Program 2 - unigram, bigram and trigram probability estimates.
|
|
2
|
+
|
|
3
|
+
Self-contained listing: ``python pl02_ngrams.py``.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
from collections import Counter
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
from nltk.tokenize import word_tokenize
|
|
11
|
+
except ImportError: # regex fallback below
|
|
12
|
+
word_tokenize = None
|
|
13
|
+
|
|
14
|
+
sentences = [
|
|
15
|
+
"The quick brown fox jumps over the lazy dog",
|
|
16
|
+
"The dog barks at the fox",
|
|
17
|
+
"The fox is quick and the dog is lazy",
|
|
18
|
+
"The quick dog jumps over the lazy fox",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def tokenize(sentence):
|
|
23
|
+
if word_tokenize is not None:
|
|
24
|
+
try:
|
|
25
|
+
return word_tokenize(sentence.lower())
|
|
26
|
+
except LookupError:
|
|
27
|
+
pass
|
|
28
|
+
return re.findall(r"[a-z]+(?:'[a-z]+)?|[^\sa-z\d]", sentence.lower())
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def ngrams(sequence, n):
|
|
32
|
+
return [tuple(sequence[i:i + n]) for i in range(len(sequence) - n + 1)]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# Tokenization
|
|
36
|
+
words = [tokenize(s) for s in sentences]
|
|
37
|
+
|
|
38
|
+
# N-gram probabilities
|
|
39
|
+
for n in [1, 2, 3]:
|
|
40
|
+
count = Counter(g for w in words for g in ngrams(w, n))
|
|
41
|
+
prev = Counter(g for w in words for g in ngrams(w, n - 1)) if n > 1 else None
|
|
42
|
+
|
|
43
|
+
print(f"\n{n}-Gram Probabilities:")
|
|
44
|
+
for g, c in count.items():
|
|
45
|
+
p = c / (sum(count.values()) if n == 1 else prev[g[:-1]])
|
|
46
|
+
print(f"{' '.join(g)}: {p:.4f}")
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Program 3 - minimum edit distance with the dynamic-programming table.
|
|
2
|
+
|
|
3
|
+
Self-contained listing: ``python pl03_edit_distance.py``.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def med(a, b):
|
|
8
|
+
"""Return (minimum edit distance, DP table)."""
|
|
9
|
+
m, n = len(a), len(b)
|
|
10
|
+
dp = [[0] * (n + 1) for _ in range(m + 1)]
|
|
11
|
+
|
|
12
|
+
for i in range(m + 1):
|
|
13
|
+
dp[i][0] = i
|
|
14
|
+
for j in range(n + 1):
|
|
15
|
+
dp[0][j] = j
|
|
16
|
+
|
|
17
|
+
for i in range(1, m + 1):
|
|
18
|
+
for j in range(1, n + 1):
|
|
19
|
+
if a[i - 1] == b[j - 1]:
|
|
20
|
+
dp[i][j] = dp[i - 1][j - 1]
|
|
21
|
+
else:
|
|
22
|
+
dp[i][j] = 1 + min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1])
|
|
23
|
+
return dp[m][n], dp
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def display(dp, a, b):
|
|
27
|
+
print("\nEdit Distance Table:")
|
|
28
|
+
print(" " + " ".join("#" + c for c in " " + b))
|
|
29
|
+
for i, row in enumerate(dp):
|
|
30
|
+
print(("#" if i == 0 else a[i - 1]), *row)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# Test cases
|
|
34
|
+
tests = [
|
|
35
|
+
("kitten", "sitting"),
|
|
36
|
+
("flaw", "lawn"),
|
|
37
|
+
("intention", "execution"),
|
|
38
|
+
("apple", "aple"),
|
|
39
|
+
("", "hello"),
|
|
40
|
+
("hello", ""),
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
for a, b in tests:
|
|
44
|
+
distance, dp = med(a, b)
|
|
45
|
+
print(f"\nComparing: '{a}' -> '{b}'")
|
|
46
|
+
print("Minimum Edit Distance:", distance)
|
|
47
|
+
display(dp, a, b)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Program 4 - top-down and bottom-up parsing from a context-free grammar.
|
|
2
|
+
|
|
3
|
+
Self-contained listing: ``python pl04_parsing.py``.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Parser:
|
|
8
|
+
def __init__(self, grammar):
|
|
9
|
+
self.rules = {}
|
|
10
|
+
for lhs, rhs in grammar:
|
|
11
|
+
self.rules.setdefault(lhs, []).append(rhs)
|
|
12
|
+
|
|
13
|
+
# ---- top-down: recursive descent with backtracking ---------------------
|
|
14
|
+
def top_down_parse(self, sentence):
|
|
15
|
+
self.tokens = list(sentence)
|
|
16
|
+
self.pos = 0
|
|
17
|
+
if not self._expand("S"):
|
|
18
|
+
return None
|
|
19
|
+
return ["S"] if self.pos == len(self.tokens) else None
|
|
20
|
+
|
|
21
|
+
def _expand(self, symbol):
|
|
22
|
+
if symbol not in self.rules: # terminal
|
|
23
|
+
if self.pos < len(self.tokens) and self.tokens[self.pos] == symbol:
|
|
24
|
+
self.pos += 1
|
|
25
|
+
return True
|
|
26
|
+
return False
|
|
27
|
+
for rhs in self.rules[symbol]:
|
|
28
|
+
mark = self.pos
|
|
29
|
+
if all(self._expand(child) for child in rhs):
|
|
30
|
+
return True
|
|
31
|
+
self.pos = mark
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
# ---- bottom-up: chart (CYK-style) parse over spans ---------------------
|
|
35
|
+
def bottom_up_parse(self, sentence, start="S"):
|
|
36
|
+
tokens = list(sentence)
|
|
37
|
+
if not tokens:
|
|
38
|
+
return []
|
|
39
|
+
|
|
40
|
+
lexical, unary, binary = {}, [], []
|
|
41
|
+
for lhs, rhs_list in self.rules.items():
|
|
42
|
+
for rhs in rhs_list:
|
|
43
|
+
if len(rhs) == 1 and rhs[0] not in self.rules:
|
|
44
|
+
lexical.setdefault(rhs[0], []).append(lhs)
|
|
45
|
+
elif len(rhs) == 1:
|
|
46
|
+
unary.append((lhs, rhs[0]))
|
|
47
|
+
elif len(rhs) == 2:
|
|
48
|
+
binary.append((lhs, rhs[0], rhs[1]))
|
|
49
|
+
else:
|
|
50
|
+
raise ValueError(f"rule {lhs} -> {' '.join(rhs)} needs splitting")
|
|
51
|
+
|
|
52
|
+
def close(cell):
|
|
53
|
+
changed = True
|
|
54
|
+
while changed:
|
|
55
|
+
changed = False
|
|
56
|
+
for parent, child in unary:
|
|
57
|
+
if child in cell and parent not in cell:
|
|
58
|
+
cell.add(parent)
|
|
59
|
+
changed = True
|
|
60
|
+
return cell
|
|
61
|
+
|
|
62
|
+
chart = {}
|
|
63
|
+
for i, token in enumerate(tokens):
|
|
64
|
+
chart[(i, i + 1)] = close(set(lexical.get(token, ())))
|
|
65
|
+
|
|
66
|
+
for length in range(2, len(tokens) + 1):
|
|
67
|
+
for s in range(0, len(tokens) - length + 1):
|
|
68
|
+
e = s + length
|
|
69
|
+
cell = set()
|
|
70
|
+
for split in range(s + 1, e):
|
|
71
|
+
left, right = chart[(s, split)], chart[(split, e)]
|
|
72
|
+
for parent, first, second in binary:
|
|
73
|
+
if first in left and second in right:
|
|
74
|
+
cell.add(parent)
|
|
75
|
+
chart[(s, e)] = close(cell)
|
|
76
|
+
|
|
77
|
+
top = chart.get((0, len(tokens)), set())
|
|
78
|
+
return [start] if start in top else (sorted(top) or tokens)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
grammar = [
|
|
82
|
+
("S", ["NP", "VP"]),
|
|
83
|
+
("NP", ["Det", "N"]),
|
|
84
|
+
("VP", ["V", "NP"]),
|
|
85
|
+
("VP", ["V"]),
|
|
86
|
+
("Det", ["a"]), ("Det", ["the"]),
|
|
87
|
+
("N", ["cat"]), ("N", ["dog"]),
|
|
88
|
+
("V", ["chases"]), ("V", ["eats"]),
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
parser = Parser(grammar)
|
|
92
|
+
sentence = ["the", "cat", "chases", "a", "dog"]
|
|
93
|
+
|
|
94
|
+
print("Top-Down Parse Result:", parser.top_down_parse(sentence))
|
|
95
|
+
print("Bottom-Up Parse Result:", parser.bottom_up_parse(sentence))
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Program 5 - add-one smoothed Naive Bayes movie-review classifier.
|
|
2
|
+
|
|
3
|
+
Self-contained listing: ``python pl05_naive_bayes.py``.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
|
|
8
|
+
reviews = [
|
|
9
|
+
("fun, couple, love, love", "comedy"),
|
|
10
|
+
("fast, furious, shoot", "action"),
|
|
11
|
+
("couple, fly, fast, fun", "comedy"),
|
|
12
|
+
("furious, shoot, shoot, fun", "action"),
|
|
13
|
+
("fly, fast, shoot, love", "action"),
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
counts = defaultdict(lambda: defaultdict(int))
|
|
17
|
+
classes = defaultdict(int)
|
|
18
|
+
vocab = set()
|
|
19
|
+
|
|
20
|
+
# Count words
|
|
21
|
+
for review, label in reviews:
|
|
22
|
+
classes[label] += 1
|
|
23
|
+
for word in review.split(", "):
|
|
24
|
+
counts[label][word] += 1
|
|
25
|
+
vocab.add(word)
|
|
26
|
+
|
|
27
|
+
V = len(vocab)
|
|
28
|
+
|
|
29
|
+
# Prior probabilities
|
|
30
|
+
total = sum(classes.values())
|
|
31
|
+
prior = {c: classes[c] / total for c in classes}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# Likelihood with Add-1 smoothing
|
|
35
|
+
def likelihood(c, w):
|
|
36
|
+
total_words = sum(counts[c].values())
|
|
37
|
+
return (counts[c][w] + 1) / (total_words + V)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# Classify new document
|
|
41
|
+
words = "fast, couple, shoot, fly".split(", ")
|
|
42
|
+
comedy = prior["comedy"]
|
|
43
|
+
action = prior["action"]
|
|
44
|
+
|
|
45
|
+
for w in words:
|
|
46
|
+
comedy *= likelihood("comedy", w)
|
|
47
|
+
action *= likelihood("action", w)
|
|
48
|
+
|
|
49
|
+
print("Posterior for comedy:", comedy)
|
|
50
|
+
print("Posterior for action:", action)
|
|
51
|
+
|
|
52
|
+
print("\nThe most likely class for the document is:",
|
|
53
|
+
"Comedy" if comedy > action else "Action")
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pdacek
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
License: MIT
|
|
5
|
+
License-File: LICENSE
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Requires-Dist: nltk>=3.8
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
10
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
11
|
+
Requires-Dist: twine>=5.0; extra == 'dev'
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
import pdacek
|
|
15
|
+
pdacek.p1() # preprocessing pipeline
|
|
16
|
+
pdacek.p2() # n-gram probabilities
|
|
17
|
+
pdacek.p3() # minimum edit distance
|
|
18
|
+
pdacek.p4() # top-down / bottom-up parsing
|
|
19
|
+
pdacek.p5() # Naive Bayes
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
pdacek/__init__.py,sha256=_wbnmUGgNfrfkyUdSd4bmecd6v-Lju8zrsvpcIr4Az4,2794
|
|
2
|
+
pdacek/__main__.py,sha256=YPR_b19rJwqt63H1haKe7o3Uq-xFsfBxl4jbVSOm-vQ,116
|
|
3
|
+
pdacek/cli.py,sha256=ZzzNpzYGBvpPKL0iy1BQgRPqqaTcVZNY_1sjK53v2jQ,2961
|
|
4
|
+
pdacek/compat.py,sha256=C7p5TDJIWD1dbFDzDmCn__KIj3nvCkg2tiHddkDzgqY,4484
|
|
5
|
+
pdacek/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
pdacek/programs/__init__.py,sha256=SW-nyRpS1FFVnhAK00XyOq2eNYwRrd4v0HOoy1G5de8,2676
|
|
7
|
+
pdacek/programs/pl01_preprocessing.py,sha256=VeZSboQRdXyUK5F2BT_jFFVMaw2KwF1yTZw1W4kyxdQ,2435
|
|
8
|
+
pdacek/programs/pl02_ngrams.py,sha256=ziMDozsuZoPWS1cZS6qvPet-oY5eoUqv2n-4ql614oc,2228
|
|
9
|
+
pdacek/programs/pl03_edit_distance.py,sha256=vjO9W88TjYswhC9WbGNYn93vlGjCzw1usoIGgs2hzRI,2766
|
|
10
|
+
pdacek/programs/pl04_parsing.py,sha256=KNsCI2F3hdzkAp4FSrK_4Ryp4xJ4K0_oeW6h04tstU4,5338
|
|
11
|
+
pdacek/programs/pl05_naive_bayes.py,sha256=amWnfqDele05HcTBktsuVlrLBWtANHnslHmlfU92T_o,3375
|
|
12
|
+
pdacek/scripts/pl01_preprocessing.py,sha256=_nZAAqaCzdAOVPYBoHlnZ2dnxYoNxeGHDLQFsKOEXt0,1986
|
|
13
|
+
pdacek/scripts/pl02_ngrams.py,sha256=XWURpSWHQN5DPudpknmGFIEv0jsxmUC5SPrDqlvHr0A,1242
|
|
14
|
+
pdacek/scripts/pl03_edit_distance.py,sha256=6TcDtqNgO3Wvs6F1oTZS3ETlijEP_azbosjDGpfl0D0,1169
|
|
15
|
+
pdacek/scripts/pl04_parsing.py,sha256=bPtmdimyIuJ2qUbcptbS0-FGQbaN-MfWNS-9eQv_EM4,3279
|
|
16
|
+
pdacek/scripts/pl05_naive_bayes.py,sha256=6sv0qnQGn9PEgOiiHPiMx9GLceabakbvRleDXq4qV80,1313
|
|
17
|
+
pdacek-0.1.0.dist-info/METADATA,sha256=lVYrV2xqM_aEonQJ6KZ1baOG1P28RizQYp4QApx5EjE,557
|
|
18
|
+
pdacek-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
19
|
+
pdacek-0.1.0.dist-info/entry_points.txt,sha256=iQxygOdFfe7fB9ZW3YrAP3fZzjExu0W9dOe0fu4WOl4,43
|
|
20
|
+
pdacek-0.1.0.dist-info/licenses/LICENSE,sha256=IeH_S9lC7iNb5q7sKLWDAI5qviV1-urlfIcisLMXAyw,1071
|
|
21
|
+
pdacek-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sohel Havaldar
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|