Document utilities

This commit is contained in:
Andras Schmelczer 2022-07-10 19:37:45 +02:00
parent 2b378114aa
commit 00568ca6d3
No known key found for this signature in database
GPG key ID: 0EA1BC97D0AB076E
21 changed files with 371 additions and 29 deletions

View file

@ -1,15 +1,16 @@
from .chunk import chunk from .chunk import chunk
from .clean import clean from .clean import clean
from .config_file import ConfigFile, ParseError from .config_file.config_file import ConfigFile
from .evaluate_ranking import evaluate_ranking from .config_file.parse_error import ParseError
from .evaluate_ranking.evaluate_ranking import evaluate_ranking
from .get_sentences import get_sentences from .get_sentences import get_sentences
from .language import english_name_of_language, is_english, predict_language from .language.english_name_of_language import english_name_of_language
from .logger import get_logger from .language.is_english import is_english
from .parallel_map import ( from .language.predict_language import predict_language
WorkerException, from .logger.get_logger import get_logger
parallel_map, from .parallel_map.parallel_map import parallel_map
simple_parallel_map, from .parallel_map.simple_parallel_map import simple_parallel_map
threaded_parallel_map, from .parallel_map.threaded_parallel_map import threaded_parallel_map
) from .parallel_map.worker_exception import WorkerException
from .unchunk import unchunk from .unchunk import unchunk
from .unique import unique from .unique import unique

View file

@ -4,6 +4,25 @@ T = TypeVar("T")
def chunk(values: Iterable[T], chunk_size: int) -> Iterable[List[T]]: def chunk(values: Iterable[T], chunk_size: int) -> Iterable[List[T]]:
"""Turn an iterable of items into an iterable of lists (chunks) of items.
Each returned chunk is of length `chunk_size` except the last one the length of
which is between 1 and `chunk_size`.
Useful for parallel processing.
Examples:
>>> list(chunk(range(10), chunk_size=3))
[[0, 1, 2], [3, 4, 5], [6, 7, 8], [9]]
Args:
values: The stream of items to pack into chunks.
chunk_size: Desired length of each (but the last) chunk.
Yields:
The next chunk.
"""
assert chunk_size >= 1 assert chunk_size >= 1
result: List[T] = [] result: List[T] = []

View file

@ -6,7 +6,7 @@ import unidecode
from .data import left_regular_punctuations, right_regular_punctuations from .data import left_regular_punctuations, right_regular_punctuations
from .external.pylatexenc.latex2text import LatexNodes2Text from .external.pylatexenc.latex2text import LatexNodes2Text
from .logger import get_logger from .logger.get_logger import get_logger
logger = get_logger("clean") logger = get_logger("clean")
latex = LatexNodes2Text() latex = LatexNodes2Text()
@ -23,6 +23,43 @@ def clean(
remove_brackets: bool = False, remove_brackets: bool = False,
convert_to_ascii: bool = False, convert_to_ascii: bool = False,
) -> str: ) -> str:
"""Clean all XML, LaTeX, PDF-extraction, and Unicode artifacts from the text.
The cleaning is quite heavy-weight and can be destructive. However, when working
with text, this is usually required to achieve sufficient cleanliness before further
processing.
Optionally, the text can be turned into ASCII. Carefully consider whether this is
absolutely needed for your use-case.
Examples:
>>> clean('<h2 color="red">Bj\\\\"{o}rn is \\t \\\\textit{happy} 🙂 &lt;3</h2>')
'Björn is happy 🙂 <3'
>>> clean(
... '<h2 color="red">Bj\\\\"{o}rn is \\t \\\\textit{happy} 🙂 &lt;3</h2>',
... convert_to_ascii=True
... )
'Bjorn is happy <3'
>>> clean(
... '<h2 color="red">Bj\\\\"{o}rn is \\t \\\\textit{happy} 🙂 &lt;3</h2>',
... ignore_xml=True
... )
'<h2 color="red">Björn is happy 🙂 lt;3</h2>'
Args:
text: Text to be cleaned.
ignore_xml: Do not process/remove XML-tags.
ignore_latex: Do not process/remove LaTeX-tags.
remove_brackets: Do not remove brackets ([])
convert_to_ascii: Strip (or convert) non-ascii characters.
Returns:
The cleaned input text with sensibly collapsed whitespace and optionally no
markup.
"""
if not ignore_xml: if not ignore_xml:
text = re.sub(r"<[^>]*>", " ", text) text = re.sub(r"<[^>]*>", " ", text)
text = html.unescape(text) text = html.unescape(text)

View file

@ -2,7 +2,7 @@ import os
from pathlib import Path from pathlib import Path
from typing import Dict, ItemsView, Iterator, KeysView, Mapping, Union, ValuesView from typing import Dict, ItemsView, Iterator, KeysView, Mapping, Union, ValuesView
from ..logger import get_logger from ..logger.get_logger import get_logger
from .parse_error import ParseError from .parse_error import ParseError
from .pattern import pattern from .pattern import pattern
@ -10,9 +10,63 @@ logger = get_logger("ConfigFile")
class ConfigFile(Mapping[str, str]): class ConfigFile(Mapping[str, str]):
"""A small and safe `INI`-style configuration loader with `dict` and `ENV` support.
The values can be accessed using both dot- and index-notation. It is compatible
with the `dict` interface.
File format example:
```toml
# comments are allowed everywhere
key = value # you can leave or omit whitespace around the equal-sign
my_hashtag = "#great_ai" # the r-value can be quoted with " or ' or `.
my_var = my_default_value # Default values can be given to env-vars,
# see next line. The default value must come first.
my_var = ENV:MY_ENV_VAR # If the value starts with the `ENV:` prefix,
# it is looked up from the environment variables.
```
Examples:
>>> ConfigFile('tests/utilities/data/simple.conf')
ConfigFile(path=tests/utilities/data/simple.conf) {'zeroth_key': 'test', 'first_key': 'András'}
>>> ConfigFile('tests/utilities/data/simple.conf').zeroth_key
'test'
>>> ConfigFile('tests/utilities/data/simple.conf').second_key
Traceback (most recent call last):
...
KeyError: 'Key `second_key` is not found in configuration file ...
>>> a = ConfigFile('tests/utilities/data/simple.conf')
>>> {**a}
{'zeroth_key': 'test', 'first_key': 'András'}
"""
ENVIRONMENT_VARIABLE_KEY_PREFIX = "ENV" ENVIRONMENT_VARIABLE_KEY_PREFIX = "ENV"
def __init__(self, path: Union[Path, str], *, ignore_missing: bool = False) -> None: def __init__(self, path: Union[Path, str], *, ignore_missing: bool = False) -> None:
"""Load and parse a configuration file.
Everything is eager-loaded, thus, exceptions may be thrown here.
Args:
path: Local path of the configuration file.
ignore_missing: Don't raise an exception on missing environment variables.
Raises:
FileNotFoundError: If there is no file at the specified path.
ParseError: If the provided file does not conform to the expected format.
KeyError: If there is duplication in the keys.
ValueError: If an environment variable is referenced but it is not set in
the system and `ignore_missing=False`.
"""
if not isinstance(path, Path): if not isinstance(path, Path):
path = Path(path) path = Path(path)
@ -28,6 +82,7 @@ class ConfigFile(Mapping[str, str]):
@property @property
def path(self) -> Path: def path(self) -> Path:
"""Original path from where the configuration was loaded."""
return self._path return self._path
def _parse(self) -> None: def _parse(self) -> None:
@ -54,16 +109,20 @@ class ConfigFile(Mapping[str, str]):
if value.startswith(f"{self.ENVIRONMENT_VARIABLE_KEY_PREFIX}:"): if value.startswith(f"{self.ENVIRONMENT_VARIABLE_KEY_PREFIX}:"):
_, value = value.split(":") _, value = value.split(":")
if value not in os.environ: if value not in os.environ:
issue = f'The value of `{key}` contains the "{self.ENVIRONMENT_VARIABLE_KEY_PREFIX}` prefix but `{value}` is not defined as an environment variable' issue = f"""The value of `{key}` contains the "{
self.ENVIRONMENT_VARIABLE_KEY_PREFIX
}` prefix but `{value}` is not defined as an environment variable"""
if already_exists: if already_exists:
logger.warning( logger.warning(
f"{issue}, using the default value defined above (`{self._key_values[key]}`)" f"""{issue}, using the default value defined above (`{
self._key_values[key]
}`)"""
) )
continue continue
elif self._ignore_missing: elif self._ignore_missing:
logger.warning(issue) logger.warning(issue)
else: else:
raise KeyError( raise ValueError(
f"{issue} and no default value has been provided" f"{issue} and no default value has been provided"
) )
else: else:

View file

@ -1,2 +0,0 @@
from .draw_f1_iso_lines import draw_f1_iso_lines
from .evaluate_ranking import evaluate_ranking

View file

@ -23,6 +23,26 @@ def evaluate_ranking(
reverse_order: bool = False, reverse_order: bool = False,
plot: bool = True, plot: bool = True,
) -> Dict[T, float]: ) -> Dict[T, float]:
"""Render the Precision-Recall curve of a ranking.
And improved version of scikit-learn's [PR-curve](https://scikit-learn.org/stable/auto_examples/model_selection/plot_precision_recall.html#sphx-glr-auto-examples-model-selection-plot-precision-recall-py)
Args:
expected: Expected ordering of the elements
(rank if it's an integer, alphabetical if a string)
actual_scores: Actual ranking scores (need not be on the same scale as
`expected`)
title: Title of the plot.
disable_interpolation: Do not interpolate.
axes: Matplotlib axes for ploting inside a subplot.
output_svg: If specified, save the chart as an svg to the given Path.
reverse_order: Reverse the ranking specified by `expected`.
plot: Display a plot on the screen.
Returns:
Precision values at given recall.
"""
assert 0 <= target_recall <= 1 assert 0 <= target_recall <= 1
if plot and axes is None: if plot and axes is None:

View file

@ -16,6 +16,32 @@ def get_sentences(
true_case: bool = False, true_case: bool = False,
remove_punctuation: bool = False, remove_punctuation: bool = False,
) -> List[str]: ) -> List[str]:
"""Return the list of sentences found in the input text.
Use [syntok](https://github.com/fnl/syntok) to segment the sentences. Further
processing can be enabled with optional arguments.
Examples:
>>> get_sentences('This is a sentence. This is a half')
['This is a sentence.', 'This is a half']
>>> get_sentences('This is a sentence. This is a half', ignore_partial=True)
['This is a sentence.']
>>> get_sentences('I like Apple.', true_case=True, remove_punctuation=True)
['i like Apple']
Args:
text: Text to be segmented into sentences.
ignore_partial: Filter out sentences that are not capitalised/don't end with a
punctuation.
true_case: Crude method: lowercase the first word of each sentence.
remove_punctuation: Remove all kinds of punctuation.
Returns:
The found sentences (with partial sentences optionally filtered out).
"""
tokenizer = Tokenizer( tokenizer = Tokenizer(
emit_hyphen_or_underscore_sep=True, replace_not_contraction=False emit_hyphen_or_underscore_sep=True, replace_not_contraction=False
) )

View file

@ -1,3 +0,0 @@
from .english_name_of_language import english_name_of_language
from .is_english import is_english
from .predict_language import predict_language

View file

@ -4,6 +4,26 @@ from langcodes import Language
def english_name_of_language(language_code: Optional[str]) -> str: def english_name_of_language(language_code: Optional[str]) -> str:
"""Human-friendly English name of language from its `language_code`.
A thin wrapper over [langcodes](https://github.com/rspeer/langcodes) for convenient
language tagging.
Examples:
>>> english_name_of_language('en-US')
'English (United States)'
>>> english_name_of_language('und')
'Unknown language'
Args:
language_code: Language code, for example, returned by
[great_ai.utilities.predict_language][].
Returns:
English name of language.
"""
if not language_code: if not language_code:
language_code = "und" language_code = "und"

View file

@ -4,6 +4,28 @@ from langcodes import standardize_tag, tag_distance
def is_english(language_code: Optional[str]) -> bool: def is_english(language_code: Optional[str]) -> bool:
"""Decide whether the `language_code` is of an English language.
A thin wrapper over [langcodes](https://github.com/rspeer/langcodes) for convenient
language tagging.
Examples:
>>> is_english('en-US')
True
>>> is_english(None)
False
>>> is_english('und')
False
Args:
language_code: Language code, for example, returned by
`[great_ai.utilities.predict_language][].
Returns:
Boolean indicating whether it's English.
"""
if not language_code: if not language_code:
language_code = "und" language_code = "und"

View file

@ -5,6 +5,23 @@ from langdetect import LangDetectException, detect
def predict_language(text: Optional[str]) -> str: def predict_language(text: Optional[str]) -> str:
"""Predict the language code from text.
A thin wrapper over [langcodes](https://github.com/rspeer/langcodes) for convenient
language tagging.
Examples:
>>> predict_language('This is a sentence.')
'en'
Args:
text: Text used for prediction.
Returns:
The predicted language code (en, en-US) or `und` if a prediction could not be
made.
"""
if not text: if not text:
return Language.make().to_tag() return Language.make().to_tag()

View file

@ -1,2 +0,0 @@
from .custom_formatter import CustomFormatter
from .get_logger import get_logger

View file

@ -9,6 +9,8 @@ loggers: Dict[str, logging.Logger] = {}
def get_logger( def get_logger(
name: str, level: int = logging.INFO, disable_colors: bool = False name: str, level: int = logging.INFO, disable_colors: bool = False
) -> logging.Logger: ) -> logging.Logger:
"""Return a customised logger used throughout the GreatAI codebase."""
if name not in loggers: if name not in loggers:
logger = logging.getLogger(name) logger = logging.getLogger(name)
logger.setLevel(level) logger.setLevel(level)

View file

@ -1,4 +0,0 @@
from .parallel_map import parallel_map
from .simple_parallel_map import simple_parallel_map
from .threaded_parallel_map import threaded_parallel_map
from .worker_exception import WorkerException

View file

@ -2,7 +2,7 @@ import os
from math import ceil from math import ceil
from typing import Callable, Iterable, Optional, Sequence, Union from typing import Callable, Iterable, Optional, Sequence, Union
from ..logger import get_logger from ..logger.get_logger import get_logger
from .parallel_map_configuration import ParallelMapConfiguration from .parallel_map_configuration import ParallelMapConfiguration
logger = get_logger("parallel_map") logger = get_logger("parallel_map")

View file

@ -4,7 +4,7 @@ import traceback
from typing import Dict, Iterable, List, TypeVar, Union from typing import Dict, Iterable, List, TypeVar, Union
from ..chunk import chunk from ..chunk import chunk
from ..logger import get_logger from ..logger.get_logger import get_logger
from .map_result import MapResult from .map_result import MapResult
from .worker_exception import WorkerException from .worker_exception import WorkerException

View file

@ -83,6 +83,49 @@ def parallel_map(
concurrency: Optional[int] = None, concurrency: Optional[int] = None,
unordered: bool = False, unordered: bool = False,
) -> Iterable[Optional[V]]: ) -> Iterable[Optional[V]]:
"""Execute a map operation on an iterable stream.
A custom parallel map operation supporting both synchronous and `async` map
functions. The `func` function is serialised with `dill`. Exceptions encountered in
the map function are sent to the host process where they are either raised (default)
or ignored.
The new processes are forked if the OS allows it, otherwise, new Python processes
are bootstrapped which can incur some startup cost. Each process processes a single
chunk at once.
Examples:
>>> import math
>>> list(parallel_map(math.sqrt, [9, 4, 1], concurrency=2))
[3.0, 2.0, 1.0]
Args:
func: The function that should be applied to each element of `input_values`.
It can `async`, in that case, a new event loop is started for each chunk.
input_values: An iterable of items that `func` is applied to.
chunk_size: Tune the number of items processed in each step. Larger numbers
result in smaller communication overhead but less parallelism at the start
and end. If `chunk_size` has a `__len__` property, the `chunk_size` is
calculated automatically if not given.
ignore_exceptions: Ignore chunks if `next()` raises an exception on
`input_values`. And return `None` if `func` raised an exception in a worker
process.
concurrency: Number of new processes to start. Shouldn't be too much more than
the number of physical cores.
unordered: Do not preserve the order of the elements, yield them as soon as they
have been processed. This decreases the latency caused by
difficult-to-process items.
Yields:
The next result obtained from applying `func` to each input value. May
contain `None`-s if `ignore_exceptions=True`. May have different order than
the input if `unordered=True`.
Raises:
WorkerException: If there was an error in the `func` function in a background
process and `ignore_exceptions=False`.
"""
config = get_config( config = get_config(
function=func, function=func,
input_values=input_values, input_values=input_values,

View file

@ -2,7 +2,7 @@ from typing import Optional
from pydantic import BaseModel from pydantic import BaseModel
from ..logger import get_logger from ..logger.get_logger import get_logger
logger = get_logger("parallel_map") logger = get_logger("parallel_map")

View file

@ -15,6 +15,36 @@ def simple_parallel_map(
chunk_size: Optional[int] = None, chunk_size: Optional[int] = None,
concurrency: Optional[int] = None, concurrency: Optional[int] = None,
) -> List[V]: ) -> List[V]:
"""Execute a map operation on an list mimicking the API of the built-in `map()`.
A thin-wrapper over [parallel_map][great_ai.utilities.parallel_map.parallel_map.parallel_map].
For more options, consult the documentation of
[parallel_map][great_ai.utilities.parallel_map.parallel_map.parallel_map].
Examples:
>>> import math
>>> list(simple_parallel_map(math.sqrt, [9, 4, 1]))
[3.0, 2.0, 1.0]
Args:
func: The function that should be applied to each element of `input_values`.
It can `async`, in that case, a new event loop is started for each chunk.
input_values: An iterable of items that `func` is applied to.
chunk_size: Tune the number of items processed in each step. Larger numbers
result in smaller communication overhead but less parallelism at the start
and end. If `chunk_size` has a `__len__` property, the `chunk_size` is
calculated automatically if not given.
concurrency: Number of new processes to start. Shouldn't be too much more than
the number of physical cores.
Returns:
An iterable of results obtained from applying `func` to each input value.
Raises:
WorkerException: If there was an error in the `func` function in a background
process.
"""
input_values = list(input_values) # in case the input is mistakenly not a sequence input_values = list(input_values) # in case the input is mistakenly not a sequence
return list( return list(
tqdm( tqdm(

View file

@ -81,6 +81,47 @@ def threaded_parallel_map(
concurrency: Optional[int] = None, concurrency: Optional[int] = None,
unordered: bool = False, unordered: bool = False,
) -> Iterable[Optional[V]]: ) -> Iterable[Optional[V]]:
"""Execute a map operation on an iterable stream.
Similar to [parallel_map][great_ai.utilities.parallel_map.parallel_map.parallel_map]
but uses threads instead of processes. Hence, it is not helpful in CPU-bound
situations.
A custom parallel map operation supporting both synchronous and `async` map
functions. Exceptions encountered in the map function are sent to the host thread
where they are either raised (default) or ignored. Each process processes a single
chunk at once.
Examples:
>>> list(threaded_parallel_map(lambda x: x ** 2, [1, 2, 3]))
[1, 4, 9]
Args:
func: The function that should be applied to each element of `input_values`.
It can `async`, in that case, a new event loop is started for each chunk.
input_values: An iterable of items that `func` is applied to.
chunk_size: Tune the number of items processed in each step. Larger numbers
result in smaller communication overhead but less parallelism at the start
and end. If `chunk_size` has a `__len__` property, the `chunk_size` is
calculated automatically if not given.
ignore_exceptions: Ignore chunks if `next()` raises an exception on
`input_values`. And return `None` if `func` raised an exception in a worker
process.
concurrency: Number of new threads to start.
unordered: Do not preserve the order of the elements, yield them as soon as they
have been processed. This decreases the latency caused by
difficult-to-process items.
Yields:
The next result obtained from applying `func` to each input value. May
contain `None`-s if `ignore_exceptions=True`. May have different order than
the input if `unordered=True`.
Raises:
WorkerException: If there was an error in the `func` function in a background
thread and `ignore_exceptions=False`.
"""
config = get_config( config = get_config(
function=func, function=func,
input_values=input_values, input_values=input_values,

View file

@ -4,6 +4,22 @@ T = TypeVar("T")
def unchunk(chunks: Iterable[Optional[Iterable[T]]]) -> Iterable[T]: def unchunk(chunks: Iterable[Optional[Iterable[T]]]) -> Iterable[T]:
"""Turn a stream of chunks of items into a stream of items (flatten operation).
The inverse operation of [chunk][great_ai.utilities.chunk.chunk].
Useful for parallel processing.
Examples:
>>> list(unchunk([[0, 1, 2], [3, 4, 5], [6, 7, 8], [9]]))
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
Args:
chunks: Stream of chunks to unpack.
Yields:
The next item in the flattened iterable.
"""
for chunk in chunks: for chunk in chunks:
if chunk is not None: if chunk is not None:
yield from chunk yield from chunk