From 1a735e0f1f079f79f3d5a673e3d641f3916f7ad1 Mon Sep 17 00:00:00 2001 From: Ines Montani Date: Mon, 25 Feb 2019 10:12:58 +0100 Subject: [PATCH 1/5] Add regression test for #3328 --- spacy/tests/regression/test_issue3328.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 spacy/tests/regression/test_issue3328.py diff --git a/spacy/tests/regression/test_issue3328.py b/spacy/tests/regression/test_issue3328.py new file mode 100644 index 000000000..fce25ca1c --- /dev/null +++ b/spacy/tests/regression/test_issue3328.py @@ -0,0 +1,21 @@ +# coding: utf-8 +from __future__ import unicode_literals + +import pytest +from spacy.matcher import Matcher +from spacy.tokens import Doc + + +@pytest.mark.xfail +def test_issue3328(en_vocab): + doc = Doc(en_vocab, words=["Hello", ",", "how", "are", "you", "doing", "?"]) + matcher = Matcher(en_vocab) + patterns = [ + [{"LOWER": {"IN": ["hello", "how"]}}], + [{"LOWER": {"IN": ["you", "doing"]}}], + ] + matcher.add("TEST", None, *patterns) + matches = matcher(doc) + assert len(matches) == 4 + matched_texts = [doc[start:end].text for _, start, end in matches] + assert matched_texts == ["Hello", "how", "you", "doing"] From f1c3108d52e3306a8f2c91b1f71d9d69b88ac672 Mon Sep 17 00:00:00 2001 From: Julia Makogon Date: Mon, 25 Feb 2019 16:48:17 +0200 Subject: [PATCH 2/5] Fixing pymorphy2 dependency issue (#3329) (closes #3327) * Classes for Ukrainian; small fix in Russian. * Contributor agreement * pymorphy2 initialization split for ru and uk (#3327) * stop-words fixed * Unit-tests updated --- spacy/lang/ru/lemmatizer.py | 7 +- spacy/lang/uk/lemmatizer.py | 239 +++++++++++++++++++++++++- spacy/lang/uk/stop_words.py | 141 +++++++++++---- spacy/tests/conftest.py | 2 + spacy/tests/lang/uk/test_tokenizer.py | 2 +- 5 files changed, 348 insertions(+), 43 deletions(-) diff --git a/spacy/lang/ru/lemmatizer.py b/spacy/lang/ru/lemmatizer.py index 2cdf08e2e..24036310f 100644 --- a/spacy/lang/ru/lemmatizer.py +++ b/spacy/lang/ru/lemmatizer.py @@ -8,17 +8,18 @@ from ...lemmatizer import Lemmatizer class RussianLemmatizer(Lemmatizer): _morph = None - def __init__(self, pymorphy2_lang='ru'): + def __init__(self): super(RussianLemmatizer, self).__init__() try: from pymorphy2 import MorphAnalyzer except ImportError: raise ImportError( 'The Russian lemmatizer requires the pymorphy2 library: ' - 'try to fix it with "pip install pymorphy2==0.8"') + 'try to fix it with "pip install pymorphy2==0.8" ' + 'or "pip install git+https://github.com/kmike/pymorphy2.git pymorphy2-dicts-uk" if you need Ukrainian too') if RussianLemmatizer._morph is None: - RussianLemmatizer._morph = MorphAnalyzer(lang=pymorphy2_lang) + RussianLemmatizer._morph = MorphAnalyzer() def __call__(self, string, univ_pos, morphology=None): univ_pos = self.normalize_univ_pos(univ_pos) diff --git a/spacy/lang/uk/lemmatizer.py b/spacy/lang/uk/lemmatizer.py index 8db294507..fffae10c5 100644 --- a/spacy/lang/uk/lemmatizer.py +++ b/spacy/lang/uk/lemmatizer.py @@ -1,12 +1,239 @@ -from ..ru.lemmatizer import RussianLemmatizer +# coding: utf8 +from ...symbols import ( + ADJ, DET, NOUN, NUM, PRON, PROPN, PUNCT, VERB, POS +) +from ...lemmatizer import Lemmatizer -class UkrainianLemmatizer(RussianLemmatizer): +class UkrainianLemmatizer(Lemmatizer): + _morph = None - def __init__(self, pymorphy2_lang='ru'): + def __init__(self): + super(UkrainianLemmatizer, self).__init__() try: - super(UkrainianLemmatizer, self).__init__(pymorphy2_lang='uk') - except ImportError: + from pymorphy2 import MorphAnalyzer + if UkrainianLemmatizer._morph is None: + UkrainianLemmatizer._morph = MorphAnalyzer(lang='uk') + except (ImportError, TypeError): raise ImportError( 'The Ukrainian lemmatizer requires the pymorphy2 library and dictionaries: ' - 'try to fix it with "pip install git+https://github.com/kmike/pymorphy2.git pymorphy2-dicts-uk"') + 'try to fix it with' + '"pip uninstall pymorphy2"' + '"pip install git+https://github.com/kmike/pymorphy2.git pymorphy2-dicts-uk"') + + + def __call__(self, string, univ_pos, morphology=None): + univ_pos = self.normalize_univ_pos(univ_pos) + if univ_pos == 'PUNCT': + return [PUNCT_RULES.get(string, string)] + + if univ_pos not in ('ADJ', 'DET', 'NOUN', 'NUM', 'PRON', 'PROPN', 'VERB'): + # Skip unchangeable pos + return [string.lower()] + + analyses = self._morph.parse(string) + filtered_analyses = [] + for analysis in analyses: + if not analysis.is_known: + # Skip suggested parse variant for unknown word for pymorphy + continue + analysis_pos, _ = oc2ud(str(analysis.tag)) + if analysis_pos == univ_pos \ + or (analysis_pos in ('NOUN', 'PROPN') and univ_pos in ('NOUN', 'PROPN')): + filtered_analyses.append(analysis) + + if not len(filtered_analyses): + return [string.lower()] + if morphology is None or (len(morphology) == 1 and POS in morphology): + return list(set([analysis.normal_form for analysis in filtered_analyses])) + + if univ_pos in ('ADJ', 'DET', 'NOUN', 'PROPN'): + features_to_compare = ['Case', 'Number', 'Gender'] + elif univ_pos == 'NUM': + features_to_compare = ['Case', 'Gender'] + elif univ_pos == 'PRON': + features_to_compare = ['Case', 'Number', 'Gender', 'Person'] + else: # VERB + features_to_compare = ['Aspect', 'Gender', 'Mood', 'Number', 'Tense', 'VerbForm', 'Voice'] + + analyses, filtered_analyses = filtered_analyses, [] + for analysis in analyses: + _, analysis_morph = oc2ud(str(analysis.tag)) + for feature in features_to_compare: + if (feature in morphology and feature in analysis_morph + and morphology[feature] != analysis_morph[feature]): + break + else: + filtered_analyses.append(analysis) + + if not len(filtered_analyses): + return [string.lower()] + return list(set([analysis.normal_form for analysis in filtered_analyses])) + + @staticmethod + def normalize_univ_pos(univ_pos): + if isinstance(univ_pos, str): + return univ_pos.upper() + + symbols_to_str = { + ADJ: 'ADJ', + DET: 'DET', + NOUN: 'NOUN', + NUM: 'NUM', + PRON: 'PRON', + PROPN: 'PROPN', + PUNCT: 'PUNCT', + VERB: 'VERB' + } + if univ_pos in symbols_to_str: + return symbols_to_str[univ_pos] + return None + + def is_base_form(self, univ_pos, morphology=None): + # TODO + raise NotImplementedError + + def det(self, string, morphology=None): + return self(string, 'det', morphology) + + def num(self, string, morphology=None): + return self(string, 'num', morphology) + + def pron(self, string, morphology=None): + return self(string, 'pron', morphology) + + def lookup(self, string): + analyses = self._morph.parse(string) + if len(analyses) == 1: + return analyses[0].normal_form + return string + + +def oc2ud(oc_tag): + gram_map = { + '_POS': { + 'ADJF': 'ADJ', + 'ADJS': 'ADJ', + 'ADVB': 'ADV', + 'Apro': 'DET', + 'COMP': 'ADJ', # Can also be an ADV - unchangeable + 'CONJ': 'CCONJ', # Can also be a SCONJ - both unchangeable ones + 'GRND': 'VERB', + 'INFN': 'VERB', + 'INTJ': 'INTJ', + 'NOUN': 'NOUN', + 'NPRO': 'PRON', + 'NUMR': 'NUM', + 'NUMB': 'NUM', + 'PNCT': 'PUNCT', + 'PRCL': 'PART', + 'PREP': 'ADP', + 'PRTF': 'VERB', + 'PRTS': 'VERB', + 'VERB': 'VERB', + }, + 'Animacy': { + 'anim': 'Anim', + 'inan': 'Inan', + }, + 'Aspect': { + 'impf': 'Imp', + 'perf': 'Perf', + }, + 'Case': { + 'ablt': 'Ins', + 'accs': 'Acc', + 'datv': 'Dat', + 'gen1': 'Gen', + 'gen2': 'Gen', + 'gent': 'Gen', + 'loc2': 'Loc', + 'loct': 'Loc', + 'nomn': 'Nom', + 'voct': 'Voc', + }, + 'Degree': { + 'COMP': 'Cmp', + 'Supr': 'Sup', + }, + 'Gender': { + 'femn': 'Fem', + 'masc': 'Masc', + 'neut': 'Neut', + }, + 'Mood': { + 'impr': 'Imp', + 'indc': 'Ind', + }, + 'Number': { + 'plur': 'Plur', + 'sing': 'Sing', + }, + 'NumForm': { + 'NUMB': 'Digit', + }, + 'Person': { + '1per': '1', + '2per': '2', + '3per': '3', + 'excl': '2', + 'incl': '1', + }, + 'Tense': { + 'futr': 'Fut', + 'past': 'Past', + 'pres': 'Pres', + }, + 'Variant': { + 'ADJS': 'Brev', + 'PRTS': 'Brev', + }, + 'VerbForm': { + 'GRND': 'Conv', + 'INFN': 'Inf', + 'PRTF': 'Part', + 'PRTS': 'Part', + 'VERB': 'Fin', + }, + 'Voice': { + 'actv': 'Act', + 'pssv': 'Pass', + }, + 'Abbr': { + 'Abbr': 'Yes' + } + } + + pos = 'X' + morphology = dict() + unmatched = set() + + grams = oc_tag.replace(' ', ',').split(',') + for gram in grams: + match = False + for categ, gmap in sorted(gram_map.items()): + if gram in gmap: + match = True + if categ == '_POS': + pos = gmap[gram] + else: + morphology[categ] = gmap[gram] + if not match: + unmatched.add(gram) + + while len(unmatched) > 0: + gram = unmatched.pop() + if gram in ('Name', 'Patr', 'Surn', 'Geox', 'Orgn'): + pos = 'PROPN' + elif gram == 'Auxt': + pos = 'AUX' + elif gram == 'Pltm': + morphology['Number'] = 'Ptan' + + return pos, morphology + + +PUNCT_RULES = { + "«": "\"", + "»": "\"" +} diff --git a/spacy/lang/uk/stop_words.py b/spacy/lang/uk/stop_words.py index f5b85312f..97f7e3dbd 100644 --- a/spacy/lang/uk/stop_words.py +++ b/spacy/lang/uk/stop_words.py @@ -13,8 +13,10 @@ from __future__ import unicode_literals STOP_WORDS = set("""а або адже +аж але алло +б багато без безперервно @@ -23,6 +25,7 @@ STOP_WORDS = set("""а більше біля близько +бо був буває буде @@ -36,22 +39,27 @@ STOP_WORDS = set("""а були було бути -бывь в -важлива -важливе -важливий -важливі вам вами вас ваш ваша ваше +вашим +вашими +ваших ваші +вашій +вашого +вашої +вашому +вашою +вашу вгорі вгору вдалині +весь вже ви від @@ -66,7 +74,15 @@ STOP_WORDS = set("""а вони воно восьмий +все +всею +всі +всім +всіх всього +всьому +всю +вся втім г геть @@ -102,16 +118,15 @@ STOP_WORDS = set("""а досить другий дуже +дякую +е +є +ж же -життя з за завжди зазвичай -зайнята -зайнятий -зайняті -зайнято занадто зараз зате @@ -119,22 +134,28 @@ STOP_WORDS = set("""а звідси звідусіль здається +зі значить знову зовсім -ім'я +і +із +її +їй +їм іноді інша інше інший інших інші -її -їй їх +й його йому +каже ким +кілька кого кожен кожна @@ -143,13 +164,13 @@ STOP_WORDS = set("""а коли кому краще -крейдуючи -кругом +крім куди ласка +ледве лише -люди -людина +м +має майже мало мати @@ -164,20 +185,27 @@ STOP_WORDS = set("""а мій мільйонів мною +мого могти моє -мож +моєї +моєму +моєю може можна можно можуть -можхо мої -мор +моїй +моїм +моїми +моїх +мою моя на навіть навіщо +навколо навкруги нагорі над @@ -190,10 +218,21 @@ STOP_WORDS = set("""а наш наша наше +нашим +нашими +наших наші +нашій +нашого +нашої +нашому +нашою +нашу не небагато +небудь недалеко +неї немає нерідко нещодавно @@ -206,17 +245,22 @@ STOP_WORDS = set("""а них ні ніби +ніж +ній ніколи нікуди +нім нічого ну -нх нього +ньому о +обидва обоє один одинадцятий одинадцять +однак однієї одній одного @@ -225,11 +269,16 @@ STOP_WORDS = set("""а он особливо ось +п'ятий +п'ятнадцятий +п'ятнадцять +п'ять перед перший під пізніше пір +після по повинно подів @@ -240,18 +289,14 @@ STOP_WORDS = set("""а потім потрібно почала -прекрасне -прекрасно +початку при про просто проте проти -п'ятий -п'ятнадцятий -п'ятнадцять -п'ять раз +разу раніше рано раптом @@ -259,6 +304,7 @@ STOP_WORDS = set("""а роки років року +році сам сама саме @@ -271,15 +317,15 @@ STOP_WORDS = set("""а самого самому саму -світу свого своє +своєї свої своїй своїх свою -сеаой себе +сих сім сімнадцятий сімнадцять @@ -307,7 +353,17 @@ STOP_WORDS = set("""а також там твій +твого твоє +твоєї +твоєму +твоєю +твої +твоїй +твоїм +твоїми +твоїх +твою твоя те тебе @@ -319,15 +375,19 @@ STOP_WORDS = set("""а тисяч тих ті +тієї тією +тій тільки +тім +то тобі тобою того тоді той -том тому +тою треба третій три @@ -345,7 +405,6 @@ STOP_WORDS = set("""а усім усіма усіх -усію усього усьому усю @@ -363,6 +422,7 @@ STOP_WORDS = set("""а цими цих ці +цієї цій цього цьому @@ -375,11 +435,24 @@ STOP_WORDS = set("""а через четвертий чи +чиє +чиєї +чиєму +чиї +чиїй +чиїм +чиїми +чиїх +чий +чийого +чийому чим численна численне численний численні +чию +чия чого чому чотири @@ -392,6 +465,8 @@ STOP_WORDS = set("""а ще що щоб +щодо +щось я як яка @@ -400,5 +475,5 @@ STOP_WORDS = set("""а які якій якого -якщо -""".split()) +якої +якщо""".split()) diff --git a/spacy/tests/conftest.py b/spacy/tests/conftest.py index 2202a1823..ddc1aee9d 100644 --- a/spacy/tests/conftest.py +++ b/spacy/tests/conftest.py @@ -52,6 +52,7 @@ def RU(request): @pytest.fixture() def UK(request): pymorphy = pytest.importorskip('pymorphy2') + pymorphy_lang = pytest.importorskip('pymorphy2.lang') return util.get_lang_class('uk')() @pytest.fixture() @@ -183,6 +184,7 @@ def ru_tokenizer(): @pytest.fixture(scope='session') def uk_tokenizer(): pymorphy = pytest.importorskip('pymorphy2') + pymorphy_lang = pytest.importorskip('pymorphy2.lang') return util.get_lang_class('uk').Defaults.create_tokenizer() @pytest.fixture(scope='session') diff --git a/spacy/tests/lang/uk/test_tokenizer.py b/spacy/tests/lang/uk/test_tokenizer.py index ded8e9300..f4e45825c 100644 --- a/spacy/tests/lang/uk/test_tokenizer.py +++ b/spacy/tests/lang/uk/test_tokenizer.py @@ -82,7 +82,7 @@ def test_uk_tokenizer_splits_open_appostrophe(uk_tokenizer, text): assert len(tokens) == 2 assert tokens[0].text == "'" - +@pytest.mark.xfail # https://github.com/explosion/spaCy/issues/3327 @pytest.mark.parametrize('text', ["Тест''"]) def test_uk_tokenizer_splits_double_end_quote(uk_tokenizer, text): tokens = uk_tokenizer(text) From f135d663f7053ba4f95eba600381b797b9d7312b Mon Sep 17 00:00:00 2001 From: Ines Montani Date: Mon, 25 Feb 2019 15:55:29 +0100 Subject: [PATCH 3/5] Update conftest.py --- spacy/tests/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spacy/tests/conftest.py b/spacy/tests/conftest.py index 99d4eb197..3f35bba8c 100644 --- a/spacy/tests/conftest.py +++ b/spacy/tests/conftest.py @@ -180,7 +180,7 @@ def tt_tokenizer(): def uk_tokenizer(): pytest.importorskip("pymorphy2") pytest.importorskip("pymorphy2.lang") - return util.get_lang_class("uk").Defaults.create_tokenizer() + return get_lang_class("uk").Defaults.create_tokenizer() @pytest.fixture(scope="session") From f2fae1f186042eadd5b74280382d5836e6df3854 Mon Sep 17 00:00:00 2001 From: Matthew Honnibal Date: Mon, 25 Feb 2019 19:30:33 +0100 Subject: [PATCH 4/5] Add batch size argument to Language.evaluate(). Closes #3263 --- spacy/language.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spacy/language.py b/spacy/language.py index c1abb62b4..0c0cf8854 100644 --- a/spacy/language.py +++ b/spacy/language.py @@ -573,7 +573,7 @@ class Language(object): proc._rehearsal_model = deepcopy(proc.model) return self._optimizer - def evaluate(self, docs_golds, verbose=False): + def evaluate(self, docs_golds, verbose=False, batch_size=256): scorer = Scorer() docs, golds = zip(*docs_golds) docs = list(docs) @@ -582,7 +582,7 @@ class Language(object): if not hasattr(pipe, "pipe"): docs = (pipe(doc) for doc in docs) else: - docs = pipe.pipe(docs, batch_size=256) + docs = pipe.pipe(docs, batch_size=batch_size) for doc, gold in zip(docs, golds): if verbose: print(doc) From 162bd4d75b74dba1f7114f71ad2507bde72101fd Mon Sep 17 00:00:00 2001 From: Ines Montani Date: Mon, 25 Feb 2019 20:11:11 +0100 Subject: [PATCH 5/5] =?UTF-8?q?=F0=9F=92=AB=20Add=20Algolia=20DocSearch=20?= =?UTF-8?q?(#3332)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Algolia DocSearch * Add human-readable selector for teaser --- website/meta/site.json | 4 ++ website/src/components/icon.js | 2 + website/src/components/navigation.js | 84 +++++++++++++++-------- website/src/components/search.js | 52 ++++++++++++++ website/src/components/title.js | 3 +- website/src/html.js | 43 ++++++++++++ website/src/images/icons/search.svg | 3 + website/src/styles/navigation.module.sass | 49 ++++++++----- website/src/styles/search.module.sass | 57 +++++++++++++++ website/src/templates/index.js | 12 +++- 10 files changed, 264 insertions(+), 45 deletions(-) create mode 100644 website/src/components/search.js create mode 100644 website/src/html.js create mode 100644 website/src/images/icons/search.svg create mode 100644 website/src/styles/search.module.sass diff --git a/website/meta/site.json b/website/meta/site.json index 847a14b55..2b65e1d84 100644 --- a/website/meta/site.json +++ b/website/meta/site.json @@ -22,6 +22,10 @@ "id": "83b0498b1e7fa3c91ce68c3f1", "list": "89ad33e698" }, + "docSearch": { + "apiKey": "371e26ed49d29a27bd36273dfdaf89af", + "indexName": "spacy" + }, "spacyVersion": "2.1", "binderUrl": "ines/spacy-io-binder", "binderBranch": "nightly", diff --git a/website/src/components/icon.js b/website/src/components/icon.js index 246820a2f..d9f0ce7c8 100644 --- a/website/src/components/icon.js +++ b/website/src/components/icon.js @@ -18,6 +18,7 @@ import { ReactComponent as YesIcon } from '../images/icons/yes.svg' import { ReactComponent as NoIcon } from '../images/icons/no.svg' import { ReactComponent as NeutralIcon } from '../images/icons/neutral.svg' import { ReactComponent as OfflineIcon } from '../images/icons/offline.svg' +import { ReactComponent as SearchIcon } from '../images/icons/search.svg' import classes from '../styles/icon.module.sass' @@ -39,6 +40,7 @@ const icons = { no: NoIcon, neutral: NeutralIcon, offline: OfflineIcon, + search: SearchIcon, } const Icon = ({ name, width, height, inline, variant, className }) => { diff --git a/website/src/components/navigation.js b/website/src/components/navigation.js index abf8a913b..bd83f7c44 100644 --- a/website/src/components/navigation.js +++ b/website/src/components/navigation.js @@ -1,6 +1,7 @@ import React from 'react' import PropTypes from 'prop-types' import classNames from 'classnames' +import { navigate } from 'gatsby' import Link from './link' import Icon from './icon' @@ -8,36 +9,64 @@ import { github } from './util' import { ReactComponent as Logo } from '../images/logo.svg' import classes from '../styles/navigation.module.sass' -const Navigation = ({ title, items, section, children }) => ( - + ) +} Navigation.defaultProps = { items: [], @@ -52,6 +81,7 @@ Navigation.propTypes = { }) ), section: PropTypes.string, + search: PropTypes.node, } export default Navigation diff --git a/website/src/components/search.js b/website/src/components/search.js new file mode 100644 index 000000000..0ea4ec993 --- /dev/null +++ b/website/src/components/search.js @@ -0,0 +1,52 @@ +import React, { useEffect, useState } from 'react' +import PropTypes from 'prop-types' +import { window } from 'browser-monads' + +import Icon from './icon' +import classes from '../styles/search.module.sass' + +const Search = ({ id, placeholder, settings }) => { + const { apiKey, indexName } = settings + const [isInitialized, setIsInitialized] = useState(false) + useEffect(() => { + if (!isInitialized) { + setIsInitialized(true) + window.docsearch({ + apiKey, + indexName, + inputSelector: `#${id}`, + debug: false, + }) + } + }, window.docsearch) + return ( +
+ + +
+ ) +} + +Search.defaultProps = { + id: 'docsearch', + placeholder: 'Search docs', +} + +Search.propTypes = { + settings: PropTypes.shape({ + apiKey: PropTypes.string.isRequired, + indexName: PropTypes.string.isRequired, + }).isRequired, + id: PropTypes.string.isRequired, + placeholder: PropTypes.string.isRequired, +} + +export default Search diff --git a/website/src/components/title.js b/website/src/components/title.js index 285ad9192..1eadb3deb 100644 --- a/website/src/components/title.js +++ b/website/src/components/title.js @@ -1,5 +1,6 @@ import React from 'react' import PropTypes from 'prop-types' +import classNames from 'classnames' import Button from './button' import Tag from './tag' @@ -34,7 +35,7 @@ const Title = ({ title, tag, version, teaser, source, image, children, ...props )} - {teaser &&
{teaser}
} + {teaser &&
{teaser}
} {children} diff --git a/website/src/html.js b/website/src/html.js new file mode 100644 index 000000000..53e50bc8a --- /dev/null +++ b/website/src/html.js @@ -0,0 +1,43 @@ +import React from 'react' +import PropTypes from 'prop-types' + +export default function HTML(props) { + return ( + + + + + + {props.headComponents} + + + + {props.preBodyComponents} + +
+ {props.postBodyComponents} + +