hojichar

HojiChar: The Text Processing Pipeline

PyPI version Python Versions CI wowkflow codecov PyPI Downloads

Official docs: https://hojichar.github.io/HojiChar/hojichar.html

Features

  • HojiChar provides a way to combine multiple arbitrary text processing tasks into a streamlined pipeline.
  • The sequence of operations can be described declaratively, ensuring portability.
  • HojiChar allows users to gather detailed statistical information from large amounts of text during processing.
  • It enables management of any Python text processing tasks, providing a Command Line Interface (CLI) capable of parallel processing.

Background and what is for HojiChar

Text preprocessing is far from a one-size-fits-all process. Depending on the data source and the specific task at hand, various steps including normalization, noise removal, and filtering may be necessary. Not all texts require the same level of preprocessing. For instance, relatively clean texts may only need minimal filtering, while "dirtier" sources like Common Crawl data often require more thorough processing. As a result, the preprocessing profile has to be tailored to each specific domain.

Many preprocessing operations can be viewed as filters, taking string as input, applying a transformation, and outputting the processed string. Even though these operations might seem straightforward individually, managing them in a multi-layered, efficient manner can be challenging.

Inspired by torchvision.transforms and iver56/audiomentations, HojiChar addresses these challenges. It enables users to define each text processing step as a class inheriting from hojichar.Filter and use hojichar.Compose to chain them together into a single filter. By writing out the Compose recipe as a profile, the preprocessing process for a specific domain's text can be made portable. Moreover, Compose automatically logs various metrics for each filter, such as byte changes, processing time, and number of rejected texts. This allows users to assess the validity of each operation and consider trade-offs between computation time and performance.

While there are other text normalization tools available, most are designed to perform a specific set of operations. Text preprocessing, despite its importance in the LLM era, is often considered a mundane task compared to machine learning or artificial intelligence tasks. As a result, many existing solutions can be ad hoc, poorly maintained, or inadequately tested. Recognizing these issues, we developed HojiChar as a robust tool for configuring text preprocessing.

Install

pip install hojichar

If you want to use the additional filters, install the package with the following command:

pip install 'hojichar[all]'

If you want to use AsyncChatAPI filter, install the package with the following command:

pip install 'hojichar[openai]'

If you want to use the near-deduplication (using MinHash LSH algorithm) filter, install the package with the following command:

pip install 'hojichar[dedup]'

Defining a Compose Object

The Compose class in HojiChar allows you to create a sequence of text processing filters.

from hojichar import Compose, document_filters

cleaner = Compose([
    document_filters.JSONLoader(key="text"),
    document_filters.AcceptJapanese(),
    document_filters.DocumentLengthFilter(min_doc_len=0,max_doc_len=1000),
    document_filters.ExampleHojiChar(),
    document_filters.JSONDumper()
])

When a Compose object is called, it accepts a string and returns the processed string.

>>> cleaner('{"text": "こんにちは、"}')
{"text": "こんにちは、<hojichar>"}

The filter pipeline above accomplishes the following steps:

  1. Extracts the value from the 'text' key in the JSON object.
  2. Discards the string if it's not in Japanese.
  3. Rejects any text shorter than 0 characters or longer than 1000 characters.
  4. Appends <hojichar> to the string.
  5. Outputs the processed string as JSON with the key "text".

The filters used in the pipeline are predefined filters found in hojichar.filters.

While HojiChar provides some fundamental text processing filters and plans to add more in the future, users can also define their custom filters.

Working with Metadata

JSONL corpora often store useful metadata alongside the text itself. JSONLoader and JSONDumper make it easy to keep that information with the document through the entire pipeline:

  • JSONLoader automatically merges any extras dictionary present in the input JSON into Document.extras. When you pass extra_keys=["url", "title"], those fields are copied as well, so metadata survives even if new extras are added later in the pipeline.
  • Filters can freely append to document.extras (for example, to record trace information or filter outcomes).
  • JSONDumper(export_extras=True) writes the current Document.extras back into the output JSON, making round-tripping metadata straightforward.
from hojichar import Compose, document_filters

pipeline = Compose(
    [
        document_filters.JSONLoader(extra_keys=["url"]),
        document_filters.DocumentNormalizer(),
        document_filters.JSONDumper(export_extras=True),
    ]
)

>>> pipeline('{"text": " Hello ", "extras": {"source": "blog"}, "url": "https://example.com"}')
{"text": "Hello", "extras": {"source": "blog", "url": "https://example.com"}}

In this example, the loader keeps both the embedded extras and the url field, later filters can enrich document.extras, and the dumper emits everything for downstream consumers.

User-defined Filters

A filter composing a Compose object is a class that inherits the Filter class and implements the text processing within the apply function.

from hojichar.core.filter_interface import Filter

class YourFilter(Filter):
    def apply(self, document):
        text = document.text
        """
        Write your text transformation...
        """
        document.text = text
        return document

The apply method accepts a hojichar.Document type as an argument and returns it after the transformations. The Document is a class that encapsulates a string.

The Document class can have additional metadata via the extras attribute. This allows you to associate values with the document that can be utilized in subsequent filters. Reject documents

  • The hojichar.Document has an is_rejected attribute. If a filter sets this flag to True, Compose will discard the document during processing.

Definition of __init__ for custom filter

When creating a user-defined class and applying a custom constructor, make sure to initialize the parent class.

class YourFilter(Filter):
    def __init__(self, your_param, *args, **kwargs) -> None:
        super().__init__(*args, **kwargs)
        self.your_param = your_param

    def apply(self, document):
        text = document.text
        text = process(text, self.your_param)
        document.text = text
        return document

This is because The Filter class implicitly has several arguments, one of which is p.

cleaner = Compose([
    document_filters.JSONLoader(key="text"),
    document_filters.AcceptJapanese(p=0.5),
    document_filters.JSONDumper()
])

The p argument passed to the document_filters.AcceptJapanese constructor determines the probability of applying the filter; with a probability of 1-p, it acts as an identity function. This behavior is defined in the parent class hojichar.Filter.

Batch and Stream Processing with apply_batch and apply_stream

The Filter and Compose classes support efficient batch and stream processing through the apply_batch and apply_stream methods.

apply_batch

  • The apply_batch method processes a list of Document objects in one go. By default, it applies the apply method to each document individually.
  • Users can override apply_batch in custom filters for optimized batch operations.
    class YourBatchFilter(Filter):
        def apply_batch(self, documents: Sequence[Document]) -> list[Document]:
            # Implement your batch processing logic here
            return documents
    

apply_stream

The apply_stream method processes an iterable (e.g., generator) of Document objects, ideal for large datasets or stream-based processing. If the use_batch flag is set to True in a Filter's constructor, its apply_batch implementation will be utilized during stream processing.

Example Usage:

stream = (Document(f"text {i}") for i in range(10000))
processed_stream = cleaner.apply_stream(stream)

for doc in processed_stream:
    print(doc.text)

This allows HojiChar to efficiently process massive corpora while maintaining low memory consumption.

Additional Notes on Compose

  • Even though the behavior of a Compose object when called is a text-in, text-out function, Compose itself also inherits from the Filter class. Therefore, applying the apply method to a Compose object results in hojihcar.Document class being used as input and output.
  • Compose class behaves like a Filter. If you add a Compose object as one of the filters in the constructor of Compose, the filter will be unfolded recursively.

HojiChar running asynchronously

  • HojiChar supports asynchronous processing of text data using the AsyncCompose class. This allows you to build pipelines that can handle out-of-CPU processing, such as making API calls.
  • You can define async versions of filter using the AsyncFilter class.

    from hojichar import AsyncFilter
    
    class YourAsyncFilter(AsyncFilter):
        async def apply(self, document):
            text = document.text
            # Perform asynchronous processing here
            document.text = text
            return document
    
  • The AsyncCompose class accepts both Filter and AsyncFilter objects, allowing you to mix synchronous and asynchronous filters in a single pipeline.

  • For filters that use the default per-document apply_batch, AsyncFilter.apply_stream keeps at most batch_size documents in flight and replenishes the window as each document finishes. Results preserve input order by default; set ordered=False to yield completed documents immediately when input order is not required.
  • Filters that override apply_batch continue to process complete batches, so implementations backed by a real batch API retain their batching behavior.

Synchronous tools can consume an AsyncCompose pipeline through imap_apply. The bridge owns one background event loop and shuts down the pipeline after iteration by default. Use the returned iterator as a context manager when the consumer may stop early.

with async_pipeline.imap_apply(input_doc_iter) as output_docs:
    for document in output_docs:
        print(document.text)

Example

Nowadays, text processing is enhanced by the intelligence of LLMs.

This example demonstrates how to use the AsyncChatAPI filter to process text data with OpenAI compatible APIs. This filter allows you to build high throughput of "Chain of LLMs" easily.

import os

from hojichar import AsyncCompose
from hojichar.filters.document_filters import JSONLoader, JSONDumper
from hojichar.utils.async_handlers import write_stream_to_file


async_pipeline = AsyncCompose(
    [
        JSONLoader(input_key="text"),
        AsyncChatAPI(
            model_id="gpt-4o",
            openai_endpoint_url="https://api.openai.com/v1", 
            openai_api_key=os.getenv("OPENAI_API_KEY"),
            max_concurrent_requests=128,
            output_key="llm_output",
            message_generator=lambda doc: [{"role": "user", "content": doc.text[:1000]}],
        ),
        JSONDumper(export_extras=True),
    ]
)

with open("input.jsonl") as f:
    async with async_pipeline:
        async_output_stream = (str(doc) async for doc in async_pipeline.apply_stream(f))
        await write_stream_to_file(async_output_stream, "output.jsonl", chunk_size=128) # Write async-iterable to file efficiently
  • You can use this filter by installing 'hojichar[openai]'
  • The filter works with OpenAI compatible APIs, like the endpoint hosted by vLLM. It's useful for text-augumentation tasks.
    • The AsyncChatAPI works 1K req/sec with optimized vLLM server. (We reccomend to use uvloop to get better throughput.)

Get Metrics of processing

HojiChar tracks detailed statistics at both the filter and pipeline levels, helping you monitor and debug your processing pipeline.

Each Filter (including Compose) maintains a Statistics object containing information such as input size, output size, discarded document count, and processing time.

Example: Getting Statistics from a Compose Object

stats = cleaner.get_total_statistics_map()
print(stats)
[{'cumulative_time_ns': 337250,
  'diff_bytes': 10,
  'diff_chars': 10,
  'discard_num': 0,
  'input_bytes': 45,
  'input_chars': 23,
  'input_num': 1,
  'name': 'Total',
  'output_bytes': 55,
  'output_chars': 33,
  'output_num': 1},
 {'cumulative_time_ns': 80209,
  'diff_bytes': -12,
  'diff_chars': -12,
  'discard_num': 0,
  'input_bytes': 45,
  'input_chars': 23,
  'input_num': 1,
  'name': '0-JSONLoader',
  'output_bytes': 33,
  'output_chars': 11,
  'output_num': 1},
 {'cumulative_time_ns': 17500,
  'diff_bytes': 0,
  'diff_chars': 0,
  'discard_num': 0,
  'input_bytes': 33,
  'input_chars': 11,
  'input_num': 1,
  'name': '1-AcceptJapanese',
  'output_bytes': 33,
  'output_chars': 11,
  'output_num': 1},
 {'cumulative_time_ns': 8125,
  'diff_bytes': 0,
  'diff_chars': 0,
  'discard_num': 0,
  'input_bytes': 33,
  'input_chars': 11,
  'input_num': 1,
  'name': '2-DocumentLengthFilter',
  'output_bytes': 33,
  'output_chars': 11,
  'output_num': 1},
 {'cumulative_time_ns': 6042,
  'diff_bytes': 10,
  'diff_chars': 10,
  'discard_num': 0,
  'input_bytes': 33,
  'input_chars': 11,
  'input_num': 1,
  'name': '3-ExampleHojiChar',
  'output_bytes': 43,
  'output_chars': 21,
  'output_num': 1},
 {'cumulative_time_ns': 81125,
  'diff_bytes': 12,
  'diff_chars': 12,
  'discard_num': 0,
  'input_bytes': 43,
  'input_chars': 21,
  'input_num': 1,
  'name': '4-JSONDumper',
  'output_bytes': 55,
  'output_chars': 33,
  'output_num': 1}]
  • Use get_statistics() to get the raw Statistics object for any filter.
  • Use get_total_statistics() to get a list of statistics for all filters in a Compose pipeline.
  • Use get_total_statistics_map() to retrieve the statistics as a list of dicts.

These tools allow granular monitoring of how each filter contributes to data reduction, rejection, or transformation.

Parallel application of Compose

The hojichar.Parallel class allows for the application of Compose to an iterable of Document concurrently. This class empowers users to process vast collections of documents by harnessing the power of multiple CPU cores.

Example usage of Parallel class to proces a very large JSON Lines file concurrently.

import hojichar

input_file = "your_text.jsonl"
input_doc_iter = (hojichar.Document(line) for line in open(input_file))

cleaner = hojichar.Compose([
    hojichar.document_filters.JSONLoader(),
    hojichar.document_filters.DocumentNormalizer(),
    # Insert your filters
    hojichar.document_filters.JSONDumper(),
])

with hojichar.Parallel(cleaner, num_jobs=10) as pfilter:
    out_doc_iter = pfilter.imap_apply(input_doc_iter)
    with open("your_processed_text.jsonl", "w") as fp:
        for doc in out_doc_iter:
            fp.write(doc.text + "\n")
  • Always use the Parallel class within a with statement.
  • Parallel.imap_apply(doc_iter) processes an iterator of Document and returns an iterator of the processed documents.
  • By default, processed documents are yielded as soon as they complete. Pass ordered=True to Parallel to yield them in the same order as the input documents.
  • For additional options and details about the Parallel class, please refer to the official documentation.

CLI tool and preprocessing profile

  • HojiChar provides CLI tools for text preprocess pipeline.
  • User defines a series of preprocessing into a python file as profile.

  • Example:

    cat <your_text.jsonl> | hojichar -p your_preprocessing_profile.py -o your_text_preprocessed.jsonl
    
  • hojichar --help

    usage: hojichar [-h] --profile <profile.py> [--args ARGS [ARGS ...]] [--output OUTPUT] [--input INPUT] [--dump-stats <path to stats.json>] [--exit-on-error] [--all] [--jobs JOBS]
    
    options:
    -h, --help            show this help message and exit
    --profile <profile.py>, -p <profile.py>
                            Path to a Python file that implements your custom filter.
    --args ARGS [ARGS ...]
                            Pass additional arguments to the profile. Use it like `--args arg1 arg2` etc. The arguments should be space-separated.
    --output OUTPUT, -o OUTPUT
                            Specifies the path for the output file. Defaults to standard output.
    --input INPUT, -i INPUT
                            Specifies the path for the input file. Defaults to standard input. If set this path, the progress bar is enabled.
    --dump-stats <path to stats.json>
                            Dump statistics to file. If the file exists, it will be appended.
    --exit-on-error       Exit if an exception occurs during filtering. Useful for debugging custom filters.
    --all                 A flag that specifies whether to include discarded samples. This is useful when inspecting discarded samples.
    --jobs JOBS, -j JOBS  The number ob parallel jobs. By default, the nuber of the CPU core.
    

Definition of Profile

  • HojiChar CLI receives a series of preprocessing as a profile.
  • The preprocessing profile is provided as a Python file. Two patterns of the file are allowed.
  • hojichar.utils.load_compose.load_compose() loads these profile.

FILTER profile

  • hojichar.Compose must be defined as FILTER variable.
  • Example.

    import json
    
    from hojichar import Compose, Filter
    from hojichar.filters.document_filters import ExampleHojiChar, JSONLoader
    
    
    class JSONDumper(Filter):
        def apply(self, document):
            text = document.text
            document.text = json.dumps({"text": text}, ensure_ascii=False)
            return document
    
    # FILTER must define Compose object.
    FILTER = Compose(
        [
            JSONLoader(),
            ExampleHojiChar(),
            JSONDumper(),
        ]
    )
    
    • Pass the texts to the filter you have defined using a pipe as follows.
      cat <your_file> | hojichar -p example_profile.py
      
  • hojichar.utils.load_compose.load_filter_from_file() loads this type of profile.

FACTORY profile

  • A callable function that returns hojichar.Compose must be defined as FACTORY variable.
  • The callable can receive arguments. In this way, parameters can be passed to the profile.
    • Some kinds of value are not preferred to static. For example, random seeds and some flags modify the behavior of a filter, etc
    • FACTORY provides a mechanism to pass those values as arguments to the preprocessing.
  • Example.

    import json
    
    from hojichar import Compose, Filter
    from hojichar.filters.document_filters import JSONLoader
    
    
    class AddSomething(Filter): #  Concat some value after every document.
      def __init__(self, something: str, *args, **kwargs) -> None:
          self.something = something
    
      def apply(self, document):
          text = document.text + self.something
          document.text = text
          return document
    
    class JSONDumper(Filter):
      def apply(self, document):
          text = document.text
          document.text = json.dumps({"text": text}, ensure_ascii=False)
          return document
    
    
    def callback(something):
      return Compose(
          [
              JSONLoader(),
              AddSomething(something),
              JSONDumper(),
          ]
      )
    
    # FACTORY must be callable which returns Compose object.
    FACTORY = callback
    
  • Using FACTORY profile with arguments in CLI.

    cat <your_file> | hojichar -p example_profile.py --args arg1 arg2
    
  • hojichar.utils.load_compose.load_parametrized_filter_from_file() or load_factory_from_file loads this type of profile.

For Developers

Installing from the Source Directory

To install the package, execute the following commands:

git clone https://github.com/HojiChar/HojiChar.git
cd HojiChar
uv sync --all-extras

Testing

Some filters incorporate doctests. You can run these tests with the command:

pytest --doctest-modules .

This command should be executed from the root of the project.

Code style

  • HojiChar requires type hints for all code. Type checking is performed in continuous integration (CI) in addition to the pytest tests.
  • HojiChar code is subject to inspection and formatting by the ruff Linter. For configuration details, please refer to pyproject.toml. You can perform linting and formatting from the root of the project using the following commands:

Linting

uvx ruff check .

Formatting

uvx ruff format .

Building the Documentation

We use Pdoc for building the documentation. You can build the documentation using the following command:

pdoc -o docs hojichar

Run this command from the project root.

In practice, the process of building the documentation is automated by CI. When a Pull Request is merged into the main branch, the documentation is built in the docs/ directory of the docs branch. This directory is then deployed to the official documentation site by GitHub Pages.

Creating a Source Tarball

To create a source tarball, for instance, for packaging or distribution, run the following command:

uv build

The tarball will be created in the dist directory. This command will compile the source code, and the resulting tarball can be installed with no additional dependencies other than the Python standard library.

Creating a Release and Uploading it to PyPI

Versions uploaded to PyPI are identified by git tags. The __version__ variable in __init__.py or the version entry in pyproject.toml are ignored. The uv-dynamic-versioning plugin is used to implement this process.

The steps to push to PyPI are as follows, although in actuality, the process is automated by CI when a GitHub release is created from the tag.

git checkout v0.1.2
uv build
uv publish --index testpypi --token ${PYPI_TOKEN}

The actual task for the manager is to apply the appropriate tag to the commit to be released and to create the release from GitHub:

git tag -a v0.1.2 -m "Version 0.1.2"
git push origin v0.1.2
 1"""
 2.. include:: ../README.md
 3"""
 4
 5from .core.async_composition import AsyncCompose, AsyncFilterAdapter
 6from .core.async_filter_interface import AsyncFilter
 7from .core.composition import Compose
 8from .core.filter_interface import Filter, TokenFilter
 9from .core.inspection import StatsContainer
10from .core.models import Document, Token
11from .core.parallel import Parallel
12from .filters import (
13    deduplication,
14    document_filters,
15    language_identification,
16    token_filters,
17    tokenization,
18)
19
20__version__ = "0.0.0"  # Replaced by uv-dynamic-versioning when deploying
21
22__all__ = [
23    "core",
24    "filters",
25    "utils",
26    "Compose",
27    "Filter",
28    "TokenFilter",
29    "Document",
30    "Token",
31    "Parallel",
32    "StatsContainer",
33    "deduplication",
34    "document_filters",
35    "language_identification",
36    "token_filters",
37    "tokenization",
38    "AsyncCompose",
39    "AsyncFilterAdapter",
40    "AsyncFilter",
41]
class Compose(hojichar.Filter):
 15class Compose(Filter):
 16    def __init__(
 17        self,
 18        filters: List[Union[Filter, TokenFilter]],
 19        random_state: Optional[Union[int, np.random.Generator]] = None,
 20        *args: Any,
 21        **kwargs: Any,
 22    ) -> None:
 23        """
 24        Compose a filter from pre-defined filter-objects.
 25        Filter which has `skip_rejected` flag ignores a document which has `is_rejected` flag.
 26        By doing so, Compose avoid applying filters that do not affect the output.
 27
 28        Parameters
 29        ----------
 30        filters : List[Union[Filter, TokenFilter]]
 31            Filter instances which apply to the corpus.
 32
 33        random_state : Union[None, int, np.random.Generator], optional
 34            Default = None
 35            Seed for applying filters randomly.
 36            `random_state` must be int or np.random.Generator instance.
 37        """
 38        super().__init__(random_state=random_state, *args, **kwargs)
 39        self.set_filters(filters)
 40        self.logger = logging.getLogger(f"{self.__module__}.{self.__class__.__name__}")
 41
 42        self._statistics.name = "Total"
 43
 44    def set_filters(self, filters: List[Union[Filter, TokenFilter]]) -> None:
 45        """
 46        Set the filter to a Compose object. The filter is expanded if the
 47        list of filters in the argument contains a filter bound by Compose.
 48
 49        Args:
 50            filters (List[Union[Filter, TokenFilter]]): Target filters
 51        """
 52        self.filters: List[Union[Filter, TokenFilter]] = []
 53
 54        filter_idx = 0
 55        for f in filters:
 56            if isinstance(f, Compose):
 57                for sub in f.filters:
 58                    sub._set_rng_if_not_initialized(self._rng)
 59                    name = f"{filter_idx}-{sub.__class__.__name__}"
 60                    sub.name = name
 61                    sub._statistics.name = name
 62                    self.filters.append(sub)
 63                    filter_idx += 1
 64            else:
 65                f._set_rng_if_not_initialized(self._rng)
 66                name = f"{filter_idx}-{f.__class__.__name__}"
 67                f.name = name
 68                f._statistics.name = name
 69                self.filters.append(f)
 70                filter_idx += 1
 71
 72    def __call__(self, text: str, **kwargs: Any) -> str:
 73        """
 74        Apply the composed filter to a text and return the processed text.
 75        If the document is rejected, return an empty string.
 76        """
 77        document = Document(text, **kwargs)
 78        document = self.apply(document)
 79        if document.is_rejected:
 80            return ""
 81        else:
 82            return document.text
 83
 84    def apply(self, document: Document) -> Document:
 85        """
 86        Apply the composed filter to a document and return the processed document.
 87        """
 88        stat = get_doc_info(document)
 89        for i, filt in enumerate(self.filters):
 90            document = filt._apply(document)
 91        new_stat = get_doc_info(document)
 92        self._statistics.update_by_diff(stat, new_stat)
 93        return document
 94
 95    def apply_batch(self, batch: Sequence[Document]) -> List[Document]:
 96        """
 97        Apply the composed filter to a batch of documents and return the processed documents.
 98        The `apply_batch` method implemented in sub-filters is called in order.
 99        """
100
101        stats = [get_doc_info(doc) for doc in batch]
102        for i, filt in enumerate(self.filters):
103            batch = filt._apply_batch(batch)
104        batch = self._finalize_batch(batch, stats)
105        return list(batch)
106
107    def apply_stream(self, stream: Iterable[Document]) -> Iterable[Document]:
108        """
109        Apply the composed filter to a stream of documents and return the processed documents.
110        The `apply_stream` method implemented in sub-filters is called in order.
111
112
113        In a sub-filter, if `apply_batch` is overridden and implemented, you need to set `use_batch`
114        to True at that filter to utilize that implementation. Otherwise, the
115        method implemented in `apply` will be applied to the stream.
116        """
117        stream = self._count_input_stats(stream)
118        for i, filt in enumerate(self.filters):
119            stream = filt.apply_stream(stream)
120
121        for doc in stream:
122            in_stat = doc._get_initial_stats()
123            if in_stat is None:
124                in_stat = get_doc_info(doc)
125                self.logger.debug(
126                    "Initial stats missing for document during stream aggregation; "
127                    "using current stats as fallback"
128                )
129            out_stat = get_doc_info(doc)
130
131            self._statistics.update_by_diff(in_stat, out_stat)
132            doc._clear_initial_stats()
133            yield doc
134
135    def _count_input_stats(self, stream: Iterable[Document]) -> Iterable[Document]:
136        for doc in stream:
137            doc._set_initial_stats(get_doc_info(doc))
138            yield doc
139
140    def get_total_statistics(self) -> List[Statistics]:
141        """
142        Get the statistics of the Compose object and sub filters.
143
144        The statistics of the Compose class are stored in an object with the name "Total",
145        and sub-filters's are stored with names in the format {filter_index}-{filter class name}.
146        """
147        stats = []
148        stats.append(self.get_statistics())
149        for i, filt in enumerate(self.filters):
150            stats.append(filt.get_statistics())
151        return stats
152
153    def get_total_statistics_map(self) -> List[Dict[str, Any]]:
154        """
155        Get the statistics of the Compose object and sub filters as a list of dictionaries.
156        """
157        stats = self.get_total_statistics()
158        return [stat.to_dict() for stat in stats]
159
160    def shutdown(self) -> None:
161        for f in self.filters:
162            f.shutdown()
163
164        super().shutdown()
165
166    @property
167    def statistics(self) -> dict:
168        """
169        Deprecated
170
171        Get the statistics of the Compose object and sub filters.
172
173        This property is retained for compatibility with previous versions.
174        Please use `get_total_statistics` or `get_total_statistics_map` instead.
175        """
176        return inspection.statistics_obj_adapter(  # type: ignore
177            self.get_total_statistics()
178        ).get_human_readable_values()
179
180    @property
181    def statistics_obj(self) -> inspection.StatsContainer:
182        """
183        Deprecated
184
185        Get the statistics of the Compose object and sub filters.
186        This method returns a StatsContainer object which contains the statistics
187        of the Compose object and sub filters.
188
189        This property is retained for compatibility with previous versions.
190        Please use `get_total_statistics` or `get_total_statistics_map` instead.
191        """
192        return inspection.statistics_obj_adapter(self.get_total_statistics())  # type: ignore
193
194    @deprecated_since("1.0.0", "get_total_statistics")
195    def summary(self, format: str = "print") -> None:
196        info = [
197            {
198                "layer": i,
199                "name": filt.name,
200                "doc": filt.__doc__,
201            }
202            for i, filt in enumerate(self.filters)
203        ]
204
205        def to_json(filter_info: dict) -> dict:
206            filter_info["doc"] = "".join(d.strip() for d in filter_info["doc"].split("\n"))
207            return filter_info
208
209        if format == "json":
210            print(json.dumps(list(map(to_json, info)), ensure_ascii=False, indent="\t"))
211        if format == "print":
212            for layer in info:
213                print(f"[{layer['layer']}] {layer['name']}")
214                pprint.pprint(layer["doc"])

Base class for all filters. Document-level filters must inherit from this class.

The definition of text processing is in apply method. If you define a new filter, override the method.

When this class is called, apply the filter from string to string.

With context manager, you can use the filter as follows:

with YourFilter(p=0.5) as filt:
    text = filt("This is a sample text.")
Compose( filters: List[Union[hojichar.Filter, hojichar.TokenFilter]], random_state: Union[int, numpy.random._generator.Generator, NoneType] = None, *args: Any, **kwargs: Any)
16    def __init__(
17        self,
18        filters: List[Union[Filter, TokenFilter]],
19        random_state: Optional[Union[int, np.random.Generator]] = None,
20        *args: Any,
21        **kwargs: Any,
22    ) -> None:
23        """
24        Compose a filter from pre-defined filter-objects.
25        Filter which has `skip_rejected` flag ignores a document which has `is_rejected` flag.
26        By doing so, Compose avoid applying filters that do not affect the output.
27
28        Parameters
29        ----------
30        filters : List[Union[Filter, TokenFilter]]
31            Filter instances which apply to the corpus.
32
33        random_state : Union[None, int, np.random.Generator], optional
34            Default = None
35            Seed for applying filters randomly.
36            `random_state` must be int or np.random.Generator instance.
37        """
38        super().__init__(random_state=random_state, *args, **kwargs)
39        self.set_filters(filters)
40        self.logger = logging.getLogger(f"{self.__module__}.{self.__class__.__name__}")
41
42        self._statistics.name = "Total"

Compose a filter from pre-defined filter-objects. Filter which has skip_rejected flag ignores a document which has is_rejected flag. By doing so, Compose avoid applying filters that do not affect the output.

Parameters

filters : List[Union[Filter, TokenFilter]] Filter instances which apply to the corpus.

random_state : Union[None, int, np.random.Generator], optional Default = None Seed for applying filters randomly. random_state must be int or np.random.Generator instance.

def set_filters( self, filters: List[Union[hojichar.Filter, hojichar.TokenFilter]]) -> None:
44    def set_filters(self, filters: List[Union[Filter, TokenFilter]]) -> None:
45        """
46        Set the filter to a Compose object. The filter is expanded if the
47        list of filters in the argument contains a filter bound by Compose.
48
49        Args:
50            filters (List[Union[Filter, TokenFilter]]): Target filters
51        """
52        self.filters: List[Union[Filter, TokenFilter]] = []
53
54        filter_idx = 0
55        for f in filters:
56            if isinstance(f, Compose):
57                for sub in f.filters:
58                    sub._set_rng_if_not_initialized(self._rng)
59                    name = f"{filter_idx}-{sub.__class__.__name__}"
60                    sub.name = name
61                    sub._statistics.name = name
62                    self.filters.append(sub)
63                    filter_idx += 1
64            else:
65                f._set_rng_if_not_initialized(self._rng)
66                name = f"{filter_idx}-{f.__class__.__name__}"
67                f.name = name
68                f._statistics.name = name
69                self.filters.append(f)
70                filter_idx += 1

Set the filter to a Compose object. The filter is expanded if the list of filters in the argument contains a filter bound by Compose.

Args: filters (List[Union[Filter, TokenFilter]]): Target filters

def apply( self, document: hojichar.Document) -> hojichar.Document:
84    def apply(self, document: Document) -> Document:
85        """
86        Apply the composed filter to a document and return the processed document.
87        """
88        stat = get_doc_info(document)
89        for i, filt in enumerate(self.filters):
90            document = filt._apply(document)
91        new_stat = get_doc_info(document)
92        self._statistics.update_by_diff(stat, new_stat)
93        return document

Apply the composed filter to a document and return the processed document.

def apply_batch( self, batch: Sequence[hojichar.Document]) -> List[hojichar.Document]:
 95    def apply_batch(self, batch: Sequence[Document]) -> List[Document]:
 96        """
 97        Apply the composed filter to a batch of documents and return the processed documents.
 98        The `apply_batch` method implemented in sub-filters is called in order.
 99        """
100
101        stats = [get_doc_info(doc) for doc in batch]
102        for i, filt in enumerate(self.filters):
103            batch = filt._apply_batch(batch)
104        batch = self._finalize_batch(batch, stats)
105        return list(batch)

Apply the composed filter to a batch of documents and return the processed documents. The apply_batch method implemented in sub-filters is called in order.

def apply_stream( self, stream: Iterable[hojichar.Document]) -> Iterable[hojichar.Document]:
107    def apply_stream(self, stream: Iterable[Document]) -> Iterable[Document]:
108        """
109        Apply the composed filter to a stream of documents and return the processed documents.
110        The `apply_stream` method implemented in sub-filters is called in order.
111
112
113        In a sub-filter, if `apply_batch` is overridden and implemented, you need to set `use_batch`
114        to True at that filter to utilize that implementation. Otherwise, the
115        method implemented in `apply` will be applied to the stream.
116        """
117        stream = self._count_input_stats(stream)
118        for i, filt in enumerate(self.filters):
119            stream = filt.apply_stream(stream)
120
121        for doc in stream:
122            in_stat = doc._get_initial_stats()
123            if in_stat is None:
124                in_stat = get_doc_info(doc)
125                self.logger.debug(
126                    "Initial stats missing for document during stream aggregation; "
127                    "using current stats as fallback"
128                )
129            out_stat = get_doc_info(doc)
130
131            self._statistics.update_by_diff(in_stat, out_stat)
132            doc._clear_initial_stats()
133            yield doc

Apply the composed filter to a stream of documents and return the processed documents. The apply_stream method implemented in sub-filters is called in order.

In a sub-filter, if apply_batch is overridden and implemented, you need to set use_batch to True at that filter to utilize that implementation. Otherwise, the method implemented in apply will be applied to the stream.

def get_total_statistics(self) -> List[hojichar.core.models.Statistics]:
140    def get_total_statistics(self) -> List[Statistics]:
141        """
142        Get the statistics of the Compose object and sub filters.
143
144        The statistics of the Compose class are stored in an object with the name "Total",
145        and sub-filters's are stored with names in the format {filter_index}-{filter class name}.
146        """
147        stats = []
148        stats.append(self.get_statistics())
149        for i, filt in enumerate(self.filters):
150            stats.append(filt.get_statistics())
151        return stats

Get the statistics of the Compose object and sub filters.

The statistics of the Compose class are stored in an object with the name "Total", and sub-filters's are stored with names in the format {filter_index}-{filter class name}.

def get_total_statistics_map(self) -> List[Dict[str, Any]]:
153    def get_total_statistics_map(self) -> List[Dict[str, Any]]:
154        """
155        Get the statistics of the Compose object and sub filters as a list of dictionaries.
156        """
157        stats = self.get_total_statistics()
158        return [stat.to_dict() for stat in stats]

Get the statistics of the Compose object and sub filters as a list of dictionaries.

def shutdown(self) -> None:
160    def shutdown(self) -> None:
161        for f in self.filters:
162            f.shutdown()
163
164        super().shutdown()

This method is called when the filter is no longer needed. You can override this method to release resources or perform cleanup tasks.

statistics: dict

Deprecated

Get the statistics of the Compose object and sub filters.

This property is retained for compatibility with previous versions. Please use get_total_statistics or get_total_statistics_map instead.

statistics_obj: hojichar.StatsContainer

Deprecated

Get the statistics of the Compose object and sub filters. This method returns a StatsContainer object which contains the statistics of the Compose object and sub filters.

This property is retained for compatibility with previous versions. Please use get_total_statistics or get_total_statistics_map instead.

@deprecated_since('1.0.0', 'get_total_statistics')
def summary(self, format: str = 'print') -> None:
194    @deprecated_since("1.0.0", "get_total_statistics")
195    def summary(self, format: str = "print") -> None:
196        info = [
197            {
198                "layer": i,
199                "name": filt.name,
200                "doc": filt.__doc__,
201            }
202            for i, filt in enumerate(self.filters)
203        ]
204
205        def to_json(filter_info: dict) -> dict:
206            filter_info["doc"] = "".join(d.strip() for d in filter_info["doc"].split("\n"))
207            return filter_info
208
209        if format == "json":
210            print(json.dumps(list(map(to_json, info)), ensure_ascii=False, indent="\t"))
211        if format == "print":
212            for layer in info:
213                print(f"[{layer['layer']}] {layer['name']}")
214                pprint.pprint(layer["doc"])
class Filter(abc.ABC):
 22class Filter(ABC):
 23    """
 24    Base class for all filters.
 25    Document-level filters must inherit from this class.
 26
 27    The definition of text processing is in `apply` method.
 28    If you define a new filter, override the method.
 29
 30    When this class is called, apply the filter from string to string.
 31
 32    With context manager, you can use the filter as follows:
 33    ```python
 34    with YourFilter(p=0.5) as filt:
 35        text = filt("This is a sample text.")
 36    ```
 37
 38    """
 39
 40    def __init__(
 41        self,
 42        p: float = 1.0,
 43        skip_rejected: bool = True,
 44        *args: Any,
 45        random_state: Optional[Union[int, np.random.Generator]] = None,
 46        use_batch: bool = False,
 47        batch_size: int = 128,
 48        **kwargs: Any,
 49    ) -> None:
 50        """
 51        Initialize the filter.
 52        Parameters
 53        ----------
 54        p : float
 55            The probability of applying the filter.
 56            If `p` is 1, the filter will always be applied.
 57        skip_rejected : bool
 58            If `True`, the filter will skip documents that are already rejected.
 59            If you want to apply the filter to all documents (e.g., postprocess), set this to `False`.
 60        random_state : Optional[Union[int, np.random.Generator]]
 61            Seed for the random number generator.
 62            If `None`, a new random number generator will be created.
 63            If `None`, and use in the `Compose` class, the random state is shared with the `Compose` object.
 64        use_batch : bool
 65            If `True`, the filter will process documents in batches in the `apply_stream` method.
 66        batch_size : int
 67            The size of the batch to process documents in the `apply_stream` method.
 68        kwargs : Any
 69            Additional keyword arguments to pass to the filter.
 70        """
 71        self.name = self.__class__.__name__
 72        self.logger = logging.getLogger(f"{self.__module__}.{self.__class__.__name__}")
 73        assert 0 <= p <= 1
 74        self.p = p
 75        self.__init_rng(random_state)
 76        self.skip_rejected = skip_rejected
 77        self.use_batch = use_batch
 78        self.batch_size = batch_size
 79
 80        self._statistics: Statistics = Statistics()
 81
 82    @abstractmethod
 83    def apply(self, document: Document) -> Document:
 84        """
 85        Definition of filter behavior.
 86
 87        The document must have a protocol `TextContent`,
 88        and mostly used hojichar.Document class.
 89
 90        In this method, the filter will modify `document.text` or
 91        `document.extras` and set `document.is_rejected = True` to discard the document.
 92
 93        Parameters
 94        ----------
 95        document : Document
 96            Input document
 97
 98        Returns
 99        -------
100        Document
101            Processed Document
102        """
103
104    @deprecated_since(version="1.0.0", alternative="apply")
105    def apply_filter(self, document: Document) -> Document:
106        document = self.apply(document)
107        return document
108
109    def _check_skip(self, document: Document) -> bool:
110        """
111        Check if the document should be skipped by this filter.
112        If `skip_rejected` is set to `True`, this method will return `True`
113        if the document is already rejected.
114        If `p` is less than 1, this method will return `True` with a probability of `1 - p`.
115        """
116        skip = self.skip_rejected and document.is_rejected
117        if skip:
118            return True
119        if self.p < 1:
120            if self._rng.random() > self.p:
121                return True
122        return False
123
124    def _apply(self, document: Document) -> Document:
125        """
126        Apply the filter to a single document.
127        This method
128          - checks if the document should be skipped
129          - counts and logging the statistics
130          - logging the reason for rejection if the document is rejected
131
132        This method may be used in `apply` method of `Compose` class.
133        """
134
135        stats = get_doc_info(document)
136
137        if not self._check_skip(document):
138            document = self.apply(document)
139
140        new_stats = get_doc_info(document)
141        self._statistics.update_by_diff(stats, new_stats)
142
143        if not stats["is_rejected"] and new_stats["is_rejected"]:
144            document.reject_reason = self.get_jsonable_vars()
145
146        return document
147
148    def apply_batch(self, batch: Sequence[Document]) -> List[Document]:
149        """
150        Apply the filter to a batch of documents.
151        You can override this method if you want to
152        apply the filter to a batch of documents at once.
153
154        This method may be used in `apply_batch` method of `Compose` class.
155
156        Parameters
157        ----------
158        documents : Sequence[Document]
159            List-like object of input documents
160
161        Returns
162        -------
163        list[Document]
164            List of processed documents
165        """
166        return [self.apply(document) for document in batch]
167
168    def _apply_batch(self, batch: Sequence[Document]) -> List[Document]:
169        """
170        Apply the filter to a batch of documents.
171        This method
172        - checks if the documents should be skipped
173        - counts and logs the statistics
174        - logs the reason for rejection if any document is rejected
175        """
176        skip = False
177        if self.p < 1:
178            skip = self._rng.random() > self.p
179
180        stats = [get_doc_info(document=doc) for doc in batch]
181        if not skip:
182            batch = self.apply_batch(batch)
183        batch = self._finalize_batch(batch, stats)
184        return batch
185
186    def apply_stream(self, stream: Iterable[Document]) -> Iterable[Document]:
187        """
188        Apply the filter to a stream of documents.
189        This method is used when you want to process documents one by one.
190        If `use_batch` is set to `True` in the constructor,
191        this method will process documents in batches using the `apply_batch` method.
192
193        Even if an exception occurs during processing, the process will continue, and the following actions will be taken:
194        - Set the `is_rejected` flag of the document to `True`
195        - Set the error details in `reject_reason`
196        - Increment the `errors` count in the statistics retrievable via `get_statistics`
197
198        Parameters
199        ----------
200        stream : Iterable[Document]
201            Stream of input documents
202
203        Returns
204        -------
205        Iterable[Document]
206            Stream of processed documents
207        """
208
209        if not self.use_batch:
210            for document in stream:
211                yield self._try_process(document, self._apply)
212        else:
213            batch: list[Document] = []
214            for document in stream:
215                if self._check_skip(document):
216                    yield document
217                    continue
218
219                batch.append(document)
220                if len(batch) >= self.batch_size:
221                    stats = [get_doc_info(doc) for doc in batch]
222                    batch = self._try_process(batch, self.apply_batch)
223                    batch = self._finalize_batch(batch, stats)
224                    yield from batch
225                    batch.clear()
226            if batch:
227                stats = [get_doc_info(doc) for doc in batch]
228                batch = self._try_process(batch, self.apply_batch)
229                batch = self._finalize_batch(batch, stats)
230                yield from batch
231
232    def _try_process(self, target: T, func: Callable[[T], T]) -> T:
233        try:
234            return func(target)
235        except Exception as e:
236            if isinstance(target, Document):
237                msg = f"{e!r} occurs while processing {self.name} with {target!r}"
238                target.is_rejected = True
239                target.reject_reason = {"error": msg}
240                self._statistics.errors += 1
241                self.logger.error(msg, exc_info=True)
242                return target
243            if isinstance(target, list):
244                msg = f"{e!r} occurs while batch processing {self.name}"
245                self.logger.error(msg, exc_info=True)
246                for doc in target:
247                    doc.is_rejected = True
248                    doc.reject_reason = {"error": msg}
249                self._statistics.errors += len(target)
250                return target
251            else:
252                raise e
253
254    def __call__(self, text: str, **kwargs: Any) -> str:
255        document = Document(text, **kwargs)
256        document = self._apply(document)
257        return document.text
258
259    def get_statistics(self) -> Statistics:
260        """
261        Get the statistics of this filter.
262        This method returns the statistics of the filter,
263        which includes the number of processed documents, discarded documents, and other statistics.
264        """
265        return self._statistics
266
267    def get_statistics_map(self) -> Dict[str, Any]:
268        """
269        Get the statistics of this filter as a dictionary.
270        """
271        return self._statistics.to_dict()
272
273    def shutdown(self) -> None:
274        """
275        This method is called when the filter is no longer needed.
276        You can override this method to release resources or perform cleanup tasks.
277        """
278        pass
279
280    def __enter__(self) -> "Filter":
281        return self
282
283    def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None:
284        """
285        This method is called when the filter is used in a context manager.
286        It calls the `shutdown` method to release resources or perform cleanup tasks.
287        """
288        self.shutdown()
289
290    def get_jsonable_vars(self, exclude_keys: Optional[Set[str]] = None) -> Dict[str, Any]:
291        """
292        Get the member variable of this filter.
293        Eligible variables are primitive types; [bool, int, float, str, None],
294        and the name of the variable not starts with the underscore; `_`.
295        """
296        if exclude_keys is None:
297            exclude_keys = set()
298        return {
299            k: v
300            for k, v in vars(self).items()
301            if (_is_jsonable(v) and (k not in exclude_keys) and (not k.startswith("_")))
302        }
303
304    def _finalize_batch(
305        self: "Filter",
306        batch: Sequence[Document],
307        old_stats: List[Dict[str, Any]] = [],
308    ) -> List[Document]:
309        new_stats = [get_doc_info(doc) for doc in batch]
310        for old, new, doc in zip(old_stats, new_stats, batch):
311            self._statistics.update_by_diff(old, new)
312            if not old["is_rejected"] and new["is_rejected"]:
313                doc.reject_reason = self.get_jsonable_vars()
314        return list(batch)
315
316    def __init_rng(self, random_state: Optional[Union[int, np.random.Generator]]) -> None:
317        self._owns_rng = True
318        if random_state is None:
319            self._rng = np.random.default_rng()
320            self._owns_rng = False
321        elif isinstance(random_state, int):
322            self._rng = np.random.default_rng(random_state)
323        elif isinstance(random_state, np.random.Generator):
324            self._rng = random_state
325
326    def _set_rng_if_not_initialized(self, rng: np.random.Generator) -> None:
327        """
328        Set the random number generator for this filter if it is not already initialized.
329        This method is called by Compose class.
330        """
331        if not self._owns_rng:
332            self._rng = rng

Base class for all filters. Document-level filters must inherit from this class.

The definition of text processing is in apply method. If you define a new filter, override the method.

When this class is called, apply the filter from string to string.

With context manager, you can use the filter as follows:

with YourFilter(p=0.5) as filt:
    text = filt("This is a sample text.")
Filter( p: float = 1.0, skip_rejected: bool = True, *args: Any, random_state: Union[int, numpy.random._generator.Generator, NoneType] = None, use_batch: bool = False, batch_size: int = 128, **kwargs: Any)
40    def __init__(
41        self,
42        p: float = 1.0,
43        skip_rejected: bool = True,
44        *args: Any,
45        random_state: Optional[Union[int, np.random.Generator]] = None,
46        use_batch: bool = False,
47        batch_size: int = 128,
48        **kwargs: Any,
49    ) -> None:
50        """
51        Initialize the filter.
52        Parameters
53        ----------
54        p : float
55            The probability of applying the filter.
56            If `p` is 1, the filter will always be applied.
57        skip_rejected : bool
58            If `True`, the filter will skip documents that are already rejected.
59            If you want to apply the filter to all documents (e.g., postprocess), set this to `False`.
60        random_state : Optional[Union[int, np.random.Generator]]
61            Seed for the random number generator.
62            If `None`, a new random number generator will be created.
63            If `None`, and use in the `Compose` class, the random state is shared with the `Compose` object.
64        use_batch : bool
65            If `True`, the filter will process documents in batches in the `apply_stream` method.
66        batch_size : int
67            The size of the batch to process documents in the `apply_stream` method.
68        kwargs : Any
69            Additional keyword arguments to pass to the filter.
70        """
71        self.name = self.__class__.__name__
72        self.logger = logging.getLogger(f"{self.__module__}.{self.__class__.__name__}")
73        assert 0 <= p <= 1
74        self.p = p
75        self.__init_rng(random_state)
76        self.skip_rejected = skip_rejected
77        self.use_batch = use_batch
78        self.batch_size = batch_size
79
80        self._statistics: Statistics = Statistics()

Initialize the filter.

Parameters

p : float The probability of applying the filter. If p is 1, the filter will always be applied. skip_rejected : bool If True, the filter will skip documents that are already rejected. If you want to apply the filter to all documents (e.g., postprocess), set this to False. random_state : Optional[Union[int, np.random.Generator]] Seed for the random number generator. If None, a new random number generator will be created. If None, and use in the Compose class, the random state is shared with the Compose object. use_batch : bool If True, the filter will process documents in batches in the apply_stream method. batch_size : int The size of the batch to process documents in the apply_stream method. kwargs : Any Additional keyword arguments to pass to the filter.

@abstractmethod
def apply( self, document: hojichar.Document) -> hojichar.Document:
 82    @abstractmethod
 83    def apply(self, document: Document) -> Document:
 84        """
 85        Definition of filter behavior.
 86
 87        The document must have a protocol `TextContent`,
 88        and mostly used hojichar.Document class.
 89
 90        In this method, the filter will modify `document.text` or
 91        `document.extras` and set `document.is_rejected = True` to discard the document.
 92
 93        Parameters
 94        ----------
 95        document : Document
 96            Input document
 97
 98        Returns
 99        -------
100        Document
101            Processed Document
102        """

Definition of filter behavior.

The document must have a protocol TextContent, and mostly used hojichar.Document class.

In this method, the filter will modify document.text or document.extras and set document.is_rejected = True to discard the document.

Parameters

document : Document Input document

Returns

Document Processed Document

@deprecated_since(version='1.0.0', alternative='apply')
def apply_filter( self, document: hojichar.Document) -> hojichar.Document:
104    @deprecated_since(version="1.0.0", alternative="apply")
105    def apply_filter(self, document: Document) -> Document:
106        document = self.apply(document)
107        return document
def apply_batch( self, batch: Sequence[hojichar.Document]) -> List[hojichar.Document]:
148    def apply_batch(self, batch: Sequence[Document]) -> List[Document]:
149        """
150        Apply the filter to a batch of documents.
151        You can override this method if you want to
152        apply the filter to a batch of documents at once.
153
154        This method may be used in `apply_batch` method of `Compose` class.
155
156        Parameters
157        ----------
158        documents : Sequence[Document]
159            List-like object of input documents
160
161        Returns
162        -------
163        list[Document]
164            List of processed documents
165        """
166        return [self.apply(document) for document in batch]

Apply the filter to a batch of documents. You can override this method if you want to apply the filter to a batch of documents at once.

This method may be used in apply_batch method of Compose class.

Parameters

documents : Sequence[Document] List-like object of input documents

Returns

list[Document] List of processed documents

def apply_stream( self, stream: Iterable[hojichar.Document]) -> Iterable[hojichar.Document]:
186    def apply_stream(self, stream: Iterable[Document]) -> Iterable[Document]:
187        """
188        Apply the filter to a stream of documents.
189        This method is used when you want to process documents one by one.
190        If `use_batch` is set to `True` in the constructor,
191        this method will process documents in batches using the `apply_batch` method.
192
193        Even if an exception occurs during processing, the process will continue, and the following actions will be taken:
194        - Set the `is_rejected` flag of the document to `True`
195        - Set the error details in `reject_reason`
196        - Increment the `errors` count in the statistics retrievable via `get_statistics`
197
198        Parameters
199        ----------
200        stream : Iterable[Document]
201            Stream of input documents
202
203        Returns
204        -------
205        Iterable[Document]
206            Stream of processed documents
207        """
208
209        if not self.use_batch:
210            for document in stream:
211                yield self._try_process(document, self._apply)
212        else:
213            batch: list[Document] = []
214            for document in stream:
215                if self._check_skip(document):
216                    yield document
217                    continue
218
219                batch.append(document)
220                if len(batch) >= self.batch_size:
221                    stats = [get_doc_info(doc) for doc in batch]
222                    batch = self._try_process(batch, self.apply_batch)
223                    batch = self._finalize_batch(batch, stats)
224                    yield from batch
225                    batch.clear()
226            if batch:
227                stats = [get_doc_info(doc) for doc in batch]
228                batch = self._try_process(batch, self.apply_batch)
229                batch = self._finalize_batch(batch, stats)
230                yield from batch

Apply the filter to a stream of documents. This method is used when you want to process documents one by one. If use_batch is set to True in the constructor, this method will process documents in batches using the apply_batch method.

Even if an exception occurs during processing, the process will continue, and the following actions will be taken:

  • Set the is_rejected flag of the document to True
  • Set the error details in reject_reason
  • Increment the errors count in the statistics retrievable via get_statistics

Parameters

stream : Iterable[Document] Stream of input documents

Returns

Iterable[Document] Stream of processed documents

def get_statistics(self) -> hojichar.core.models.Statistics:
259    def get_statistics(self) -> Statistics:
260        """
261        Get the statistics of this filter.
262        This method returns the statistics of the filter,
263        which includes the number of processed documents, discarded documents, and other statistics.
264        """
265        return self._statistics

Get the statistics of this filter. This method returns the statistics of the filter, which includes the number of processed documents, discarded documents, and other statistics.

def get_statistics_map(self) -> Dict[str, Any]:
267    def get_statistics_map(self) -> Dict[str, Any]:
268        """
269        Get the statistics of this filter as a dictionary.
270        """
271        return self._statistics.to_dict()

Get the statistics of this filter as a dictionary.

def shutdown(self) -> None:
273    def shutdown(self) -> None:
274        """
275        This method is called when the filter is no longer needed.
276        You can override this method to release resources or perform cleanup tasks.
277        """
278        pass

This method is called when the filter is no longer needed. You can override this method to release resources or perform cleanup tasks.

def get_jsonable_vars(self, exclude_keys: Optional[Set[str]] = None) -> Dict[str, Any]:
290    def get_jsonable_vars(self, exclude_keys: Optional[Set[str]] = None) -> Dict[str, Any]:
291        """
292        Get the member variable of this filter.
293        Eligible variables are primitive types; [bool, int, float, str, None],
294        and the name of the variable not starts with the underscore; `_`.
295        """
296        if exclude_keys is None:
297            exclude_keys = set()
298        return {
299            k: v
300            for k, v in vars(self).items()
301            if (_is_jsonable(v) and (k not in exclude_keys) and (not k.startswith("_")))
302        }

Get the member variable of this filter. Eligible variables are primitive types; [bool, int, float, str, None], and the name of the variable not starts with the underscore; _.

@deprecated_since(version='1.0.0', alternative='Filter')
class TokenFilter(hojichar.Filter, abc.ABC):
335@deprecated_since(version="1.0.0", alternative="Filter")
336class TokenFilter(Filter, ABC):
337    """
338    Base class for token-level filters.
339
340    Token filters, which shuld be implemented in hojichar/filters/token_filters.py,
341    must inherit from this class.
342    """
343
344    def __init__(
345        self, p: float = 1, skip_rejected: bool = True, *args: Any, **kwargs: Any
346    ) -> None:
347        self.name = self.__class__.__name__
348        self.logger = logging.getLogger("hojichar.token_filters." + self.name)
349        assert 0 <= p <= 1
350        self.p = p
351        self.skip_rejected = skip_rejected
352
353    def apply(self, token: Token) -> Token:  # type: ignore
354        raise NotImplementedError(f"{self.__class__.__name__}.apply method is not defined")
355        return token
356
357    def apply_filter(self, document: Document) -> Document:
358        document.tokens = [self.apply(token) for token in document.tokens if not token.is_rejected]
359        return document
360
361    def __call__(self, text: str) -> str:  # type: ignore
362        token = Token(text)
363        token = self.apply(token)
364        return token.text
365
366    def _apply(self, document: Document) -> Document:
367        """
368        Apply the token filter to a single document.
369        This method checks if the document should be skipped.
370        """
371        if self.skip_rejected and document.is_rejected:
372            return document
373        return self.apply_filter(document)
374
375    def get_jsonable_vars(self, exclude_keys: Optional[Set[str]] = None) -> dict:
376        """
377        Get the member variable of this filter.
378        Eligible variables are primitive types; [bool, int, float, str, None],
379        and the name of the variable not starts with the underscore; `_`.
380        """
381        if exclude_keys is None:
382            exclude_keys = set()
383        return {
384            k: v
385            for k, v in vars(self).items()
386            if (_is_jsonable(v) and (k not in exclude_keys) and (not k.startswith("_")))
387        }

Base class for token-level filters.

Token filters, which shuld be implemented in hojichar/filters/token_filters.py, must inherit from this class.

TokenFilter(p: float = 1, skip_rejected: bool = True, *args: Any, **kwargs: Any)
344    def __init__(
345        self, p: float = 1, skip_rejected: bool = True, *args: Any, **kwargs: Any
346    ) -> None:
347        self.name = self.__class__.__name__
348        self.logger = logging.getLogger("hojichar.token_filters." + self.name)
349        assert 0 <= p <= 1
350        self.p = p
351        self.skip_rejected = skip_rejected

Initialize the filter.

Parameters

p : float The probability of applying the filter. If p is 1, the filter will always be applied. skip_rejected : bool If True, the filter will skip documents that are already rejected. If you want to apply the filter to all documents (e.g., postprocess), set this to False. random_state : Optional[Union[int, np.random.Generator]] Seed for the random number generator. If None, a new random number generator will be created. If None, and use in the Compose class, the random state is shared with the Compose object. use_batch : bool If True, the filter will process documents in batches in the apply_stream method. batch_size : int The size of the batch to process documents in the apply_stream method. kwargs : Any Additional keyword arguments to pass to the filter.

def apply(self, token: hojichar.Token) -> hojichar.Token:
353    def apply(self, token: Token) -> Token:  # type: ignore
354        raise NotImplementedError(f"{self.__class__.__name__}.apply method is not defined")
355        return token

Definition of filter behavior.

The document must have a protocol TextContent, and mostly used hojichar.Document class.

In this method, the filter will modify document.text or document.extras and set document.is_rejected = True to discard the document.

Parameters

document : Document Input document

Returns

Document Processed Document

def apply_filter( self, document: hojichar.Document) -> hojichar.Document:
357    def apply_filter(self, document: Document) -> Document:
358        document.tokens = [self.apply(token) for token in document.tokens if not token.is_rejected]
359        return document
def get_jsonable_vars(self, exclude_keys: Optional[Set[str]] = None) -> dict:
375    def get_jsonable_vars(self, exclude_keys: Optional[Set[str]] = None) -> dict:
376        """
377        Get the member variable of this filter.
378        Eligible variables are primitive types; [bool, int, float, str, None],
379        and the name of the variable not starts with the underscore; `_`.
380        """
381        if exclude_keys is None:
382            exclude_keys = set()
383        return {
384            k: v
385            for k, v in vars(self).items()
386            if (_is_jsonable(v) and (k not in exclude_keys) and (not k.startswith("_")))
387        }

Get the member variable of this filter. Eligible variables are primitive types; [bool, int, float, str, None], and the name of the variable not starts with the underscore; _.

class Document:
 24class Document:
 25    """
 26    Document class represents a text document with metadata.
 27    It contains the text of the document, a flag indicating whether it is rejected,
 28     and additional metadata stored in the `extras` dictionary.
 29
 30    The `tokens` attribute will be deprecated in future versions,
 31    and users are encouraged to use the `extras` dictionary to store token-related information.
 32
 33    Attributes:
 34        text (str): The text content of the document.
 35        is_rejected (bool): A flag indicating whether the document is rejected.
 36        extras (Dict[str, Any]): A dictionary to store additional metadata about the document.
 37        reject_reason (Dict[str, Any]): A dictionary to store the reason for rejection. The
 38          filter class and the member name and value will logged at the filter is logged here.
 39        initial_stats (Optional[Dict[str, Any]]): Internal copy of the document statistics
 40          captured before the pipeline mutates the document.
 41
 42    Next attributes will be deprecated in future versions:
 43        tokens (List[Token]): A list of tokens extracted from the document.
 44    """
 45
 46    def __init__(
 47        self,
 48        text: str,
 49        is_rejected: bool = False,
 50        tokens: Optional[List[Token]] = None,
 51        extras: Optional[Dict[str, Any]] = None,
 52    ) -> None:
 53        self.text = text
 54        self.__original = text
 55        self.is_rejected = is_rejected
 56        if tokens is None:
 57            self.tokens: List[Token] = []
 58        else:
 59            self.tokens = tokens
 60
 61        if extras is None:
 62            self.extras: Dict[str, Any] = {}
 63        else:
 64            self.extras = extras
 65
 66        self.reject_reason: Dict[str, Any] = {}
 67        self._initial_stats: Optional[dict[str, Any]] = None
 68        if "__init_stats" in self.extras:
 69            self._initial_stats = self.extras.pop("__init_stats")
 70
 71    @property
 72    def original(self) -> str:
 73        return self.__original
 74
 75    def _set_initial_stats(self, stats: dict[str, Any]) -> None:
 76        """
 77        Store the document statistics captured before the pipeline modifies the document.
 78        Internal API: not intended for filter implementations.
 79        """
 80        self._initial_stats = stats
 81
 82    def _get_initial_stats(self) -> Optional[dict[str, Any]]:
 83        """
 84        Retrieve the stored initial statistics of the document, if available.
 85        Internal API: not intended for filter implementations.
 86        """
 87        return self._initial_stats
 88
 89    def _clear_initial_stats(self) -> None:
 90        """
 91        Remove the stored initial statistics. Useful after the stats are consumed.
 92        Internal API: not intended for filter implementations.
 93        """
 94        self._initial_stats = None
 95
 96    @deprecated_since("1.0.0")
 97    def set_tokens(self, tokens: List[str]) -> None:
 98        self.tokens = [Token(token) for token in tokens]
 99
100    @deprecated_since("1.0.0")
101    def get_tokens(self) -> List[str]:
102        return [token.text for token in self.tokens]
103
104    def __str__(self) -> str:
105        return self.text
106
107    def __repr__(self) -> str:
108        return (
109            f"Document(text={self.text!r}, is_rejected={self.is_rejected}, extras={self.extras})"  # noqa
110        )

Document class represents a text document with metadata. It contains the text of the document, a flag indicating whether it is rejected, and additional metadata stored in the extras dictionary.

The tokens attribute will be deprecated in future versions, and users are encouraged to use the extras dictionary to store token-related information.

Attributes: text (str): The text content of the document. is_rejected (bool): A flag indicating whether the document is rejected. extras (Dict[str, Any]): A dictionary to store additional metadata about the document. reject_reason (Dict[str, Any]): A dictionary to store the reason for rejection. The filter class and the member name and value will logged at the filter is logged here. initial_stats (Optional[Dict[str, Any]]): Internal copy of the document statistics captured before the pipeline mutates the document.

Next attributes will be deprecated in future versions: tokens (List[Token]): A list of tokens extracted from the document.

Document( text: str, is_rejected: bool = False, tokens: Optional[List[hojichar.Token]] = None, extras: Optional[Dict[str, Any]] = None)
46    def __init__(
47        self,
48        text: str,
49        is_rejected: bool = False,
50        tokens: Optional[List[Token]] = None,
51        extras: Optional[Dict[str, Any]] = None,
52    ) -> None:
53        self.text = text
54        self.__original = text
55        self.is_rejected = is_rejected
56        if tokens is None:
57            self.tokens: List[Token] = []
58        else:
59            self.tokens = tokens
60
61        if extras is None:
62            self.extras: Dict[str, Any] = {}
63        else:
64            self.extras = extras
65
66        self.reject_reason: Dict[str, Any] = {}
67        self._initial_stats: Optional[dict[str, Any]] = None
68        if "__init_stats" in self.extras:
69            self._initial_stats = self.extras.pop("__init_stats")
@deprecated_since('1.0.0')
def set_tokens(self, tokens: List[str]) -> None:
96    @deprecated_since("1.0.0")
97    def set_tokens(self, tokens: List[str]) -> None:
98        self.tokens = [Token(token) for token in tokens]
@deprecated_since('1.0.0')
def get_tokens(self) -> List[str]:
100    @deprecated_since("1.0.0")
101    def get_tokens(self) -> List[str]:
102        return [token.text for token in self.tokens]
@deprecated_since('0.1.0', 'Document')
class Token:
 9@deprecated_since("0.1.0", "Document")
10class Token:
11    def __init__(self, text: str, is_rejected: bool = False) -> None:
12        self.text = text
13        self.__original = text
14        self.is_rejected = is_rejected
15
16    @property
17    def original(self) -> str:
18        return self.__original
19
20    def __str__(self) -> str:
21        return self.text
Token(text: str, is_rejected: bool = False)
11    def __init__(self, text: str, is_rejected: bool = False) -> None:
12        self.text = text
13        self.__original = text
14        self.is_rejected = is_rejected
class Parallel:
100class Parallel:
101    """
102    The Parallel class provides a way to apply a hojichar.Compose filter
103    to an iterator of documents in a parallel manner using a specified
104    number of worker processes. This class should be used as a context
105    manager with a 'with' statement.
106
107    Example:
108
109    doc_iter = (hojichar.Document(d) for d in open("my_text.txt"))
110    with Parallel(my_filter, num_jobs=8) as pfilter:
111        for doc in pfilter.imap_apply(doc_iter):
112            pass  # Process the filtered document as needed.
113    """
114
115    def __init__(
116        self,
117        filter: hojichar.Compose,
118        num_jobs: int | None = None,
119        ignore_errors: bool = False,
120        ordered: bool = False,
121        max_in_flight: int | None = None,
122    ):
123        """
124        Initializes a new instance of the Parallel class.
125
126        Args:
127            filter (hojichar.Compose): A composed filter object that specifies the
128                processing operations to apply to each document in parallel.
129                A copy of the filter is made within a 'with' statement. When the 'with'
130                block terminates,the statistical information obtained through `filter.statistics`
131                or`filter.statistics_obj` is replaced with the total value of the statistical
132                information processed within the 'with' block.
133
134            num_jobs (int | None, optional): The number of worker processes to use.
135                If None, then the number returned by os.cpu_count() is used. Defaults to None.
136            ignore_errors (bool, optional): If set to True, any exceptions thrown during
137                the processing of a document will be caught and logged, but will not
138                stop the processing of further documents. If set to False, the first
139                exception thrown will terminate the entire parallel processing operation.
140                Defaults to False.
141            ordered (bool, optional): If set to True, processed documents are yielded in
142                the same order as the input documents. If set to False, documents are
143                yielded as soon as their processing completes. Defaults to False.
144            max_in_flight (int | None, optional): Strict upper bound on the number of
145                documents drawn from the input iterator whose results have not yet been
146                handed back to the caller. A yielded document keeps its permit until the
147                caller requests the next one, so with `max_in_flight=1` the pool holds a
148                single document end to end (no pipelining). Without a bound, Pool's
149                task-handler thread drains the input as fast as the worker pipe accepts,
150                so a producer that outruns the filters can buffer a large number of
151                documents. If None, no explicit bound is applied. Defaults to None.
152        """
153        if max_in_flight is not None and max_in_flight < 1:
154            raise ValueError("max_in_flight must be at least 1")
155        self.filter = filter
156        self.num_jobs = num_jobs
157        self.ignore_errors = ignore_errors
158        self.ordered = ordered
159        self.max_in_flight = max_in_flight
160
161        self._pool: Pool | None = None
162        self._pid_stats: dict[int, List[Statistics]] | None = None
163        self._gates: list[_InFlightGate] = []
164
165    def __enter__(self) -> Parallel:
166        self._pool = Pool(
167            processes=self.num_jobs,
168            initializer=_init_worker,
169            initargs=(self.filter, self.ignore_errors),
170        )
171        self._pid_stats = dict()
172        self._gates = []
173        return self
174
175    def imap_apply(self, docs: Iterator[hojichar.Document]) -> Iterator[hojichar.Document]:
176        """
177        Takes an iterator of Documents and applies the Compose filter to
178        each Document in a parallel manner. This is a generator method
179        that yields processed Documents.
180
181        Args:
182            docs (Iterator[hojichar.Document]): An iterator of Documents to be processed.
183
184        Raises:
185            RuntimeError: If the Parallel instance is not properly initialized. This
186                generally happens when the method is called outside of a 'with' statement.
187            Exception: If any exceptions are raised within the worker processes.
188
189        Yields:
190            Iterator[hojichar.Document]: An iterator that yields processed Documents.
191        """
192        if self._pool is None or self._pid_stats is None:
193            raise RuntimeError(
194                "Parallel instance not properly initialized. Use within a 'with' statement."
195            )
196        gate: _InFlightGate | None = None
197        if self.max_in_flight is not None:
198            gate = _InFlightGate(self.max_in_flight)
199            self._gates.append(gate)
200            docs = gate.feed(docs)
201        try:
202            results = (
203                self._pool.imap(_worker, docs)
204                if self.ordered
205                else self._pool.imap_unordered(_worker, docs)
206            )
207            for doc, pid, stat, err_msg in results:
208                self._pid_stats[pid] = stat
209                if err_msg is not None:
210                    logger.error(f"Error in worker {pid}: {err_msg}")
211                # The permit is returned only once the caller has taken the
212                # document: releasing before the yield would let the feeder
213                # momentarily draw max_in_flight + 1 documents. On an abnormal
214                # exit (close/throw at the yield point) the gate is stopped
215                # *before* the permit is returned, so a feeder woken by the
216                # release always observes the stop and cannot draw again.
217                try:
218                    yield doc
219                except BaseException:
220                    if gate is not None:
221                        gate.stop()
222                        gate.release()
223                    raise
224                else:
225                    if gate is not None:
226                        gate.release()
227        except Exception:
228            self.__exit__(None, None, None)
229            raise
230        finally:
231            if gate is not None:
232                gate.stop()
233
234    def __exit__(self, exc_type, exc_value, traceback) -> None:  # type: ignore
235        # Feeders blocked on a max_in_flight gate must exit before the pool is
236        # joined, or shutdown would wait on them forever.
237        for gate in self._gates:
238            gate.stop()
239        if self._pool:
240            self._pool.terminate()
241            self._pool.join()
242        if self._pid_stats:
243            total_stats = functools.reduce(
244                lambda x, y: Statistics.add_list_of_stats(x, y), self._pid_stats.values()
245            )
246            self.filter._statistics.update(Statistics.get_filter("Total", total_stats))
247            for stat in total_stats:
248                for filt in self.filter.filters:
249                    if stat.name == filt.name:
250                        filt._statistics.update(stat)
251                        break
252
253    def get_total_statistics(self) -> List[Statistics]:
254        """
255        Returns a statistics object of the total statistical
256        values processed within the Parallel block.
257
258        Returns:
259            StatsContainer: Statistics object
260        """
261        if self._pid_stats:
262            total_stats = functools.reduce(
263                lambda x, y: Statistics.add_list_of_stats(x, y), self._pid_stats.values()
264            )
265            return total_stats
266        else:
267            return []
268
269    def get_total_statistics_map(self) -> List[dict]:
270        return [stat.to_dict() for stat in self.get_total_statistics()]
271
272    @property
273    def statistics_obj(self) -> inspection.StatsContainer:
274        """
275        Returns the statistics object of the Parallel instance.
276        This is a StatsContainer object which contains the statistics
277        of the Parallel instance and sub filters.
278
279        Returns:
280            StatsContainer: Statistics object
281        """
282        return inspection.statistics_obj_adapter(self.get_total_statistics())  # type: ignore

The Parallel class provides a way to apply a hojichar.Compose filter to an iterator of documents in a parallel manner using a specified number of worker processes. This class should be used as a context manager with a 'with' statement.

Example:

doc_iter = (hojichar.Document(d) for d in open("my_text.txt")) with Parallel(my_filter, num_jobs=8) as pfilter: for doc in pfilter.imap_apply(doc_iter): pass # Process the filtered document as needed.

Parallel( filter: hojichar.Compose, num_jobs: int | None = None, ignore_errors: bool = False, ordered: bool = False, max_in_flight: int | None = None)
115    def __init__(
116        self,
117        filter: hojichar.Compose,
118        num_jobs: int | None = None,
119        ignore_errors: bool = False,
120        ordered: bool = False,
121        max_in_flight: int | None = None,
122    ):
123        """
124        Initializes a new instance of the Parallel class.
125
126        Args:
127            filter (hojichar.Compose): A composed filter object that specifies the
128                processing operations to apply to each document in parallel.
129                A copy of the filter is made within a 'with' statement. When the 'with'
130                block terminates,the statistical information obtained through `filter.statistics`
131                or`filter.statistics_obj` is replaced with the total value of the statistical
132                information processed within the 'with' block.
133
134            num_jobs (int | None, optional): The number of worker processes to use.
135                If None, then the number returned by os.cpu_count() is used. Defaults to None.
136            ignore_errors (bool, optional): If set to True, any exceptions thrown during
137                the processing of a document will be caught and logged, but will not
138                stop the processing of further documents. If set to False, the first
139                exception thrown will terminate the entire parallel processing operation.
140                Defaults to False.
141            ordered (bool, optional): If set to True, processed documents are yielded in
142                the same order as the input documents. If set to False, documents are
143                yielded as soon as their processing completes. Defaults to False.
144            max_in_flight (int | None, optional): Strict upper bound on the number of
145                documents drawn from the input iterator whose results have not yet been
146                handed back to the caller. A yielded document keeps its permit until the
147                caller requests the next one, so with `max_in_flight=1` the pool holds a
148                single document end to end (no pipelining). Without a bound, Pool's
149                task-handler thread drains the input as fast as the worker pipe accepts,
150                so a producer that outruns the filters can buffer a large number of
151                documents. If None, no explicit bound is applied. Defaults to None.
152        """
153        if max_in_flight is not None and max_in_flight < 1:
154            raise ValueError("max_in_flight must be at least 1")
155        self.filter = filter
156        self.num_jobs = num_jobs
157        self.ignore_errors = ignore_errors
158        self.ordered = ordered
159        self.max_in_flight = max_in_flight
160
161        self._pool: Pool | None = None
162        self._pid_stats: dict[int, List[Statistics]] | None = None
163        self._gates: list[_InFlightGate] = []

Initializes a new instance of the Parallel class.

Args: filter (hojichar.Compose): A composed filter object that specifies the processing operations to apply to each document in parallel. A copy of the filter is made within a 'with' statement. When the 'with' block terminates,the statistical information obtained through filter.statistics orfilter.statistics_obj is replaced with the total value of the statistical information processed within the 'with' block.

num_jobs (int | None, optional): The number of worker processes to use.
    If None, then the number returned by os.cpu_count() is used. Defaults to None.
ignore_errors (bool, optional): If set to True, any exceptions thrown during
    the processing of a document will be caught and logged, but will not
    stop the processing of further documents. If set to False, the first
    exception thrown will terminate the entire parallel processing operation.
    Defaults to False.
ordered (bool, optional): If set to True, processed documents are yielded in
    the same order as the input documents. If set to False, documents are
    yielded as soon as their processing completes. Defaults to False.
max_in_flight (int | None, optional): Strict upper bound on the number of
    documents drawn from the input iterator whose results have not yet been
    handed back to the caller. A yielded document keeps its permit until the
    caller requests the next one, so with `max_in_flight=1` the pool holds a
    single document end to end (no pipelining). Without a bound, Pool's
    task-handler thread drains the input as fast as the worker pipe accepts,
    so a producer that outruns the filters can buffer a large number of
    documents. If None, no explicit bound is applied. Defaults to None.
def imap_apply( self, docs: Iterator[hojichar.Document]) -> Iterator[hojichar.Document]:
175    def imap_apply(self, docs: Iterator[hojichar.Document]) -> Iterator[hojichar.Document]:
176        """
177        Takes an iterator of Documents and applies the Compose filter to
178        each Document in a parallel manner. This is a generator method
179        that yields processed Documents.
180
181        Args:
182            docs (Iterator[hojichar.Document]): An iterator of Documents to be processed.
183
184        Raises:
185            RuntimeError: If the Parallel instance is not properly initialized. This
186                generally happens when the method is called outside of a 'with' statement.
187            Exception: If any exceptions are raised within the worker processes.
188
189        Yields:
190            Iterator[hojichar.Document]: An iterator that yields processed Documents.
191        """
192        if self._pool is None or self._pid_stats is None:
193            raise RuntimeError(
194                "Parallel instance not properly initialized. Use within a 'with' statement."
195            )
196        gate: _InFlightGate | None = None
197        if self.max_in_flight is not None:
198            gate = _InFlightGate(self.max_in_flight)
199            self._gates.append(gate)
200            docs = gate.feed(docs)
201        try:
202            results = (
203                self._pool.imap(_worker, docs)
204                if self.ordered
205                else self._pool.imap_unordered(_worker, docs)
206            )
207            for doc, pid, stat, err_msg in results:
208                self._pid_stats[pid] = stat
209                if err_msg is not None:
210                    logger.error(f"Error in worker {pid}: {err_msg}")
211                # The permit is returned only once the caller has taken the
212                # document: releasing before the yield would let the feeder
213                # momentarily draw max_in_flight + 1 documents. On an abnormal
214                # exit (close/throw at the yield point) the gate is stopped
215                # *before* the permit is returned, so a feeder woken by the
216                # release always observes the stop and cannot draw again.
217                try:
218                    yield doc
219                except BaseException:
220                    if gate is not None:
221                        gate.stop()
222                        gate.release()
223                    raise
224                else:
225                    if gate is not None:
226                        gate.release()
227        except Exception:
228            self.__exit__(None, None, None)
229            raise
230        finally:
231            if gate is not None:
232                gate.stop()

Takes an iterator of Documents and applies the Compose filter to each Document in a parallel manner. This is a generator method that yields processed Documents.

Args: docs (Iterator[hojichar.Document]): An iterator of Documents to be processed.

Raises: RuntimeError: If the Parallel instance is not properly initialized. This generally happens when the method is called outside of a 'with' statement. Exception: If any exceptions are raised within the worker processes.

Yields: Iterator[hojichar.Document]: An iterator that yields processed Documents.

def get_total_statistics(self) -> List[hojichar.core.models.Statistics]:
253    def get_total_statistics(self) -> List[Statistics]:
254        """
255        Returns a statistics object of the total statistical
256        values processed within the Parallel block.
257
258        Returns:
259            StatsContainer: Statistics object
260        """
261        if self._pid_stats:
262            total_stats = functools.reduce(
263                lambda x, y: Statistics.add_list_of_stats(x, y), self._pid_stats.values()
264            )
265            return total_stats
266        else:
267            return []

Returns a statistics object of the total statistical values processed within the Parallel block.

Returns: StatsContainer: Statistics object

def get_total_statistics_map(self) -> List[dict]:
269    def get_total_statistics_map(self) -> List[dict]:
270        return [stat.to_dict() for stat in self.get_total_statistics()]
statistics_obj: hojichar.StatsContainer

Returns the statistics object of the Parallel instance. This is a StatsContainer object which contains the statistics of the Parallel instance and sub filters.

Returns: StatsContainer: Statistics object

@dataclasses.dataclass
class StatsContainer:
116@dataclasses.dataclass
117class StatsContainer:
118    total_info: DocStatistics
119    layers_info: Dict[str, FilterStatistics]  # Key of the dict is filter name.
120
121    def __add__(self, other: StatsContainer) -> StatsContainer:
122        assert self.layers_info.keys() == other.layers_info.keys(), "Layer names must match"
123        return StatsContainer(
124            self.total_info + other.total_info,
125            {k: v + other.layers_info[k] for k, v in self.layers_info.items()},
126        )
127
128    def get_human_readable_values(self) -> dict:
129        return {
130            "total_info": self.total_info.get_human_readable_values(),
131            "layers_info": [
132                layer.get_human_readable_values() for layer in self.layers_info.values()
133            ],
134        }
135
136    def reset(self) -> StatsContainer:
137        self.total_info.reset
138        for layer in self.layers_info.values():
139            layer.reset()
140        return self
StatsContainer( total_info: hojichar.core.inspection.DocStatistics, layers_info: Dict[str, hojichar.core.inspection.FilterStatistics])
def get_human_readable_values(self) -> dict:
128    def get_human_readable_values(self) -> dict:
129        return {
130            "total_info": self.total_info.get_human_readable_values(),
131            "layers_info": [
132                layer.get_human_readable_values() for layer in self.layers_info.values()
133            ],
134        }
def reset(self) -> hojichar.StatsContainer:
136    def reset(self) -> StatsContainer:
137        self.total_info.reset
138        for layer in self.layers_info.values():
139            layer.reset()
140        return self
class AsyncCompose(hojichar.AsyncFilter):
 77class AsyncCompose(AsyncFilter):
 78    def __init__(
 79        self,
 80        filters: list[AsyncFilter | Filter],
 81        random_state: int | np.random.Generator | None = None,
 82        executor: ThreadPoolExecutor | None = None,
 83        *args: Any,
 84        **kwargs: Any,
 85    ):
 86        super().__init__(random_state=random_state, *args, **kwargs)
 87        self.logger = logging.getLogger(f"{self.__module__}.{self.__class__.__name__}")
 88        self._statistics.name = "Total"
 89        self._has_external_executor = executor is not None
 90        self._executor = executor or ThreadPoolExecutor()
 91        self.set_filters(filters)
 92
 93    def set_filters(self, filters: list[AsyncFilter | Filter]) -> None:
 94        self.filters: list[AsyncFilter] = []
 95        filter_idx = 0
 96        for f in filters:
 97            if isinstance(f, (AsyncCompose, Compose)):
 98                for sub in f.filters:
 99                    name = f"{filter_idx}-{sub.__class__.__name__}"
100                    if isinstance(sub, Filter):
101                        name = f"{filter_idx}-{sub.__class__.__name__}"
102                        sub = AsyncFilterAdapter(sub, executor=self._executor)
103
104                    sub._set_rng_if_not_initialized(self._rng)
105                    sub.name = name
106                    sub._statistics.name = name
107                    self.filters.append(sub)
108                    filter_idx += 1
109            else:
110                name = f"{filter_idx}-{f.__class__.__name__}"
111                if isinstance(f, Filter):
112                    name = f"{filter_idx}-{f.__class__.__name__}"
113                    f = AsyncFilterAdapter(f, executor=self._executor)
114                f._set_rng_if_not_initialized(self._rng)
115                f.name = name
116                f._statistics.name = name
117                self.filters.append(f)
118                filter_idx += 1
119
120    async def apply(self, document: Document) -> Document:
121        stat = get_doc_info(document)
122        for filter_idx, filt in enumerate(self.filters):
123            document = await filt._apply(document)
124        new_stat = get_doc_info(document)
125        async with self._stats_lock:
126            self._statistics.update_by_diff(stat, new_stat)
127        return document
128
129    async def apply_batch(self, batch: Sequence[Document]) -> list[Document]:
130        stats = [get_doc_info(doc) for doc in batch]
131        for i, filt in enumerate(self.filters):
132            batch = await filt._apply_batch(batch)
133        batch = await self._finalize_batch(batch, stats)
134        return list(batch)
135
136    async def apply_stream(
137        self,
138        stream: AsyncIterable[Document] | Iterable[Document],
139    ) -> AsyncGenerator[Document, None]:
140        async_stream = handle_stream_as_async(stream, chunk_size=1000, executor=self._executor)
141        async_stream = self._count_input_stats(async_stream)
142
143        for i, filt in enumerate(self.filters):
144            async_stream = filt.apply_stream(async_stream)
145
146        async for doc in async_stream:
147            in_stat = doc._get_initial_stats()
148            if in_stat is None:
149                in_stat = get_doc_info(doc)
150                self.logger.debug(
151                    "Initial stats missing for document during async stream aggregation; "
152                    "using current stats as fallback"
153                )
154            out_stat = get_doc_info(doc)
155            async with self._stats_lock:
156                self._statistics.update_by_diff(in_stat, out_stat)
157            doc._clear_initial_stats()
158            yield doc
159
160    def imap_apply(
161        self,
162        stream: AsyncIterable[Document] | Iterable[Document],
163        *,
164        buffer_size: int = 128,
165        shutdown: bool = True,
166    ) -> AsyncToSyncIterator[Document]:
167        """Synchronously consume this asynchronous pipeline.
168
169        The returned iterator runs the pipeline on a dedicated background event loop. It is
170        intended for one-shot use and shuts down the pipeline after consumption by default.
171        Use the iterator as a context manager if iteration may stop early.
172        """
173
174        finalizer = self.shutdown if shutdown else None
175        return handle_async_stream_as_sync(
176            self.apply_stream(stream),
177            buffer_size=buffer_size,
178            finalizer=finalizer,
179        )
180
181    async def _count_input_stats(
182        self, async_stream: AsyncIterable[Document]
183    ) -> AsyncGenerator[Document, None]:
184        async for doc in async_stream:
185            doc._set_initial_stats(get_doc_info(doc))
186            yield doc
187
188    def get_total_statistics(self) -> list[Statistics]:
189        """
190        Get the statistics of the Compose object and sub filters.
191
192        The statistics of the Compose class are stored in an object with the name "Total",
193        and sub-filters's are stored with names in the format {filter_index}-{filter class name}.
194        """
195        stats = []
196        stats.append(self.get_statistics())
197        for i, filt in enumerate(self.filters):
198            stats.append(filt.get_statistics())
199        return stats
200
201    def get_total_statistics_map(self) -> list[dict[str, Any]]:
202        """
203        Get the statistics of the Compose object and sub filters as a list of dictionaries.
204        """
205        stats = self.get_total_statistics()
206        return [stat.to_dict() for stat in stats]
207
208    @property
209    def statistics(self) -> dict:
210        """
211        Deprecated
212
213        Get the statistics of the Compose object and sub filters.
214
215        This property is retained for compatibility with previous versions.
216        Please use `get_total_statistics` or `get_total_statistics_map` instead.
217        """
218        return inspection.statistics_obj_adapter(  # type: ignore
219            self.get_total_statistics()
220        ).get_human_readable_values()
221
222    @property
223    def statistics_obj(self) -> inspection.StatsContainer:
224        """
225        Deprecated
226
227        Get the statistics of the AsyncCompose object and sub filters.
228        This method returns a StatsContainer object which contains the statistics
229        of the AsyncCompose object and sub filters.
230
231        This property is retained for compatibility with previous versions.
232        Please use `get_total_statistics` or `get_total_statistics_map` instead.
233        """
234        return inspection.statistics_obj_adapter(self.get_total_statistics())  # type: ignore
235
236    async def shutdown(self) -> None:
237        for filt in self.filters:
238            await filt.shutdown()
239        if not self._has_external_executor:
240            self._executor.shutdown()
241
242    async def __aenter__(self) -> "AsyncCompose":
243        return self
244
245    async def __aexit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None:
246        await self.shutdown()
247        if exc_type is not None:
248            raise exc_value

Helper class that provides a standard way to create an ABC using inheritance.

AsyncCompose( filters: list[hojichar.AsyncFilter | hojichar.Filter], random_state: int | numpy.random._generator.Generator | None = None, executor: concurrent.futures.thread.ThreadPoolExecutor | None = None, *args: Any, **kwargs: Any)
78    def __init__(
79        self,
80        filters: list[AsyncFilter | Filter],
81        random_state: int | np.random.Generator | None = None,
82        executor: ThreadPoolExecutor | None = None,
83        *args: Any,
84        **kwargs: Any,
85    ):
86        super().__init__(random_state=random_state, *args, **kwargs)
87        self.logger = logging.getLogger(f"{self.__module__}.{self.__class__.__name__}")
88        self._statistics.name = "Total"
89        self._has_external_executor = executor is not None
90        self._executor = executor or ThreadPoolExecutor()
91        self.set_filters(filters)

Base class for asynchronous filters.

Parameters

p : float The probability of applying the filter. If p is 1, the filter will always be applied. skip_rejected : bool If True, the filter will skip documents that are already rejected. If you want to apply the filter to all documents (e.g., postprocess), set this to False. random_state : Optional[Union[int, np.random.Generator]] Seed for the random number generator. If None is specified, the random number generator managed by the Compose class will be used. use_batch : bool If True, the filter will process documents in batches in the apply_stream method. batch_size : int The size of the batch to process documents in the apply_stream method. When apply_batch is not overridden, this is also the maximum number of in-flight document tasks used by the sliding-window scheduler. Ordered processing applies backpressure after at most two windows of started but not yet yielded documents. ordered : bool If True, apply_stream yields documents in input order. If False, filters using the default apply_batch implementation yield documents as soon as their processing completes.

def set_filters( self, filters: list[hojichar.AsyncFilter | hojichar.Filter]) -> None:
 93    def set_filters(self, filters: list[AsyncFilter | Filter]) -> None:
 94        self.filters: list[AsyncFilter] = []
 95        filter_idx = 0
 96        for f in filters:
 97            if isinstance(f, (AsyncCompose, Compose)):
 98                for sub in f.filters:
 99                    name = f"{filter_idx}-{sub.__class__.__name__}"
100                    if isinstance(sub, Filter):
101                        name = f"{filter_idx}-{sub.__class__.__name__}"
102                        sub = AsyncFilterAdapter(sub, executor=self._executor)
103
104                    sub._set_rng_if_not_initialized(self._rng)
105                    sub.name = name
106                    sub._statistics.name = name
107                    self.filters.append(sub)
108                    filter_idx += 1
109            else:
110                name = f"{filter_idx}-{f.__class__.__name__}"
111                if isinstance(f, Filter):
112                    name = f"{filter_idx}-{f.__class__.__name__}"
113                    f = AsyncFilterAdapter(f, executor=self._executor)
114                f._set_rng_if_not_initialized(self._rng)
115                f.name = name
116                f._statistics.name = name
117                self.filters.append(f)
118                filter_idx += 1
async def apply( self, document: hojichar.Document) -> hojichar.Document:
120    async def apply(self, document: Document) -> Document:
121        stat = get_doc_info(document)
122        for filter_idx, filt in enumerate(self.filters):
123            document = await filt._apply(document)
124        new_stat = get_doc_info(document)
125        async with self._stats_lock:
126            self._statistics.update_by_diff(stat, new_stat)
127        return document

Definition of async filter behavior.

In this method, the filter will modify document.text or document.extras and set document.is_rejected = True to discard the document.

Parameters

document : Document Input document

Returns

Document Processed Document

async def apply_batch( self, batch: Sequence[hojichar.Document]) -> list[hojichar.Document]:
129    async def apply_batch(self, batch: Sequence[Document]) -> list[Document]:
130        stats = [get_doc_info(doc) for doc in batch]
131        for i, filt in enumerate(self.filters):
132            batch = await filt._apply_batch(batch)
133        batch = await self._finalize_batch(batch, stats)
134        return list(batch)

Apply the filter to a Sequence of documents. By default, the processing implemented in apply is executed asynchronously and concurrently. If the filter processing can be optimized for batch processing, override this method.

async def apply_stream( self, stream: Union[AsyncIterable[hojichar.Document], Iterable[hojichar.Document]]) -> AsyncGenerator[hojichar.Document, NoneType]:
136    async def apply_stream(
137        self,
138        stream: AsyncIterable[Document] | Iterable[Document],
139    ) -> AsyncGenerator[Document, None]:
140        async_stream = handle_stream_as_async(stream, chunk_size=1000, executor=self._executor)
141        async_stream = self._count_input_stats(async_stream)
142
143        for i, filt in enumerate(self.filters):
144            async_stream = filt.apply_stream(async_stream)
145
146        async for doc in async_stream:
147            in_stat = doc._get_initial_stats()
148            if in_stat is None:
149                in_stat = get_doc_info(doc)
150                self.logger.debug(
151                    "Initial stats missing for document during async stream aggregation; "
152                    "using current stats as fallback"
153                )
154            out_stat = get_doc_info(doc)
155            async with self._stats_lock:
156                self._statistics.update_by_diff(in_stat, out_stat)
157            doc._clear_initial_stats()
158            yield doc

Apply the filter to a stream of documents (Iterable or AsyncIterable). If use_batch is set to True at initialization, the filter will process documents in batches. If the stream is not asynchronous, use handle_stream_as_async to convert it to an asynchronous stream.

Even if an exception occurs during processing, the process will continue, and the following actions will be taken:

  • Set the is_rejected flag of the document to True
  • Set the error details in reject_reason
  • Increment the errors count in the statistics retrievable via get_statistics
def imap_apply( self, stream: Union[AsyncIterable[hojichar.Document], Iterable[hojichar.Document]], *, buffer_size: int = 128, shutdown: bool = True) -> hojichar.utils.async_handlers.AsyncToSyncIterator[hojichar.Document]:
160    def imap_apply(
161        self,
162        stream: AsyncIterable[Document] | Iterable[Document],
163        *,
164        buffer_size: int = 128,
165        shutdown: bool = True,
166    ) -> AsyncToSyncIterator[Document]:
167        """Synchronously consume this asynchronous pipeline.
168
169        The returned iterator runs the pipeline on a dedicated background event loop. It is
170        intended for one-shot use and shuts down the pipeline after consumption by default.
171        Use the iterator as a context manager if iteration may stop early.
172        """
173
174        finalizer = self.shutdown if shutdown else None
175        return handle_async_stream_as_sync(
176            self.apply_stream(stream),
177            buffer_size=buffer_size,
178            finalizer=finalizer,
179        )

Synchronously consume this asynchronous pipeline.

The returned iterator runs the pipeline on a dedicated background event loop. It is intended for one-shot use and shuts down the pipeline after consumption by default. Use the iterator as a context manager if iteration may stop early.

def get_total_statistics(self) -> list[hojichar.core.models.Statistics]:
188    def get_total_statistics(self) -> list[Statistics]:
189        """
190        Get the statistics of the Compose object and sub filters.
191
192        The statistics of the Compose class are stored in an object with the name "Total",
193        and sub-filters's are stored with names in the format {filter_index}-{filter class name}.
194        """
195        stats = []
196        stats.append(self.get_statistics())
197        for i, filt in enumerate(self.filters):
198            stats.append(filt.get_statistics())
199        return stats

Get the statistics of the Compose object and sub filters.

The statistics of the Compose class are stored in an object with the name "Total", and sub-filters's are stored with names in the format {filter_index}-{filter class name}.

def get_total_statistics_map(self) -> list[dict[str, typing.Any]]:
201    def get_total_statistics_map(self) -> list[dict[str, Any]]:
202        """
203        Get the statistics of the Compose object and sub filters as a list of dictionaries.
204        """
205        stats = self.get_total_statistics()
206        return [stat.to_dict() for stat in stats]

Get the statistics of the Compose object and sub filters as a list of dictionaries.

statistics: dict

Deprecated

Get the statistics of the Compose object and sub filters.

This property is retained for compatibility with previous versions. Please use get_total_statistics or get_total_statistics_map instead.

statistics_obj: hojichar.StatsContainer

Deprecated

Get the statistics of the AsyncCompose object and sub filters. This method returns a StatsContainer object which contains the statistics of the AsyncCompose object and sub filters.

This property is retained for compatibility with previous versions. Please use get_total_statistics or get_total_statistics_map instead.

async def shutdown(self) -> None:
236    async def shutdown(self) -> None:
237        for filt in self.filters:
238            await filt.shutdown()
239        if not self._has_external_executor:
240            self._executor.shutdown()

You can override this method to release resources or perform cleanup tasks.

class AsyncFilterAdapter(hojichar.AsyncFilter):
23class AsyncFilterAdapter(AsyncFilter):
24    """
25    Adapter class for executing hojichar.Filter asynchronously.
26    """
27
28    def __init__(
29        self,
30        sync_filter: Filter,
31        *args: Any,
32        executor: Executor | None = None,
33        use_batch: bool = True,
34        **kwargs: Any,
35    ):
36        """
37        Adapter class for executing hojichar.Filter asynchronously.
38        Used to incorporate Filter into AsyncCompose.
39
40        To reduce the overhead of asynchronous context switching,
41        use_batch is set to True by default to process in batches
42        in apply_stream, regardless of the sync_filter's use_batch setting.
43
44        If performing CPU-bound and heavy processing, you can specify an executor
45        to offload the processing to the executor. However, due to Python's GIL
46        constraints, using ThreadPoolExecutor will not parallelize CPU-bound
47        processing, and the entire process will be locked.
48
49        By using ProcessPoolExecutor as the executor, it may be possible to
50        parallelize CPU-bound processing. However, for parallelizing CPU-bound
51        processing, it is recommended to use the hojichar.Parallel class to
52        parallelize synchronous Compose pipeline.
53        """
54        super().__init__(*args, use_batch=use_batch, **kwargs)
55        self.sync_filter = sync_filter
56        self._has_external_executor = executor is not None
57        self._executor = executor or ThreadPoolExecutor()
58        self.batch_size = sync_filter.batch_size
59
60    async def apply(self, document: Document) -> Document:
61        loop = asyncio.get_running_loop()
62        return await loop.run_in_executor(self._executor, self.sync_filter.apply, document)
63
64    async def apply_batch(self, batch: Sequence[Document]) -> list[Document]:
65        loop = asyncio.get_running_loop()
66        return await loop.run_in_executor(
67            self._executor,
68            lambda: self.sync_filter.apply_batch(batch),
69        )
70
71    async def shutdown(self) -> None:
72        self.sync_filter.shutdown()
73        if not self._has_external_executor:
74            self._executor.shutdown()

Adapter class for executing hojichar.Filter asynchronously.

AsyncFilterAdapter( sync_filter: hojichar.Filter, *args: Any, executor: concurrent.futures._base.Executor | None = None, use_batch: bool = True, **kwargs: Any)
28    def __init__(
29        self,
30        sync_filter: Filter,
31        *args: Any,
32        executor: Executor | None = None,
33        use_batch: bool = True,
34        **kwargs: Any,
35    ):
36        """
37        Adapter class for executing hojichar.Filter asynchronously.
38        Used to incorporate Filter into AsyncCompose.
39
40        To reduce the overhead of asynchronous context switching,
41        use_batch is set to True by default to process in batches
42        in apply_stream, regardless of the sync_filter's use_batch setting.
43
44        If performing CPU-bound and heavy processing, you can specify an executor
45        to offload the processing to the executor. However, due to Python's GIL
46        constraints, using ThreadPoolExecutor will not parallelize CPU-bound
47        processing, and the entire process will be locked.
48
49        By using ProcessPoolExecutor as the executor, it may be possible to
50        parallelize CPU-bound processing. However, for parallelizing CPU-bound
51        processing, it is recommended to use the hojichar.Parallel class to
52        parallelize synchronous Compose pipeline.
53        """
54        super().__init__(*args, use_batch=use_batch, **kwargs)
55        self.sync_filter = sync_filter
56        self._has_external_executor = executor is not None
57        self._executor = executor or ThreadPoolExecutor()
58        self.batch_size = sync_filter.batch_size

Adapter class for executing hojichar.Filter asynchronously. Used to incorporate Filter into AsyncCompose.

To reduce the overhead of asynchronous context switching, use_batch is set to True by default to process in batches in apply_stream, regardless of the sync_filter's use_batch setting.

If performing CPU-bound and heavy processing, you can specify an executor to offload the processing to the executor. However, due to Python's GIL constraints, using ThreadPoolExecutor will not parallelize CPU-bound processing, and the entire process will be locked.

By using ProcessPoolExecutor as the executor, it may be possible to parallelize CPU-bound processing. However, for parallelizing CPU-bound processing, it is recommended to use the hojichar.Parallel class to parallelize synchronous Compose pipeline.

async def apply( self, document: hojichar.Document) -> hojichar.Document:
60    async def apply(self, document: Document) -> Document:
61        loop = asyncio.get_running_loop()
62        return await loop.run_in_executor(self._executor, self.sync_filter.apply, document)

Definition of async filter behavior.

In this method, the filter will modify document.text or document.extras and set document.is_rejected = True to discard the document.

Parameters

document : Document Input document

Returns

Document Processed Document

async def apply_batch( self, batch: Sequence[hojichar.Document]) -> list[hojichar.Document]:
64    async def apply_batch(self, batch: Sequence[Document]) -> list[Document]:
65        loop = asyncio.get_running_loop()
66        return await loop.run_in_executor(
67            self._executor,
68            lambda: self.sync_filter.apply_batch(batch),
69        )

Apply the filter to a Sequence of documents. By default, the processing implemented in apply is executed asynchronously and concurrently. If the filter processing can be optimized for batch processing, override this method.

async def shutdown(self) -> None:
71    async def shutdown(self) -> None:
72        self.sync_filter.shutdown()
73        if not self._has_external_executor:
74            self._executor.shutdown()

You can override this method to release resources or perform cleanup tasks.

class AsyncFilter(abc.ABC):
 34class AsyncFilter(ABC):
 35    def __init__(
 36        self,
 37        *args: Any,
 38        p: float = 1.0,
 39        skip_rejected: bool = True,
 40        random_state: int | np.random.Generator | None = None,
 41        use_batch: bool = True,
 42        batch_size: int = 128,
 43        ordered: bool = True,
 44        **kwargs: Any,
 45    ):
 46        """
 47        Base class for asynchronous filters.
 48
 49        Parameters
 50        ----------
 51        p : float
 52            The probability of applying the filter.
 53            If `p` is 1, the filter will always be applied.
 54        skip_rejected : bool
 55            If `True`, the filter will skip documents that are already rejected.
 56            If you want to apply the filter to all documents (e.g., postprocess), set this to `False`.
 57        random_state : Optional[Union[int, np.random.Generator]]
 58            Seed for the random number generator.
 59            If `None` is specified, the random number generator managed by the Compose class will be used.
 60        use_batch : bool
 61            If `True`, the filter will process documents in batches in the `apply_stream` method.
 62        batch_size : int
 63            The size of the batch to process documents in the `apply_stream` method.
 64            When `apply_batch` is not overridden, this is also the maximum number of
 65            in-flight document tasks used by the sliding-window scheduler. Ordered
 66            processing applies backpressure after at most two windows of started but
 67            not yet yielded documents.
 68        ordered : bool
 69            If `True`, `apply_stream` yields documents in input order. If `False`, filters
 70            using the default `apply_batch` implementation yield documents as soon as their
 71            processing completes.
 72        """
 73        if batch_size < 1:
 74            raise ValueError("batch_size must be at least 1")
 75
 76        self.name = self.__class__.__name__
 77        self.logger = logging.getLogger(f"{self.__module__}.{self.__class__.__name__}")
 78        assert 0 <= p <= 1
 79        self.p = p
 80        self.__init_rng(random_state)
 81        self.skip_rejected = skip_rejected
 82        self.use_batch = use_batch
 83        self.batch_size = batch_size
 84        self.ordered = ordered
 85
 86        self._statistics = Statistics(name=self.name)
 87        self._stats_lock = asyncio.Lock()
 88
 89    @abstractmethod
 90    async def apply(self, document: Document) -> Document:
 91        """
 92        Definition of async filter behavior.
 93
 94        In this method, the filter will modify `document.text` or
 95        `document.extras` and set `document.is_rejected = True` to discard the document.
 96
 97        Parameters
 98        ----------
 99        document : Document
100            Input document
101
102        Returns
103        -------
104        Document
105            Processed Document
106        """
107        pass
108
109    def _check_skip(self, document: Document) -> bool:
110        """
111        Check if the document should be skipped by this filter.
112        If `skip_rejected` is set to `True`, this method will return `True`
113        if the document is already rejected.
114        If `p` is less than 1, this method will return `True` with a probability of `1 - p`.
115        """
116        skip = self.skip_rejected and document.is_rejected
117        if skip:
118            return True
119        if self.p < 1:
120            if self._rng.random() > self.p:
121                return True
122        return False
123
124    async def _apply(self, document: Document) -> Document:
125        stats = get_doc_info(document)
126        if not self._check_skip(document):
127            document = await self.apply(document)
128        new_stats = get_doc_info(document)
129        async with self._stats_lock:
130            self._statistics.update_by_diff(stats, new_stats)
131
132        if not stats["is_rejected"] and new_stats["is_rejected"]:
133            document.reject_reason = self.get_jsonable_vars()
134        return document
135
136    async def apply_batch(self, batch: Sequence[Document]) -> list[Document]:
137        """
138        Apply the filter to a Sequence of documents.
139        By default, the processing implemented in `apply` is executed asynchronously and concurrently.
140        If the filter processing can be optimized for batch processing, override this method.
141        """
142        tasks = [self.apply(doc) for doc in batch]
143        return await asyncio.gather(*tasks)
144
145    async def _apply_batch(self, batch: Sequence[Document]) -> list[Document]:
146        skip = False
147        if self.p < 1:
148            skip = self._rng.random() > self.p
149
150        stats = [get_doc_info(doc) for doc in batch]
151        if not skip:
152            batch = await self.apply_batch(batch)
153        batch = await self._finalize_batch(batch, stats)
154        return list(batch)
155
156    async def apply_stream(
157        self,
158        stream: Iterable[Document] | AsyncIterable[Document],
159    ) -> AsyncGenerator[Document, None]:
160        """
161        Apply the filter to a stream of documents (Iterable or AsyncIterable).
162        If use_batch is set to `True` at initialization, the filter will process documents in batches.
163        If the stream is not asynchronous, use handle_stream_as_async to convert it to an asynchronous stream.
164
165        Even if an exception occurs during processing, the process will continue, and the following actions will be taken:
166        - Set the `is_rejected` flag of the document to `True`
167        - Set the error details in `reject_reason`
168        - Increment the `errors` count in the statistics retrievable via `get_statistics`
169        """
170        async_stream: AsyncIterable[Document] = handle_stream_as_async(stream)
171
172        if not self.use_batch:
173            async for doc in async_stream:
174                yield await self._try_process(doc, self._apply)
175        elif type(self).apply_batch is AsyncFilter.apply_batch:
176            async for doc in self._apply_stream_concurrently(async_stream):
177                yield doc
178        else:
179            batch: list[Document] = []
180            async for doc in async_stream:
181                if self._check_skip(doc):
182                    yield doc
183                    continue
184
185                batch.append(doc)
186                # Batch size reached, apply batch
187                if len(batch) >= self.batch_size:
188                    stats = [get_doc_info(doc) for doc in batch]
189                    batch = await self._try_process(batch, self.apply_batch)
190                    batch = await self._finalize_batch(batch, stats)
191                    for out in batch:
192                        yield out
193                    batch.clear()
194
195            # Flush remaining documents in the batch
196            if batch:
197                stats = [get_doc_info(doc) for doc in batch]
198                batch = await self._try_process(batch, self.apply_batch)
199                batch = await self._finalize_batch(batch, stats)
200                for out in batch:
201                    yield out
202
203    async def _apply_stream_concurrently(
204        self,
205        stream: AsyncIterable[Document],
206    ) -> AsyncGenerator[Document, None]:
207        """Apply documents with a bounded sliding window.
208
209        This path is used when a filter relies on the default per-document `apply_batch`
210        implementation. Filters that override `apply_batch` retain true batch processing.
211        """
212        async_iterator = stream.__aiter__()
213
214        async def next_document() -> Document:
215            return await async_iterator.__anext__()
216
217        pending: dict[asyncio.Task[Document], int] = {}
218        completed_results: dict[int, Document] = {}
219        next_input_index = 0
220        next_output_index = 0
221        source_task: asyncio.Task[Document] | None = None
222        source_exhausted = False
223
224        # Keep one window of completed out-of-order results in addition to the active
225        # window. This absorbs ordinary latency variation without allowing a stalled
226        # early document to make the scheduler consume an unbounded input stream.
227        ordered_backlog_limit = self.batch_size * 2
228
229        def schedule_next_document_if_possible() -> None:
230            nonlocal source_task
231
232            if source_exhausted or source_task is not None:
233                return
234            if len(pending) >= self.batch_size:
235                return
236            if (
237                self.ordered
238                and len(pending) + len(completed_results) >= ordered_backlog_limit
239            ):
240                return
241            source_task = asyncio.create_task(next_document())
242
243        schedule_next_document_if_possible()
244        try:
245            while source_task is not None or pending:
246                wait_for = set(pending)
247                if source_task is not None:
248                    wait_for.add(source_task)
249                done, _ = await asyncio.wait(
250                    wait_for,
251                    return_when=asyncio.FIRST_COMPLETED,
252                )
253
254                if source_task is not None and source_task in done:
255                    completed_source_task = source_task
256                    source_task = None
257                    try:
258                        document = completed_source_task.result()
259                    except StopAsyncIteration:
260                        source_exhausted = True
261                    else:
262                        skip = self._check_skip(document)
263                        processing_task = asyncio.create_task(
264                            self._process_stream_document(document, skip=skip)
265                        )
266                        pending[processing_task] = next_input_index
267                        next_input_index += 1
268
269                completed_tasks = done.intersection(pending)
270                newly_completed: list[Document] = []
271                for task in completed_tasks:
272                    input_index = pending.pop(task)
273                    result = task.result()
274                    if self.ordered:
275                        completed_results[input_index] = result
276                    else:
277                        newly_completed.append(result)
278
279                schedule_next_document_if_possible()
280
281                if self.ordered:
282                    while next_output_index in completed_results:
283                        yield completed_results.pop(next_output_index)
284                        next_output_index += 1
285                else:
286                    for result in newly_completed:
287                        yield result
288
289                # Yielding ordered results may be what frees backlog capacity.
290                schedule_next_document_if_possible()
291        finally:
292            remaining = list(pending)
293            if source_task is not None:
294                remaining.append(source_task)
295            await self._cancel_tasks(remaining)
296
297    async def _process_stream_document(self, document: Document, *, skip: bool) -> Document:
298        if skip:
299            return document
300
301        stats = [get_doc_info(document)]
302        document = await self._try_process(document, self.apply)
303        return (await self._finalize_batch([document], stats))[0]
304
305    @staticmethod
306    async def _cancel_tasks(tasks: Iterable[asyncio.Task[Any]]) -> None:
307        tasks = list(tasks)
308        for task in tasks:
309            if not task.done():
310                task.cancel()
311        if tasks:
312            await asyncio.gather(*tasks, return_exceptions=True)
313
314    async def _try_process(self, target: T, func: Callable[[T], Awaitable[T]]) -> T:
315        try:
316            return await func(target)
317        except Exception as e:
318            if isinstance(target, Document):
319                msg = f"{e!r} occurs while processing {self.name} with {target!r}"
320                self.logger.error(msg, exc_info=True)
321                target.is_rejected = True
322                target.reject_reason = {"error": msg}
323                async with self._stats_lock:
324                    self._statistics.errors += 1
325                return target
326            if isinstance(target, list):
327                msg = f"{e!r} occurs while batch processing {self.name}"
328                self.logger.error(msg, exc_info=True)
329                for doc in target:
330                    doc.is_rejected = True
331                    doc.reject_reason = {"error": msg}
332                async with self._stats_lock:
333                    self._statistics.errors += len(target)
334                return target
335            raise e
336
337    async def __call__(self, text: str) -> str:
338        document = Document(text=text)
339        return (await self._apply(document)).text
340
341    def get_statistics(self) -> Statistics:
342        """
343        Get the statistics of this filter.
344        Returns:
345            Statistics: The statistics of this filter.
346        """
347        return self._statistics
348
349    def get_statistics_map(self) -> dict[str, Statistics]:
350        """
351        Get the statistics of this filter as a dictionary.
352        """
353        return self._statistics.to_dict()
354
355    async def shutdown(self) -> None:
356        """
357        You can override this method to release resources or perform cleanup tasks.
358        """
359        pass
360
361    async def __aenter__(self) -> "AsyncFilter":
362        return self
363
364    async def __aexit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None:
365        await self.shutdown()
366
367    def get_jsonable_vars(self, exclude_keys: set[str] | None = None) -> dict[str, Any]:
368        """
369        Get the member variable of this filter.
370        Eligible variables are primitive types; [bool, int, float, str, None],
371        and the name of the variable not starts with the underscore; `_`.
372        """
373        if exclude_keys is None:
374            exclude_keys = set()
375        return {
376            k: v
377            for k, v in vars(self).items()
378            if (_is_jsonable(v) and (k not in exclude_keys) and (not k.startswith("_")))
379        }
380
381    async def _finalize_batch(
382        self,
383        batch: Sequence[Document],
384        old_stats: list[dict[str, Any]],
385    ) -> list[Document]:
386        new_stats = [get_doc_info(doc) for doc in batch]
387        for old, new, doc in zip(old_stats, new_stats, batch):
388            async with self._stats_lock:
389                self._statistics.update_by_diff(old, new)
390            if not old["is_rejected"] and new["is_rejected"] and "error" not in doc.reject_reason:
391                doc.reject_reason = self.get_jsonable_vars()
392        return list(batch)
393
394    def __init_rng(self, random_state: int | np.random.Generator | None) -> None:
395        self._owns_rng = True
396        if random_state is None:
397            self._rng = np.random.default_rng()
398            self._owns_rng = False
399        elif isinstance(random_state, int):
400            self._rng = np.random.default_rng(random_state)
401        elif isinstance(random_state, np.random.Generator):
402            self._rng = random_state
403        else:
404            raise TypeError(
405                f"random_state must be int or np.random.Generator, not {type(random_state)}"
406            )
407
408    def _set_rng_if_not_initialized(self, rng: np.random.Generator) -> None:
409        """
410        Set the random number generator for this filter if it is not already initialized.
411        This method is called by Compose class.
412        """
413        if not self._owns_rng:
414            self._rng = rng

Helper class that provides a standard way to create an ABC using inheritance.

AsyncFilter( *args: Any, p: float = 1.0, skip_rejected: bool = True, random_state: int | numpy.random._generator.Generator | None = None, use_batch: bool = True, batch_size: int = 128, ordered: bool = True, **kwargs: Any)
35    def __init__(
36        self,
37        *args: Any,
38        p: float = 1.0,
39        skip_rejected: bool = True,
40        random_state: int | np.random.Generator | None = None,
41        use_batch: bool = True,
42        batch_size: int = 128,
43        ordered: bool = True,
44        **kwargs: Any,
45    ):
46        """
47        Base class for asynchronous filters.
48
49        Parameters
50        ----------
51        p : float
52            The probability of applying the filter.
53            If `p` is 1, the filter will always be applied.
54        skip_rejected : bool
55            If `True`, the filter will skip documents that are already rejected.
56            If you want to apply the filter to all documents (e.g., postprocess), set this to `False`.
57        random_state : Optional[Union[int, np.random.Generator]]
58            Seed for the random number generator.
59            If `None` is specified, the random number generator managed by the Compose class will be used.
60        use_batch : bool
61            If `True`, the filter will process documents in batches in the `apply_stream` method.
62        batch_size : int
63            The size of the batch to process documents in the `apply_stream` method.
64            When `apply_batch` is not overridden, this is also the maximum number of
65            in-flight document tasks used by the sliding-window scheduler. Ordered
66            processing applies backpressure after at most two windows of started but
67            not yet yielded documents.
68        ordered : bool
69            If `True`, `apply_stream` yields documents in input order. If `False`, filters
70            using the default `apply_batch` implementation yield documents as soon as their
71            processing completes.
72        """
73        if batch_size < 1:
74            raise ValueError("batch_size must be at least 1")
75
76        self.name = self.__class__.__name__
77        self.logger = logging.getLogger(f"{self.__module__}.{self.__class__.__name__}")
78        assert 0 <= p <= 1
79        self.p = p
80        self.__init_rng(random_state)
81        self.skip_rejected = skip_rejected
82        self.use_batch = use_batch
83        self.batch_size = batch_size
84        self.ordered = ordered
85
86        self._statistics = Statistics(name=self.name)
87        self._stats_lock = asyncio.Lock()

Base class for asynchronous filters.

Parameters

p : float The probability of applying the filter. If p is 1, the filter will always be applied. skip_rejected : bool If True, the filter will skip documents that are already rejected. If you want to apply the filter to all documents (e.g., postprocess), set this to False. random_state : Optional[Union[int, np.random.Generator]] Seed for the random number generator. If None is specified, the random number generator managed by the Compose class will be used. use_batch : bool If True, the filter will process documents in batches in the apply_stream method. batch_size : int The size of the batch to process documents in the apply_stream method. When apply_batch is not overridden, this is also the maximum number of in-flight document tasks used by the sliding-window scheduler. Ordered processing applies backpressure after at most two windows of started but not yet yielded documents. ordered : bool If True, apply_stream yields documents in input order. If False, filters using the default apply_batch implementation yield documents as soon as their processing completes.

@abstractmethod
async def apply( self, document: hojichar.Document) -> hojichar.Document:
 89    @abstractmethod
 90    async def apply(self, document: Document) -> Document:
 91        """
 92        Definition of async filter behavior.
 93
 94        In this method, the filter will modify `document.text` or
 95        `document.extras` and set `document.is_rejected = True` to discard the document.
 96
 97        Parameters
 98        ----------
 99        document : Document
100            Input document
101
102        Returns
103        -------
104        Document
105            Processed Document
106        """
107        pass

Definition of async filter behavior.

In this method, the filter will modify document.text or document.extras and set document.is_rejected = True to discard the document.

Parameters

document : Document Input document

Returns

Document Processed Document

async def apply_batch( self, batch: Sequence[hojichar.Document]) -> list[hojichar.Document]:
136    async def apply_batch(self, batch: Sequence[Document]) -> list[Document]:
137        """
138        Apply the filter to a Sequence of documents.
139        By default, the processing implemented in `apply` is executed asynchronously and concurrently.
140        If the filter processing can be optimized for batch processing, override this method.
141        """
142        tasks = [self.apply(doc) for doc in batch]
143        return await asyncio.gather(*tasks)

Apply the filter to a Sequence of documents. By default, the processing implemented in apply is executed asynchronously and concurrently. If the filter processing can be optimized for batch processing, override this method.

async def apply_stream( self, stream: Union[Iterable[hojichar.Document], AsyncIterable[hojichar.Document]]) -> AsyncGenerator[hojichar.Document, NoneType]:
156    async def apply_stream(
157        self,
158        stream: Iterable[Document] | AsyncIterable[Document],
159    ) -> AsyncGenerator[Document, None]:
160        """
161        Apply the filter to a stream of documents (Iterable or AsyncIterable).
162        If use_batch is set to `True` at initialization, the filter will process documents in batches.
163        If the stream is not asynchronous, use handle_stream_as_async to convert it to an asynchronous stream.
164
165        Even if an exception occurs during processing, the process will continue, and the following actions will be taken:
166        - Set the `is_rejected` flag of the document to `True`
167        - Set the error details in `reject_reason`
168        - Increment the `errors` count in the statistics retrievable via `get_statistics`
169        """
170        async_stream: AsyncIterable[Document] = handle_stream_as_async(stream)
171
172        if not self.use_batch:
173            async for doc in async_stream:
174                yield await self._try_process(doc, self._apply)
175        elif type(self).apply_batch is AsyncFilter.apply_batch:
176            async for doc in self._apply_stream_concurrently(async_stream):
177                yield doc
178        else:
179            batch: list[Document] = []
180            async for doc in async_stream:
181                if self._check_skip(doc):
182                    yield doc
183                    continue
184
185                batch.append(doc)
186                # Batch size reached, apply batch
187                if len(batch) >= self.batch_size:
188                    stats = [get_doc_info(doc) for doc in batch]
189                    batch = await self._try_process(batch, self.apply_batch)
190                    batch = await self._finalize_batch(batch, stats)
191                    for out in batch:
192                        yield out
193                    batch.clear()
194
195            # Flush remaining documents in the batch
196            if batch:
197                stats = [get_doc_info(doc) for doc in batch]
198                batch = await self._try_process(batch, self.apply_batch)
199                batch = await self._finalize_batch(batch, stats)
200                for out in batch:
201                    yield out

Apply the filter to a stream of documents (Iterable or AsyncIterable). If use_batch is set to True at initialization, the filter will process documents in batches. If the stream is not asynchronous, use handle_stream_as_async to convert it to an asynchronous stream.

Even if an exception occurs during processing, the process will continue, and the following actions will be taken:

  • Set the is_rejected flag of the document to True
  • Set the error details in reject_reason
  • Increment the errors count in the statistics retrievable via get_statistics
def get_statistics(self) -> hojichar.core.models.Statistics:
341    def get_statistics(self) -> Statistics:
342        """
343        Get the statistics of this filter.
344        Returns:
345            Statistics: The statistics of this filter.
346        """
347        return self._statistics

Get the statistics of this filter. Returns: Statistics: The statistics of this filter.

def get_statistics_map(self) -> dict[str, hojichar.core.models.Statistics]:
349    def get_statistics_map(self) -> dict[str, Statistics]:
350        """
351        Get the statistics of this filter as a dictionary.
352        """
353        return self._statistics.to_dict()

Get the statistics of this filter as a dictionary.

async def shutdown(self) -> None:
355    async def shutdown(self) -> None:
356        """
357        You can override this method to release resources or perform cleanup tasks.
358        """
359        pass

You can override this method to release resources or perform cleanup tasks.

def get_jsonable_vars(self, exclude_keys: set[str] | None = None) -> dict[str, typing.Any]:
367    def get_jsonable_vars(self, exclude_keys: set[str] | None = None) -> dict[str, Any]:
368        """
369        Get the member variable of this filter.
370        Eligible variables are primitive types; [bool, int, float, str, None],
371        and the name of the variable not starts with the underscore; `_`.
372        """
373        if exclude_keys is None:
374            exclude_keys = set()
375        return {
376            k: v
377            for k, v in vars(self).items()
378            if (_is_jsonable(v) and (k not in exclude_keys) and (not k.startswith("_")))
379        }

Get the member variable of this filter. Eligible variables are primitive types; [bool, int, float, str, None], and the name of the variable not starts with the underscore; _.