From 220e228b89024568a0e35a89dadfb18fd5129e80 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Primo=C5=BE=20Godec?=
Date: Sun, 1 Mar 2020 12:59:20 +0100
Subject: [PATCH 1/3] Statistics widget
---
.../text/widgets/icons/Statistics.svg | 99 +++
orangecontrib/text/widgets/owstatistics.py | 685 ++++++++++++++++++
.../text/widgets/tests/test_owstatistics.py | 439 +++++++++++
orangecontrib/text/widgets/utils/context.py | 28 +
orangecontrib/text/widgets/utils/widgets.py | 65 ++
5 files changed, 1316 insertions(+)
create mode 100644 orangecontrib/text/widgets/icons/Statistics.svg
create mode 100644 orangecontrib/text/widgets/owstatistics.py
create mode 100644 orangecontrib/text/widgets/tests/test_owstatistics.py
create mode 100644 orangecontrib/text/widgets/utils/context.py
diff --git a/orangecontrib/text/widgets/icons/Statistics.svg b/orangecontrib/text/widgets/icons/Statistics.svg
new file mode 100644
index 000000000..f5371108e
--- /dev/null
+++ b/orangecontrib/text/widgets/icons/Statistics.svg
@@ -0,0 +1,99 @@
+
+
diff --git a/orangecontrib/text/widgets/owstatistics.py b/orangecontrib/text/widgets/owstatistics.py
new file mode 100644
index 000000000..31626b5cd
--- /dev/null
+++ b/orangecontrib/text/widgets/owstatistics.py
@@ -0,0 +1,685 @@
+import re
+from copy import copy
+from string import punctuation
+from typing import Callable, List, Optional, Tuple
+
+import numpy as np
+from AnyQt.QtCore import QSize
+from AnyQt.QtWidgets import QComboBox, QGridLayout, QLabel, QLineEdit
+
+from Orange.widgets import gui
+from Orange.widgets.settings import ContextSetting
+from Orange.widgets.utils.concurrent import ConcurrentWidgetMixin, TaskState
+from Orange.widgets.utils.widgetpreview import WidgetPreview
+from Orange.widgets.widget import Input, Output, OWWidget
+from orangewidget.widget import Msg
+
+from orangecontrib.text import Corpus
+
+# those functions are implemented here since they are used in more statistics
+from orangecontrib.text.preprocess import (
+ LowercaseTransformer,
+ Preprocessor,
+ RegexpTokenizer,
+ UrlRemover,
+)
+from orangecontrib.text.widgets.utils import format_summary_details
+from orangecontrib.text.widgets.utils.context import (
+ AlmostPerfectContextHandler,
+)
+
+
+def num_words(document: str, callback: Callable) -> int:
+ """
+ Return number of words in document-string. Word is every entity divided by
+ space, tab, newline.
+ """
+ callback()
+ return len(document.split())
+
+
+def char_count(document: str, callback: Callable) -> int:
+ """
+ Count number of alpha-numerical in document/string.
+ """
+ callback()
+ return sum(c.isalnum() for c in document)
+
+
+def digit_count(document: str, callback: Callable) -> int:
+ """
+ Count number of digits in document/string.
+ """
+ callback()
+ return sum(c.isdigit() for c in document)
+
+
+def count_appearances(
+ document: str, characters: List[str], callback: Callable
+) -> int:
+ """
+ Count number of appearances of chars from `characters` list.
+ """
+ callback()
+ # I think it supports the majority of main languages
+ # Y can be vowel too sometimes - it is not possible to distinguish
+ return sum(document.lower().count(c) for c in characters)
+
+
+def preprocess_only_words(corpus: Corpus) -> Corpus:
+ """
+ Apply the preprocessor that splits words, transforms them to lower case
+ (and removes punctuations).
+
+ Parameters
+ ----------
+ corpus
+ Corpus on which the preprocessor will be applied.
+
+ Returns
+ -------
+ Preprocessed corpus. Result of pre-processing is saved in tokens/ngrams.
+ """
+ p = Preprocessor(
+ transformers=[LowercaseTransformer()],
+ # by default regexp keeps only words (no punctuations, no spaces)
+ tokenizer=RegexpTokenizer(),
+ )
+ return p(corpus, inplace=False)
+
+
+# every statistic returns a np.ndarray with statistics
+# and list with variables names - it must be implemented here since some
+# statistics in the future will have more variables
+
+
+def words_count(
+ corpus: Corpus, _: str, callback: Callable
+) -> Tuple[np.ndarray, List[str]]:
+ """
+ Count number of words in each document.
+ """
+ corpus = preprocess_only_words(corpus)
+ # np.c_ makes column vector (ndarray) out of the list
+ # [1, 2, 3] -> [[1], [2], [3]]
+ return (
+ np.c_[[num_words(d, callback) for d in corpus.documents]],
+ ["Word count"],
+ )
+
+
+def characters_count(
+ corpus: Corpus, _: str, callback: Callable
+) -> Tuple[np.ndarray, List[str]]:
+ """
+ Count number of characters without spaces, newlines, tabs, ...
+ """
+ return (
+ np.c_[[char_count(d, callback) for d in corpus.documents]],
+ ["Character count"],
+ )
+
+
+def n_gram_count(
+ corpus: Corpus, _: str, callback: Callable
+) -> Tuple[np.ndarray, List[str]]:
+ """
+ Count number of n-grams in every document
+ """
+
+ def ng_count(n_gram: List[str]):
+ callback()
+ return len(n_gram)
+
+ return np.c_[list(map(ng_count, corpus.ngrams))], ["N-gram count"]
+
+
+def average_word_len(
+ corpus: Corpus, _: str, callback: Callable
+) -> Tuple[np.ndarray, List[str]]:
+ """
+ Computes word density as: word count / character count + 1
+ """
+ return (
+ np.c_[
+ [
+ char_count(d, lambda: True) / num_words(d, callback)
+ for d in corpus.documents
+ ]
+ ],
+ ["Average word length"],
+ )
+
+
+def punctuation_count(
+ corpus: Corpus, _: str, callback: Callable
+) -> Tuple[np.ndarray, List[str]]:
+ """
+ Count number of punctuation signs
+ """
+
+ def num_punctuation(document: str):
+ callback()
+ return sum(document.count(c) for c in punctuation)
+
+ return (
+ np.c_[list(map(num_punctuation, corpus.documents))],
+ ["Punctuation count"],
+ )
+
+
+def capital_count(
+ corpus: Corpus, _: str, callback: Callable
+) -> Tuple[np.ndarray, List[str]]:
+ """
+ Count number of capital letters in documents
+ """
+
+ def num_capitals(document: str):
+ callback()
+ return sum(1 for c in document if c.isupper())
+
+ return (
+ np.c_[list(map(num_capitals, corpus.documents))],
+ ["Capital letter count"],
+ )
+
+
+def vowel_count(
+ corpus: Corpus, vowels: str, callback: Callable
+) -> Tuple[np.ndarray, List[str]]:
+ """
+ Count number of vowels in documents
+ """
+ # comma separated string of vowels to list
+ vowels = [v.strip() for v in vowels.split(",")]
+ return (
+ np.c_[
+ [count_appearances(d, vowels, callback) for d in corpus.documents]
+ ],
+ ["Vowel count"],
+ )
+
+
+def consonant_count(
+ corpus: Corpus, consonants: str, callback: Callable
+) -> Tuple[np.ndarray, List[str]]:
+ """
+ Count number of consonants in documents. Consonants are all alnum
+ characters except vowels and numbers
+ """
+ # comma separated string of consonants to list
+ consonants = [v.strip() for v in consonants.split(",")]
+ return (
+ np.c_[
+ [
+ count_appearances(d, consonants, callback)
+ for d in corpus.documents
+ ]
+ ],
+ ["Consonant count"],
+ )
+
+
+def per_cent_unique_words(
+ corpus: Corpus, _: str, callback: Callable
+) -> Tuple[np.ndarray, List[str]]:
+ """
+ Ratio between unique words count and all words count
+ """
+ corpus = preprocess_only_words(corpus)
+
+ def perc_unique(tokens: str):
+ callback()
+ return len(set(tokens)) / len(tokens)
+
+ return np.c_[list(map(perc_unique, corpus.tokens))], ["% unique words"]
+
+
+def starts_with(
+ corpus: Corpus, prefix: str, callback: Callable
+) -> Tuple[np.ndarray, List[str]]:
+ """
+ Number of words that starts with the string in `prefix`.
+ """
+ corpus = preprocess_only_words(corpus)
+
+ def number_starts_with(tokens: List[str]):
+ callback()
+ return sum(t.startswith(prefix) for t in tokens)
+
+ return (
+ np.c_[list(map(number_starts_with, corpus.tokens))],
+ [f"Starts with {prefix}"],
+ )
+
+
+def ends_with(
+ corpus: Corpus, postfix: str, callback: Callable
+) -> Tuple[np.ndarray, List[str]]:
+ """
+ Number of words that ends with the string in `postfix`.
+ """
+ corpus = preprocess_only_words(corpus)
+
+ def number_ends_with(tokens: List[str]):
+ callback()
+ return sum(t.endswith(postfix) for t in tokens)
+
+ return (
+ np.c_[list(map(number_ends_with, corpus.tokens))],
+ [f"Ends with {postfix}"],
+ )
+
+
+def contains(
+ corpus: Corpus, text: str, callback: Callable
+) -> Tuple[np.ndarray, List[str]]:
+ """
+ Number of words that contains string in `text`.
+ """
+ return (
+ np.c_[
+ [count_appearances(d, [text], callback) for d in corpus.documents]
+ ],
+ [f"Contains {text}"],
+ )
+
+
+def regex(
+ corpus: Corpus, expression: str, callback: Callable
+) -> Tuple[np.ndarray, List[str]]:
+ """
+ Count occurrences of pattern in `expression`.
+ """
+ pattern = re.compile(expression)
+
+ def number_regex(tokens: List[str]):
+ callback()
+ return sum(bool(pattern.match(t)) for t in tokens)
+
+ return (
+ np.c_[list(map(number_regex, corpus.tokens))],
+ [f"Regex {expression}"],
+ )
+
+
+def pos_tags(
+ corpus: Corpus, pos_tags: str, callback: Callable
+) -> Optional[Tuple[np.ndarray, List[str]]]:
+ """
+ Count number of specified pos tags in corpus
+ """
+ p_tags = [v.strip().lower() for v in pos_tags.split(",")]
+
+ def cust_count(tags):
+ callback()
+ tags = [t.lower() for t in tags]
+ return sum(tags.count(t) for t in p_tags)
+
+ if corpus.pos_tags is None:
+ return None
+ return (
+ np.c_[[cust_count(p) for p in corpus.pos_tags]],
+ [f"POS tags {pos_tags}"],
+ )
+
+
+class ComputeValue:
+ """
+ Class which provides compute value functionality. It stores the function
+ that is used to compute values on new data table using this domain.
+
+ Attributes
+ ----------
+ function
+ Function that computes new values
+ pattern
+ Some statistics need additional parameter with the pattern
+ (e.g. starts with), for others it is set to empty string.
+ """
+
+ def __init__(self, function: Callable, pattern: str) -> None:
+ self.function = function
+ self.pattern = pattern
+
+ def __call__(self, data: Corpus) -> np.ndarray:
+ """
+ This function compute values on new table.
+ """
+ # lambda is added as a placeholder for a callback.
+ return self.function(data, self.pattern, lambda: True)[0]
+
+
+# the definition of all statistics used in this widget, if new statistic
+# is required ad it to this list
+
+STATISTICS = [
+ # (name of the statistics, function to compute, default value)
+ # if default value is None - text box is not required
+ ("Word count", words_count, None),
+ ("Character count", characters_count, None),
+ ("N-gram count", n_gram_count, None),
+ ("Average word length", average_word_len, None),
+ ("Punctuation count", punctuation_count, None),
+ ("Capital letter count", capital_count, None),
+ ("Vowel count", vowel_count, "a,e,i,o,u"),
+ (
+ "Consonant count",
+ consonant_count,
+ "b,c,d,f,g,h,j,k,l,m,n,p,q,r,s,t,v,w,x,y,z",
+ ),
+ ("Per cent unique words", per_cent_unique_words, None),
+ ("Starts with", starts_with, ""),
+ ("Ends with", ends_with, ""),
+ ("Contains", contains, ""),
+ ("Regex", regex, ""),
+ ("POS tag", pos_tags, "NN,VV,JJ"),
+]
+STATISTICS_NAMES = list(list(zip(*STATISTICS))[0])
+STATISTICS_FUNCTIONS = list(list(zip(*STATISTICS))[1])
+STATISTICS_DEFAULT_VALUE = list(list(zip(*STATISTICS))[2])
+
+
+def run(corpus: Corpus, statistics: Tuple[int, str], state: TaskState) -> None:
+ """
+ This function runs the computation for new features.
+ All results will be reported as a partial results.
+
+ Parameters
+ ----------
+ corpus
+ The corpus on which the computation is held.
+ statistics
+ Tuple of statistic pairs to be computed:
+ (statistics id, string pattern)
+ state
+ State used to report progress and partial results.
+ """
+ # callback is called for each corpus element statistics time
+ tick_values = iter(np.linspace(0, 100, len(corpus) * len(statistics)))
+
+ def advance():
+ state.set_progress_value(next(tick_values))
+
+ for s, patern in statistics:
+ fun = STATISTICS_FUNCTIONS[s]
+ result = fun(corpus, patern, advance)
+ if result is not None:
+ result = result + (ComputeValue(fun, patern),)
+ state.set_partial_result((s, patern, result))
+
+
+class OWStatistics(OWWidget, ConcurrentWidgetMixin):
+ name = "Statistics"
+ description = "Create new statistic variables for documents."
+ keywords = []
+ icon = "icons/Statistics.svg"
+
+ class Inputs:
+ corpus = Input("Corpus", Corpus)
+
+ class Outputs:
+ corpus = Output("Corpus", Corpus)
+
+ class Warning(OWWidget.Warning):
+ not_computed = Msg(
+ "{} statistics cannot be computed and is omitted from results."
+ )
+
+ want_main_area = False
+ settingsHandler = AlmostPerfectContextHandler(0.9)
+
+ # settings
+ default_rules = [(0, ""), (1, "")] # rules used to reset the active rules
+ active_rules: List[Tuple[int, str]] = ContextSetting(default_rules[:])
+ # rules active at time of apply clicked
+ applied_rules: Optional[List[Tuple[int, str]]] = None
+
+ result_dict = {}
+
+ def __init__(self) -> None:
+ OWWidget.__init__(self)
+ ConcurrentWidgetMixin.__init__(self)
+ self.corpus = None
+
+ # the list with combos from the widget
+ self.combos = []
+ # the list with line edits from the widget
+ self.line_edits = []
+ # the list of buttons in front of controls that removes them
+ self.remove_buttons = []
+
+ self._init_controls()
+
+ def _init_controls(self) -> None:
+ """ Init all controls of the widget """
+ self._init_statistics_box()
+ box = gui.hBox(self.controlArea)
+ gui.rubber(box)
+ gui.button(
+ box,
+ self,
+ "Apply",
+ autoDefault=False,
+ width=180,
+ callback=self.apply,
+ )
+
+ def _init_statistics_box(self) -> None:
+ """
+ Init the statistics box in control area - place where used statistics
+ are listed, remove, and added.
+ """
+ patternbox = gui.vBox(self.controlArea, box=True)
+ self.rules_box = rules_box = QGridLayout()
+ patternbox.layout().addLayout(self.rules_box)
+ box = gui.hBox(patternbox)
+ gui.button(
+ box,
+ self,
+ "+",
+ callback=self._add_row,
+ autoDefault=False,
+ flat=True,
+ minimumSize=(QSize(20, 20)),
+ )
+ gui.rubber(box)
+ self.rules_box.setColumnMinimumWidth(1, 70)
+ self.rules_box.setColumnMinimumWidth(0, 10)
+ self.rules_box.setColumnStretch(0, 1)
+ self.rules_box.setColumnStretch(1, 1)
+ self.rules_box.setColumnStretch(2, 100)
+ rules_box.addWidget(QLabel("Feature"), 0, 1)
+ rules_box.addWidget(QLabel("Pattern"), 0, 2)
+ self.adjust_n_rule_rows()
+
+ def adjust_n_rule_rows(self) -> None:
+ """
+ Add or remove lines in statistics box if needed and fix the tab order.
+ """
+
+ def _add_line():
+ n_lines = len(self.combos) + 1
+
+ # add delete symbol
+ button = gui.button(
+ None,
+ self,
+ label="×",
+ flat=True,
+ height=20,
+ styleSheet="* {font-size: 16pt; color: silver}"
+ "*:hover {color: black}",
+ autoDefault=False,
+ callback=self._remove_row,
+ )
+ button.setMinimumSize(QSize(12, 20))
+ self.rules_box.addWidget(button, n_lines, 0)
+ self.remove_buttons.append(button)
+
+ # add statistics type dropdown
+ combo = QComboBox()
+ combo.addItems(STATISTICS_NAMES)
+ combo.currentIndexChanged.connect(self._sync_edit_combo)
+ self.rules_box.addWidget(combo, n_lines, 1)
+ self.combos.append(combo)
+
+ # add line edit for patern
+ line_edit = QLineEdit()
+ self.rules_box.addWidget(line_edit, n_lines, 2)
+ line_edit.textChanged.connect(self._sync_edit_line)
+ self.line_edits.append(line_edit)
+
+ def _remove_line():
+ self.combos.pop().deleteLater()
+ self.line_edits.pop().deleteLater()
+ self.remove_buttons.pop().deleteLater()
+
+ def _fix_tab_order():
+ # TODO: write it differently - check create class
+ for i, (r, c, l) in enumerate(
+ zip(self.active_rules, self.combos, self.line_edits)
+ ):
+ c.setCurrentIndex(r[0]) # update combo
+ l.setText(r[1]) # update line edit
+ if STATISTICS_DEFAULT_VALUE[r[0]] is not None:
+ l.setVisible(True)
+ else:
+ l.setVisible(False)
+
+ n = len(self.active_rules)
+ while n > len(self.combos):
+ _add_line()
+ while len(self.combos) > n:
+ _remove_line()
+ _fix_tab_order()
+
+ def _add_row(self) -> None:
+ """ Add a new row to the statistic box """
+ self.active_rules.append((0, ""))
+ self.adjust_n_rule_rows()
+
+ def _remove_row(self) -> None:
+ """ Removes the clicked row in the statistic box """
+ remove_idx = self.remove_buttons.index(self.sender())
+ del self.active_rules[remove_idx]
+ self.adjust_n_rule_rows()
+
+ def _sync_edit_combo(self) -> None:
+ """ Update rules when combo value changed """
+ combo = self.sender()
+ edit_index = self.combos.index(combo)
+ selected_i = combo.currentIndex()
+ default_value = STATISTICS_DEFAULT_VALUE[selected_i]
+ self.active_rules[edit_index] = (
+ selected_i,
+ default_value or self.active_rules[edit_index][1],
+ )
+ self.adjust_n_rule_rows()
+
+ def _sync_edit_line(self) -> None:
+ """ Update rules when line edit value changed """
+ line_edit = self.sender()
+ edit_index = self.line_edits.index(line_edit)
+ self.active_rules[edit_index] = (
+ self.active_rules[edit_index][0],
+ line_edit.text(),
+ )
+
+ @Inputs.corpus
+ def set_data(self, corpus) -> None:
+ self.closeContext()
+ self.corpus = corpus
+ self.active_rules = self.default_rules[:]
+ self.openContext(corpus)
+ self.adjust_n_rule_rows()
+ self.result_dict = {} # empty computational results when new data
+ # reset old output - it also handle case with corpus == None
+ self.Outputs.corpus.send(None)
+
+ # summary
+ if corpus:
+ self.info.set_input_summary(
+ len(corpus), format_summary_details(corpus)
+ )
+ self.apply()
+ else:
+ self.info.set_input_summary(self.info.NoInput)
+ self.info.set_output_summary(self.info.NoOutput)
+
+ def apply(self) -> None:
+ """
+ This function is called when user click apply button. It starts
+ the computation. When computation is finished results are shown
+ on the output - on_done.
+ """
+ if self.corpus is None:
+ return
+ self.applied_rules = copy(self.active_rules)
+ self.cancel() # cancel task since user clicked apply again
+ rules_to_compute = [
+ r for r in self.active_rules if r not in self.result_dict
+ ]
+ self.start(run, self.corpus, rules_to_compute)
+
+ def on_exception(self, exception: Exception) -> None:
+ raise exception
+
+ def on_partial_result(
+ self, result: Tuple[int, str, Tuple[np.ndarray, List[str], Callable]]
+ ) -> None:
+ statistic, patern, result = result
+ self.result_dict[(statistic, patern)] = result
+
+ def on_done(self, result: None) -> None:
+ # join results
+ if self.corpus:
+ self.output_results()
+
+ # remove unnecessary results from dict - it can happen that user
+ # already removes the statistic from gui but it is still computed
+ for k in list(self.result_dict.keys()):
+ if k not in self.active_rules:
+ del self.result_dict[k]
+
+ def output_results(self) -> None:
+ self.Warning.not_computed.clear()
+ to_stack = []
+ attributes = []
+ comput_values = []
+ not_computed = []
+ for rule in self.applied_rules:
+ # check for safety reasons - in practice should not happen
+ if rule in self.result_dict:
+ res = self.result_dict[rule]
+ if res is None:
+ not_computed.append(STATISTICS_NAMES[rule[0]])
+ else:
+ data, variables, comp_value = res
+ to_stack.append(data)
+ attributes += variables
+ comput_values.append(comp_value)
+ if not_computed:
+ self.Warning.not_computed(", ".join(not_computed))
+ # here we will use extend_attributes function - this function add
+ # attributes to existing corpus so it must be copied first
+ # TODO: when change of pre-processing is finished change this function
+ # to have inplace parameter which is False by default,
+ # also I would prefer extend_attriubtes where you give variables
+ # instead of strings on input
+ new_corpus = self.corpus.copy()
+ if to_stack:
+ new_corpus.extend_attributes(
+ np.hstack(to_stack), attributes, compute_values=comput_values
+ )
+ self.Outputs.corpus.send(new_corpus)
+
+ # summary
+ self.info.set_output_summary(
+ len(new_corpus), format_summary_details(new_corpus)
+ )
+
+
+if __name__ == "__main__":
+ WidgetPreview(OWStatistics).run(Corpus.from_file("book-excerpts"))
diff --git a/orangecontrib/text/widgets/tests/test_owstatistics.py b/orangecontrib/text/widgets/tests/test_owstatistics.py
new file mode 100644
index 000000000..893f43b4b
--- /dev/null
+++ b/orangecontrib/text/widgets/tests/test_owstatistics.py
@@ -0,0 +1,439 @@
+import unittest
+from unittest.mock import Mock
+
+import numpy as np
+import pkg_resources
+from AnyQt.QtWidgets import QPushButton
+
+from Orange.data import Domain, StringVariable
+from Orange.widgets.tests.base import WidgetTest
+from orangecontrib.text import Corpus
+from orangecontrib.text.tag import AveragedPerceptronTagger
+from orangecontrib.text.widgets.owstatistics import (
+ STATISTICS_NAMES,
+ OWStatistics,
+)
+
+
+class TestStatisticsWidget(WidgetTest):
+ def setUp(self) -> None:
+ self.widget = self.create_widget(OWStatistics)
+ self.book_data = Corpus.from_file("book-excerpts")
+ self._create_simple_data()
+
+ def _create_simple_data(self) -> None:
+ """
+ Creat a simple dataset with 4 documents. Save it to `self.corpus`.
+ """
+ metas = np.array(
+ [
+ "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
+ "Duis viverra elit eu mi blandit, {et} sollicitudin nisi ",
+ " a porta\tleo. Duis vitae ultrices massa. Mauris ut pulvinar a",
+ "tortor. Class (aptent) taciti\nsociosqu ad lit1ora torquent per",
+ ]
+ ).reshape(-1, 1)
+ text_var = StringVariable("text")
+ domain = Domain([], metas=[text_var])
+ self.corpus = Corpus(
+ domain,
+ X=np.empty((len(metas), 0)),
+ metas=metas,
+ text_features=[text_var],
+ )
+
+ def _set_feature(self, feature_name: str, value: str = ""):
+ """
+ Set statistic which need to be computed by widget. It sets only one
+ statistics.
+
+ Parameters
+ ----------
+ feature_name
+ The name of statistic
+ value
+ If statistic need a value (e.g. prefix) it is passed here.
+ """
+ feature_index = STATISTICS_NAMES.index(feature_name)
+ self.widget.active_rules = [(feature_index, value)]
+ self.widget.adjust_n_rule_rows()
+
+ def _compute_features(self, feature_name: str, value: str = "") -> Corpus:
+ """
+ Send `self.corpus` to widget, set statistic which need bo be computed,
+ run the computation, and return widget output.
+
+ Parameters
+ ----------
+ feature_name
+ The name of the statistic, only one statistic is set
+ value
+ The value if statistic need it.
+
+ Returns
+ -------
+ Resulting corpus.
+ """
+ self.send_signal(self.widget.Inputs.corpus, self.corpus)
+ self.wait_until_finished()
+ self._set_feature(feature_name, value)
+ self.widget.apply()
+ self.wait_until_finished()
+ res = self.get_output(self.widget.Outputs.corpus)
+ self.assertTupleEqual((len(self.corpus), 1), res.X.shape)
+ return res
+
+ def test_send_data(self):
+ """ Test with basic data, and empty data """
+ self.send_signal(self.widget.Inputs.corpus, self.book_data)
+ self.assertEqual(len(self.book_data), len(self.widget.corpus))
+
+ self.send_signal(self.widget.Inputs.corpus, None)
+ self.assertIsNone(self.widget.corpus)
+ self.widget.apply()
+ self.assertIsNone(self.get_output(self.widget.Outputs.corpus))
+
+ def test_words_count(self):
+ """ Test words count statistic """
+ data = self._compute_features("Word count")
+ np.testing.assert_array_equal(data.X.flatten(), [8, 9, 11, 9])
+
+ self.send_signal(self.widget.Inputs.corpus, None)
+ self.assertIsNone(self.get_output(self.widget.Outputs.corpus))
+
+ def test_characters_count(self):
+ """ Test characters count statistic """
+ data = self._compute_features("Character count")
+ np.testing.assert_array_equal(data.X.flatten(), [47, 44, 48, 51])
+
+ self.send_signal(self.widget.Inputs.corpus, None)
+ self.assertIsNone(self.get_output(self.widget.Outputs.corpus))
+
+ def test_n_gram_count(self):
+ """ Test n-grams count statistic """
+ data = self._compute_features("N-gram count")
+ np.testing.assert_array_equal(data.X.flatten(), [10, 12, 13, 12])
+
+ self.send_signal(self.widget.Inputs.corpus, None)
+ self.assertIsNone(self.get_output(self.widget.Outputs.corpus))
+
+ def test_average_word_len(self):
+ """ Test word density statistic """
+ data = self._compute_features("Average word length")
+ np.testing.assert_array_almost_equal(
+ data.X.flatten(), [5.875, 4.888889, 4.363636, 5.666667]
+ )
+
+ self.send_signal(self.widget.Inputs.corpus, None)
+ self.assertIsNone(self.get_output(self.widget.Outputs.corpus))
+
+ def test_punctuations_cont(self):
+ """ Test punctuations count statistic """
+ data = self._compute_features("Punctuation count")
+ np.testing.assert_array_equal(data.X.flatten(), [2, 3, 2, 3])
+
+ self.send_signal(self.widget.Inputs.corpus, None)
+ self.assertIsNone(self.get_output(self.widget.Outputs.corpus))
+
+ def test_capitals_count(self):
+ """ Test capitals count statistic """
+ data = self._compute_features("Capital letter count")
+ np.testing.assert_array_equal(data.X.flatten(), [1, 1, 2, 1])
+
+ self.send_signal(self.widget.Inputs.corpus, None)
+ self.assertIsNone(self.get_output(self.widget.Outputs.corpus))
+
+ def test_vowels_count(self):
+ """ Test vowels count statistic """
+ data = self._compute_features("Vowel count", "a,e,i,o,u")
+ np.testing.assert_array_equal(data.X.flatten(), [19, 20, 23, 20])
+
+ self.send_signal(self.widget.Inputs.corpus, None)
+ self.assertIsNone(self.get_output(self.widget.Outputs.corpus))
+
+ def test_consonants_count(self):
+ """ Test consonants count statistic """
+ data = self._compute_features(
+ "Consonant count", "b,c,d,f,g,h,j,k,l,m,n,p,q,r,s,t,v,w,x,y,z"
+ )
+ np.testing.assert_array_equal(data.X.flatten(), [28, 24, 25, 30])
+
+ self.send_signal(self.widget.Inputs.corpus, None)
+ self.assertIsNone(self.get_output(self.widget.Outputs.corpus))
+
+ def test_per_cent_unique_words(self):
+ """ Test per-cent unique words statistic """
+ data = self._compute_features("Per cent unique words")
+ np.testing.assert_array_almost_equal(
+ data.X.flatten(), [1, 1, 0.909091, 1]
+ )
+
+ self.send_signal(self.widget.Inputs.corpus, None)
+ self.assertIsNone(self.get_output(self.widget.Outputs.corpus))
+
+ def test_starts_with(self):
+ """ Test starts with count statistic """
+ data = self._compute_features("Starts with", "a")
+ np.testing.assert_array_almost_equal(data.X.flatten(), [2, 0, 2, 2])
+
+ data = self._compute_features("Starts with", "ap")
+ np.testing.assert_array_almost_equal(data.X.flatten(), [0, 0, 0, 1])
+
+ self.send_signal(self.widget.Inputs.corpus, None)
+ self.assertIsNone(self.get_output(self.widget.Outputs.corpus))
+
+ def test_ends_with(self):
+ """ Test ends with count statistic """
+ data = self._compute_features("Ends with", "t")
+ np.testing.assert_array_almost_equal(data.X.flatten(), [3, 3, 1, 2])
+
+ data = self._compute_features("Ends with", "et")
+ np.testing.assert_array_almost_equal(data.X.flatten(), [1, 1, 0, 0])
+
+ self.send_signal(self.widget.Inputs.corpus, None)
+ self.assertIsNone(self.get_output(self.widget.Outputs.corpus))
+
+ def test_contains(self):
+ """ Test contains count statistic """
+ data = self._compute_features("Contains", "t")
+ np.testing.assert_array_almost_equal(data.X.flatten(), [5, 4, 4, 9])
+
+ data = self._compute_features("Contains", "et")
+ np.testing.assert_array_almost_equal(data.X.flatten(), [2, 1, 0, 0])
+
+ data = self._compute_features("Contains", "is")
+ np.testing.assert_array_almost_equal(data.X.flatten(), [1, 2, 2, 0])
+
+ self.send_signal(self.widget.Inputs.corpus, None)
+ self.assertIsNone(self.get_output(self.widget.Outputs.corpus))
+
+ def test_regex(self):
+ """ Test regex statistic """
+ # words that contains digit
+ data = self._compute_features("Regex", "\w*\d\w*")
+ np.testing.assert_array_almost_equal(data.X.flatten(), [0, 0, 0, 1])
+
+ # words that contains digit
+ data = self._compute_features("Regex", "\w*is\w*")
+ np.testing.assert_array_almost_equal(data.X.flatten(), [1, 2, 2, 0])
+
+ self.send_signal(self.widget.Inputs.corpus, None)
+ self.assertIsNone(self.get_output(self.widget.Outputs.corpus))
+
+ def test_pos(self):
+ """
+ Test post tags count
+ - test with corpus that has no pos tags - warning raised
+ - test with corpus that has pos tags
+ """
+ self.send_signal(self.widget.Inputs.corpus, self.corpus)
+ self._set_feature("POS tag", "NN")
+ self.widget.apply()
+ self.wait_until_finished()
+ res = self.get_output(self.widget.Outputs.corpus)
+ self.assertEqual(0, res.X.shape[1])
+ self.assertTrue(self.widget.Warning.not_computed.is_shown())
+
+ tagger = AveragedPerceptronTagger()
+ result = tagger.tag_corpus(self.corpus)
+
+ self.send_signal(self.widget.Inputs.corpus, result)
+ self._set_feature("POS tag", "NN")
+ self.widget.apply()
+ self.wait_until_finished()
+ res = self.get_output(self.widget.Outputs.corpus)
+ self.assertTupleEqual((len(self.corpus), 1), res.X.shape)
+ np.testing.assert_array_almost_equal(res.X.flatten(), [7, 6, 4, 6])
+ self.assertFalse(self.widget.Warning.not_computed.is_shown())
+
+ def test_statistics_combination(self):
+ """
+ Testing three statistics at same time and see if column concatenated
+ correctly.
+ """
+ self.send_signal(self.widget.Inputs.corpus, self.corpus)
+
+ wc_index = STATISTICS_NAMES.index("Word count")
+ starts_with_index = STATISTICS_NAMES.index("Starts with")
+ capital_counts_index = STATISTICS_NAMES.index("Capital letter count")
+ self.widget.active_rules = [
+ (wc_index, ""),
+ (starts_with_index, "a"),
+ (capital_counts_index, ""),
+ ]
+ self.widget.adjust_n_rule_rows()
+
+ self.widget.apply()
+ self.wait_until_finished()
+ res = self.get_output(self.widget.Outputs.corpus)
+
+ self.assertTupleEqual((len(self.corpus), 3), res.X.shape)
+ np.testing.assert_array_almost_equal(
+ res.X[:, 0].flatten(), [8, 9, 11, 9]
+ )
+ np.testing.assert_array_almost_equal(
+ res.X[:, 1].flatten(), [2, 0, 2, 2]
+ )
+ np.testing.assert_array_almost_equal(
+ res.X[:, 2].flatten(), [1, 1, 2, 1]
+ )
+
+ def test_dictionary_statistics(self):
+ """
+ Test remove statistic from the dictionary when they are not required
+ """
+ self.send_signal(self.widget.Inputs.corpus, self.corpus)
+
+ self.widget.active_rules = [
+ (1, ""),
+ ]
+ self.widget.adjust_n_rule_rows()
+ self.widget.apply()
+ self.wait_until_finished()
+
+ self.assertListEqual([(1, "")], list(self.widget.result_dict.keys()))
+
+ self.widget.active_rules = [(1, ""), (2, "")]
+ self.widget.adjust_n_rule_rows()
+ self.widget.apply()
+ self.wait_until_finished()
+
+ self.assertListEqual(
+ [(1, ""), (2, "")], list(self.widget.result_dict.keys())
+ )
+
+ self.widget.active_rules = [(2, "")]
+ self.widget.adjust_n_rule_rows()
+ self.widget.apply()
+ self.wait_until_finished()
+
+ self.assertListEqual([(2, "")], list(self.widget.result_dict.keys()))
+
+ # dict should empty on new data
+ self.send_signal(self.widget.Inputs.corpus, self.corpus)
+ self.assertListEqual([], list(self.widget.result_dict.keys()))
+
+ def test_context(self):
+ """ Test whether context correctly restore rules """
+ rules = [(0, ""), (1, ""), (2, "")]
+ self.send_signal(self.widget.Inputs.corpus, self.corpus)
+ self.widget.active_rules = rules[:]
+
+ self.send_signal(self.widget.Inputs.corpus, self.book_data)
+ self.assertListEqual([(0, ""), (1, "")], self.widget.active_rules)
+
+ self.send_signal(self.widget.Inputs.corpus, self.corpus)
+ self.assertListEqual(rules, self.widget.active_rules)
+
+ def test_compute_values(self):
+ """ Test compute values on new data """
+ data = self._compute_features("Word count")
+
+ computed = Corpus.from_table(data.domain, self.book_data)
+ self.assertEqual(data.domain, computed.domain)
+ self.assertTupleEqual((len(self.book_data), 1), computed.X.shape)
+
+ def test_append_to_existing_X(self):
+ """ Test if new features are correctly attached to X matrix """
+ data = Corpus.from_file("election-tweets-2016")
+ self.send_signal(self.widget.Inputs.corpus, data)
+ self.wait_until_finished()
+ statistics = self.get_output(self.widget.Outputs.corpus)
+
+ self.assertTupleEqual(
+ (data.X.shape[0], data.X.shape[1] + 2), statistics.X.shape
+ )
+
+ def test_add_row(self):
+ self.send_signal(self.widget.Inputs.corpus, self.corpus)
+ self.wait_until_finished()
+ self.widget.active_rules = []
+ self.widget.adjust_n_rule_rows()
+ add_button = [
+ x
+ for x in self.widget.controlArea.findChildren(QPushButton)
+ if x.text() == "+"
+ ][0]
+ add_button.click()
+ self.assertListEqual([(0, "")], self.widget.active_rules)
+
+ def test_remove_row(self):
+ self.send_signal(self.widget.Inputs.corpus, self.corpus)
+ self.widget.active_rules = [(0, "")]
+ self.widget.adjust_n_rule_rows()
+ self.assertListEqual([(0, "")], self.widget.active_rules)
+
+ remove_button = [
+ x
+ for x in self.widget.controlArea.findChildren(QPushButton)
+ if x.text() == "×"
+ ][0]
+ remove_button.click()
+ self.assertListEqual([], self.widget.active_rules)
+
+ def test_input_summary(self):
+ """ Test correctness of the input summary """
+ self.widget.info.set_input_summary = in_sum = Mock()
+
+ self.send_signal(self.widget.Inputs.corpus, self.corpus)
+ in_sum.assert_called_with(
+ len(self.corpus),
+ "4 instances, 1 variable\nFeatures: —\nTarget: —\nMetas: string "
+ "(not shown)",
+ )
+ in_sum.reset_mock()
+
+ self.send_signal(self.widget.Inputs.corpus, self.book_data)
+ in_sum.assert_called_with(
+ len(self.book_data),
+ "140 instances, 2 variables\nFeatures: —\nTarget: categorical\n"
+ "Metas: string (not shown)",
+ )
+ in_sum.reset_mock()
+
+ self.send_signal(self.widget.Inputs.corpus, None)
+ in_sum.assert_called_with(self.widget.info.NoInput)
+
+ def test_output_summary(self):
+ """ Test correctness of the output summary"""
+ self.widget.info.set_output_summary = out_sum = Mock()
+
+ self.send_signal(self.widget.Inputs.corpus, self.corpus)
+ self.wait_until_finished()
+ out_sum.assert_called_with(
+ len(self.corpus),
+ "4 instances, 3 variables\nFeatures: 2 numeric\nTarget: —\nMetas: "
+ "string (not shown)",
+ )
+ out_sum.reset_mock()
+
+ self.send_signal(self.widget.Inputs.corpus, self.book_data)
+ self.wait_until_finished()
+ out_sum.assert_called_with(
+ len(self.book_data),
+ "140 instances, 4 variables\nFeatures: 2 numeric\nTarget: "
+ "categorical\nMetas: string (not shown)",
+ )
+ out_sum.reset_mock()
+
+ self.send_signal(self.widget.Inputs.corpus, None)
+ self.wait_until_finished()
+ out_sum.assert_called_with(self.widget.info.NoOutput)
+
+ def test_remove_function(self):
+ """
+ This test will start to fail when version of Orange >= 3.27.0
+ When this tests fails:
+ - removes `format_summary_details` and `format_variables_string` from
+ utils.widget
+ - replace `format_summary_details` in statistics widget with the same
+ function from core orange
+ - set minimum orange version to 3.25 for the text add-on
+ """
+ self.assertLessEqual(
+ pkg_resources.get_distribution("orange3").version, "3.27.0"
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/orangecontrib/text/widgets/utils/context.py b/orangecontrib/text/widgets/utils/context.py
new file mode 100644
index 000000000..fe383c866
--- /dev/null
+++ b/orangecontrib/text/widgets/utils/context.py
@@ -0,0 +1,28 @@
+from Orange.widgets.settings import PerfectDomainContextHandler
+
+
+class AlmostPerfectContextHandler(PerfectDomainContextHandler):
+ """
+ This context compares both domains and demands that both domain matches
+ in share_domain_matches (e.g. 0.9) of variables. The position of variables
+ (attribute, meta, class_var) is not important since widget that use this
+ handler do not use their values directly.
+
+ Attributes
+ ----------
+ share_domain_matches
+ The share of domain attributes that need to match.
+ """
+ def __init__(self, share_domain_matches: float) -> None:
+ super().__init__()
+ self.share_domain_matches = share_domain_matches
+
+ def match(self, context, domain, attributes, class_vars, metas):
+ context_vars = context.attributes + context.class_vars + context.metas
+ domain_vars = attributes + class_vars + metas
+ matching_vars = [var for var in context_vars if var in domain_vars]
+
+ return (self.PERFECT_MATCH
+ if (len(matching_vars) / len(domain_vars)
+ > self.share_domain_matches)
+ else self.NO_MATCH)
diff --git a/orangecontrib/text/widgets/utils/widgets.py b/orangecontrib/text/widgets/utils/widgets.py
index b06fcb5e4..effdb5c1f 100644
--- a/orangecontrib/text/widgets/utils/widgets.py
+++ b/orangecontrib/text/widgets/utils/widgets.py
@@ -7,6 +7,8 @@
QGridLayout, QCheckBox, QStackedLayout)
from AnyQt.QtGui import QColor
from AnyQt.QtCore import QDate, pyqtSignal, Qt, QSize
+from Orange.data import DiscreteVariable, ContinuousVariable, TimeVariable, \
+ StringVariable
from Orange.widgets.gui import OWComponent, hBox
from Orange.widgets import settings
@@ -565,3 +567,66 @@ def load_provider(self, path_to_file):
self.resource_path = path_to_file
self.valueChanged.emit(self.model_path, self.resource_path)
+
+def format_variables_string(variables):
+ """
+ A function that formats the descriptive part of the input/output summary for
+ either features, targets or metas of the input dataset.
+
+ :param variables: Features, targets or metas of the input dataset
+ :return: A formatted string
+ """
+ if not variables:
+ return '—'
+
+ agg = []
+ for var_type_name, var_type in [('categorical', DiscreteVariable),
+ ('numeric', ContinuousVariable),
+ ('time', TimeVariable),
+ ('string', StringVariable)]:
+ # Disable pylint here because a `TimeVariable` is also a
+ # `ContinuousVariable`, and should be labelled as such. That is why
+ # it is necessary to check the type this way instead of using
+ # `isinstance`, which would fail in the above case
+ var_type_list = [v for v in variables if type(v) is var_type] # pylint: disable=unidiomatic-typecheck
+ if var_type_list:
+ not_shown = ' (not shown)' if issubclass(var_type, StringVariable)\
+ else ''
+ agg.append((f'{var_type_name}{not_shown}', len(var_type_list)))
+
+ attrs, counts = list(zip(*agg))
+ if len(attrs) > 1:
+ var_string = [f'{i} {j}' for i, j in zip(counts, attrs)]
+ var_string = f'{sum(counts)} ({", ".join(var_string)})'
+ elif counts[0] == 1:
+ var_string = attrs[0]
+ else:
+ var_string = f'{counts[0]} {attrs[0]}'
+ return var_string
+
+
+def format_summary_details(data):
+ """
+ A function that forms the entire descriptive part of the input/output
+ summary.
+
+ :param data: A dataset
+ :type data: Orange.data.Table
+ :return: A formatted string
+ """
+ def _plural(number):
+ return 's' * (number != 1)
+
+ details = ''
+ if data:
+ features = format_variables_string(data.domain.attributes)
+ targets = format_variables_string(data.domain.class_vars)
+ metas = format_variables_string(data.domain.metas)
+
+ n_features = len(data.domain.variables) + len(data.domain.metas)
+ details = \
+ f'{len(data)} instance{_plural(len(data))}, ' \
+ f'{n_features} variable{_plural(n_features)}\n' \
+ f'Features: {features}\nTarget: {targets}\nMetas: {metas}'
+
+ return details
From faee8b16b379dda73d1d5b272ecba52c6e1a3c86 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Primo=C5=BE=20Godec?=
Date: Tue, 17 Mar 2020 13:58:58 +0100
Subject: [PATCH 2/3] Lift minimum required version of Orange
---
.travis.yml | 2 +-
requirements.txt | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/.travis.yml b/.travis.yml
index 42d238e14..a08698726 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -28,7 +28,7 @@ matrix:
env: ORANGE="master"
- &orange3-21-0
python: '3.7'
- env: ORANGE="3.21.0"
+ env: ORANGE="3.24.0"
env:
global:
diff --git a/requirements.txt b/requirements.txt
index a9aa22e95..29ca04a1d 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -6,7 +6,7 @@ docutils<0.16 # denpendency for botocore
python-dateutil<2.8.1 # denpendency for botocore
gensim>=0.12.3 # LDA's show topics unified in 0.12.3
setuptools-git
-Orange3 >=3.21.0
+Orange3 >=3.24.0
tweepy
beautifulsoup4
simhash
From 964dd6c3b8e1da427db8d650bc60644b4c269e35 Mon Sep 17 00:00:00 2001
From: Ajda Pretnar
Date: Fri, 17 Apr 2020 15:22:08 +0200
Subject: [PATCH 3/3] Statistics: documentation
---
doc/index.rst | 1 +
doc/widgets.json | 7 ++++
doc/widgets/images/statistics-example.png | Bin 0 -> 158014 bytes
doc/widgets/images/statistics-stamped.png | Bin 0 -> 7608 bytes
doc/widgets/statistics.md | 44 ++++++++++++++++++++++
5 files changed, 52 insertions(+)
create mode 100644 doc/widgets/images/statistics-example.png
create mode 100644 doc/widgets/images/statistics-stamped.png
create mode 100644 doc/widgets/statistics.md
diff --git a/doc/index.rst b/doc/index.rst
index 4a4f31b9d..9eaff9fca 100644
--- a/doc/index.rst
+++ b/doc/index.rst
@@ -26,6 +26,7 @@ Widgets
widgets/docmap
widgets/wordenrichment
widgets/duplicatedetection
+ widgets/statistics
Scripting
---------
diff --git a/doc/widgets.json b/doc/widgets.json
index dd0fa18cf..8a082cf7d 100644
--- a/doc/widgets.json
+++ b/doc/widgets.json
@@ -146,6 +146,13 @@
"icon": "../orangecontrib/text/widgets/icons/Duplicates.svg",
"background": "light-blue",
"keywords": []
+ },
+ {
+ "text": "Statistics",
+ "doc": "widgets/statistics.md",
+ "icon": "../orangecontrib/text/widgets/icons/Statistics.svg",
+ "background": "light-blue",
+ "keywords": []
}
]
]
diff --git a/doc/widgets/images/statistics-example.png b/doc/widgets/images/statistics-example.png
new file mode 100644
index 0000000000000000000000000000000000000000..7afa3a0aeba8f0ce826e320977ff8ee88650f7c4
GIT binary patch
literal 158014
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%0D;s~QYEDmp>)$tO
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