<jupyter_start><jupyter_text>This notebook demos some functionality in ConvoKit to preprocess text, and store the results. In particular, it shows examples of:* A `TextProcessor` base class that maps per-utterance attributes to per-utterance outputs;* A `TextParser` class that does dependency parsing;* Selective and decoupled data storage and loading;* Per-utterance calls to a transformer;* Pipelining transformers.   Preliminaries: loading an existing corpus. To start, we load a clean version of a corpus. For speed we will use a 200-utterance subset of the tennis corpus.<jupyter_code>import os
import convokit
from convokit import download
# OPTION 1: DOWNLOAD CORPUS 
# UNCOMMENT THESE LINES TO DOWNLOAD CORPUS
# DATA_DIR = '<YOUR DIRECTORY>'
# ROOT_DIR = download('tennis-corpus')

# OPTION 2: READ PREVIOUSLY-DOWNLOADED CORPUS FROM DISK
# UNCOMMENT THIS LINE AND REPLACE WITH THE DIRECTORY WHERE THE TENNIS-CORPUS IS LOCATED
# ROOT_DIR = '<YOUR DIRECTORY>'

corpus = convokit.Corpus(ROOT_DIR, utterance_end_index=199)
corpus.print_summary_stats()
# SET YOUR OWN OUTPUT DIRECTORY HERE. 
OUT_DIR = '<YOUR DIRECTORY>'<jupyter_output><empty_output><jupyter_text>Here's an example of an utterance from this corpus (questions asked to tennis players after matches, and the answers they give):<jupyter_code>test_utt_id = '1681_14.a'
utt = corpus.get_utterance(test_utt_id)
utt.text<jupyter_output><empty_output><jupyter_text>Right now, `utt.meta` contains the following fields:<jupyter_code>utt.meta<jupyter_output><empty_output><jupyter_text>The TextProcessor class Many of our transformers are per-utterance mappings of one attribute of an utterance to another. To facilitate these calls, we use a `TextProcessor` class that inherits from `Transformer`. `TextProcessor` is initialized with the following arguments:* `proc_fn`: the mapping function. Supports one of two function signatures: `proc_fn(input)` and `proc_fn(input, auxiliary_info)`. * `input_field`: the attribute of the utterance that `proc_fn` will take as input. If set to `None`, will default to reading `utt.text`, as seems to be presently done.* `output_field`: the name of the attribute that the output of `proc_fn` will be written to. * `aux_input`: any auxiliary input that `proc_fn` needs (e.g., a pre-loaded model); passed in as a dict.* `input_filter`: a boolean function of signature `input_filter(utterance, aux_input)`, where `aux_input` is again passed as a dict. If this returns `False` then the particular utterance will be skipped; by default it will always return `True`.Both `input_field` and `output_field` support multiple items -- that is, `proc_fn` could take in multiple attributes of an utterance and output multiple attributes. I'll show how this works in advanced usage, below."Attribute" is a deliberately generic term. `TextProcessor` could produce "features" as we may conventionally think of them (e.g., wordcount, politeness strategies). It can also be used to pre-process text, i.e., generate alternate representations of the text.<jupyter_code>from convokit.text_processing import TextProcessor<jupyter_output><empty_output><jupyter_text>simple example: cleaning the text As a simple example, suppose we want to remove hyphens "`--`" from the text as a preprocessing step. To use `TextProcessor` to do this for us, we'd define the following as a `proc_fn`:<jupyter_code>def preprocess_text(text):
    text = text.replace(' -- ', ' ')
    return text<jupyter_output><empty_output><jupyter_text>Below, we initialize `prep`, a `TextProcessor` object that will run `preprocess_text` on each utterance.When we call `prep.transform()`, the following will occur:* Because we didn't specify an input field, `prep` will pass `utterance.text` into `preprocess_text`* It will write the output -- the text minus the hyphens -- to a field called `clean_text` that will be stored in the utterance meta and that can be accessed as `utt.meta['clean_text']` or `utt.get_info('clean_text')`<jupyter_code>prep = TextProcessor(proc_fn=preprocess_text, output_field='clean_text')
corpus = prep.transform(corpus)<jupyter_output><empty_output><jupyter_text>And as desired, we now have a new field attached to `utt`.<jupyter_code>utt.get_info('clean_text')<jupyter_output><empty_output><jupyter_text>Parsing text with the TextParser class One common utterance-level thing we want to do is parse the text. In practice, in increasing order of (computational) difficulty, this typically entails:* proper tokenizing of words and sentences;* POS-tagging;* dependency-parsing. As such, we provide a `TextParser` class that inherits from `TextProcessor` to do all of this, taking in the following arguments:* `output_field`: defaults to `'parsed'`* `input_field`* `mode`: whether we want to go through all of the above steps (which may be expensive) or stop mid-way through. Supports the following options: `'tokenize'`, `'tag'`, `'parse'` (the default).Under the surface, `TextParser` actually uses two separate models: a `spacy` object that does word tokenization, tagging and parsing _per sentence_, and `nltk`'s sentence tokenizer. The rationale is:* `spacy` doesn't support sentence tokenization without dependency-parsing, and we often want sentence tokenization without having to go through the effort of parsing.* We want to be consistent (as much as possible, given changes to spacy and nltk) in the tokenizations we produce, between runs where we don't want parsing and runs where we do.If we've pre-loaded these models, we can pass them into the constructor too, as:* `spacy_nlp`* `sent_tokenizer`<jupyter_code>from convokit.text_processing import TextParser
parser = TextParser(input_field='clean_text', verbosity=50)
corpus = parser.transform(corpus)<jupyter_output>050/200 utterances processed
100/200 utterances processed
150/200 utterances processed
200/200 utterances processed<jupyter_text>parse outputA parse produced by `TextParser` is serialized in text form. It is a list consisting of sentences, where each sentence is a dict with* `toks`: a list of tokens (i.e., words) in the sentence;* `rt`: the index of the root of the dependency tree (i.e., `sentence['toks'][sentence['rt']` gives the root)Each token, in turn, contains the following:* `tok`: the text of the token;* `tag`: the tag;* `up`: the index of the parent of the token in the dependency tree (no entry for the root);* `down`: the indices of the children of the token;* `dep`: the dependency of the edge between the token and its parent.<jupyter_code>test_parse = utt.get_info('parsed')
test_parse[0]<jupyter_output><empty_output><jupyter_text>If we didn't want to go through the trouble of dependency-parsing (which could be expensive) we could initialize `TextParser` with `mode='tag'`, which only POS-tags tokens:<jupyter_code>texttagger = TextParser(output_field='tagged', input_field='clean_text', mode='tag')
corpus = texttagger.transform(corpus)
utt.get_info('tagged')[0]<jupyter_output><empty_output><jupyter_text>Storing and loading corpora We've now computed a bunch of utterance-level attributes.<jupyter_code>list(utt.meta.keys())<jupyter_output><empty_output><jupyter_text>By default, calling `corpus.dump` will write all of these attributes to disk, within the file that stores utterances; later calling `corpus.load` will load all of these attributes back into a new corpus. For big objects like parses, this incurs a high computational burden (especially if in a later use case you might not even need to look at parses). To avoid this, `corpus.dump`  takes an optional argument `fields_to_skip`, which is a dict of object type (`'utterance'`, `'conversation'`, `'speaker'`, `'corpus'`) to a list of fields that we do not want to write to disk. The following call will write the corpus to disk, without any of the preprocessing output we generated above:<jupyter_code>corpus.dump(os.path.basename(OUT_DIR), base_path=os.path.dirname(OUT_DIR), 
            fields_to_skip={'utterance': ['parsed','tagged','clean_text']})<jupyter_output><empty_output><jupyter_text>For attributes we want to keep around, but that we don't want to read and write to disk in a big batch with all the other corpus data, `corpus.dump_info` will dump fields of a Corpus object into separate files. This takes the following arguments as input:* `obj_type`: which type of Corpus object you're dealing with.* `fields`: a list of the fields to write. * `dir_name`: which directory to write to; by default will write to the directory you read the corpus from.This function will write each field in `fields` to a separate file called `info..jsonl` where each line of the file is a json-serialized dict: `{"id": , "value": }`.<jupyter_code>corpus.dump_info('utterance',['parsed','tagged'], dir_name = OUT_DIR)<jupyter_output><empty_output><jupyter_text>As expected, we now have the following files in the output directory:<jupyter_code>ls $OUT_DIR<jupyter_output>conversations.json  index.json         info.tagged.jsonl  utterances.jsonl
corpus.json         info.parsed.jsonl  speakers.json<jupyter_text>If we now initialize a new corpus by reading from this directory:<jupyter_code>new_corpus = convokit.Corpus(OUT_DIR)
new_utt = new_corpus.get_utterance(test_utt_id)<jupyter_output><empty_output><jupyter_text>We see that things that we've omitted in the `corpus.dump` call will not be read.<jupyter_code>new_utt.meta.keys()<jupyter_output><empty_output><jupyter_text>As a counterpart to `corpus.dump_info` we can also load auxiliary information on-demand. Here, this call will look for `info..jsonl` in the directory of `new_corpus` (or an optionally-specified `dir_name`) and attach the value specified in each line of the file to the utterance with the associated id:<jupyter_code>new_corpus.load_info('utterance',['parsed'])
new_utt.get_info('parsed')<jupyter_output><empty_output><jupyter_text>Per-utterance calls `TextProcessor` objects also support calls per-utterance via `TextProcessor.transform_utterance()`. These calls take in raw strings as well as utterances, and will return an utterance:<jupyter_code>test_str = "I played -- a tennis match."
prep.transform_utterance(test_str)
from convokit.model import Utterance
adhoc_utt = Utterance(text=test_str)
adhoc_utt = prep.transform_utterance(adhoc_utt)
adhoc_utt.get_info('clean_text')<jupyter_output><empty_output><jupyter_text>Pipelines Finally, we can string together multiple transformers, and hence `TextProcessors`, into a pipeline, using a `ConvokitPipeline` object. This is analogous to (and in fact inherits from) scikit-learn's `Pipeline` class.<jupyter_code>from convokit.convokitPipeline import ConvokitPipeline<jupyter_output><empty_output><jupyter_text>As an example, suppose we want to both clean the text and parse it. We can chain the required steps to get there by initializing `ConvokitPipeline` with a list of steps, represented as a tuple of `(, initialized transformer-like object)`:* `'prep'`, our de-hyphenator* `'parse'`, our parser<jupyter_code>parse_pipe = ConvokitPipeline([('prep', TextProcessor(preprocess_text, 'clean_text_pipe')),
                ('parse', TextParser('parsed_pipe', input_field='clean_text_pipe',
                                    verbosity=50))])
corpus = parse_pipe.transform(corpus)
utt.get_info('parsed_pipe')<jupyter_output><empty_output><jupyter_text>As promised, the pipeline also works to transform utterances.<jupyter_code>test_utt = parse_pipe.transform_utterance(test_str)
test_utt.get_info('parsed_pipe')<jupyter_output><empty_output><jupyter_text>Some advanced usage: playing around with parameters The point of the following is to demonstrate more elaborate calls to `TextProcessor`. As an example, we will count words in an utterance.First, we'll initialize a `TextProcessor` that does wordcounts (i.e., `len(x.split())`) on just the raw text (`utt.text`), writing output to field `wc_raw`.<jupyter_code>wc_raw = TextProcessor(proc_fn=lambda x: len(x.split()), output_field='wc_raw')
corpus = wc_raw.transform(corpus)
utt.get_info('wc_raw')<jupyter_output><empty_output><jupyter_text>If we instead wanted to wordcount our preprocessed text, with the hyphens removed, we can specify `input_field='clean_text'` -- as such, the `TextProcessor` will read from `utt.get_info('clean_text')` instead.<jupyter_code>wc = TextProcessor(proc_fn=lambda x: len(x.split()), output_field='wc', input_field='clean_text')
corpus = wc.transform(corpus)<jupyter_output><empty_output><jupyter_text>Here we see that we are no longer counting the extra hyphen.<jupyter_code>utt.get_info('wc')<jupyter_output><empty_output><jupyter_text>Likewise, we can count characters:<jupyter_code>chars = TextProcessor(proc_fn=lambda x: len(x), output_field='ch', input_field='clean_text')
corpus = chars.transform(corpus)
utt.get_info('ch')<jupyter_output><empty_output><jupyter_text>Suppose that for some reason we now wanted to calculate:* characters per word* words per character (the reciprocal)This requires:* a `TextProcessor` that takes in multiple input fields, `'ch'` and `'wc'`;* and that writes to multiple output fields, `'char_per_word'` and `'word_per_char'`.Here's how the resultant object, `char_per_word`, handles this:* in `transform()`, we pass `proc_fn` a dict mapping input field name to value, e.g., `{'wc': 22, 'ch': 120}`* `proc_fn` will be written to return a tuple, where each element of that tuple corresponds to each element of the list we've passed to `output_field`, e.g., ```out0, out1 = proc_fn(input)utt.set_info('char_per_word', out0) utt.set_info('word_per_char', out1)```<jupyter_code>char_per_word = TextProcessor(proc_fn=lambda x: (x['ch']/x['wc'], x['wc']/x['ch']), 
                              output_field=['char_per_word', 'word_per_char'], input_field=['ch','wc'])
corpus = char_per_word.transform(corpus)
utt.get_info('char_per_word')
utt.get_info('word_per_char')<jupyter_output><empty_output><jupyter_text>Some advanced usage: input filters Just for the sake of demonstration, suppose we wished to save some computation time and only parse the questions in a corpus. We can do this by specifying `input_filter` (which, recall discussion above, takes as argument an `Utterance` object).<jupyter_code>def is_question(utt, aux={}):
    return utt.meta['is_question']
qparser = TextParser(output_field='qparsed', input_field='clean_text', input_filter=is_question, verbosity=50)
corpus = qparser.transform(corpus)<jupyter_output>050/200 utterances processed
100/200 utterances processed
150/200 utterances processed
200/200 utterances processed<jupyter_text>Since our test utterance is not a question, `qparser.transform()` will skip over it, and hence the utterance won't have the 'qparsed' attribute (and `get_info` returns `None`):<jupyter_code>utt.get_info('qparsed')<jupyter_output><empty_output><jupyter_text>However, if we take an utterance that's a question, we see that it is indeed parsed:<jupyter_code>q_utt_id = '1681_14.q'
q_utt = corpus.get_utterance(q_utt_id)
q_utt.text
q_utt.get_info('qparsed')<jupyter_output><empty_output>
{'fraction_non_alphanumeric': 0.09690515806988352, 'fraction_numerical': 0.005657237936772047, 'mean_word_length': 6.383783783783784, 'pattern_counts': {'":': 2, '<': 117, '<?xml version=': 0, '>': 117, 'https://': 0, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 3, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'chain_length': '29', 'dir': 'jupyter-structured-clean-dedup', 'id': '150229', 'license': 'MIT', 'n_tokens_mistral': 4655, 'n_tokens_neox': 4476, 'n_words': 1627, 'path': 'examples/text-processing/text_preprocessing_demo.ipynb', 'repo_name': 'sophieball/Cornell-Conversational-Analysis-Toolkit', 'size': '15025'}