<?php
/**
* SeekQuarry/Yioop --
* Open Source Pure PHP Search Engine, Crawler, and Indexer
*
* Copyright (C) 2009 - 2026 Chris Pollett chris@pollett.org
*
* LICENSE:
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* @author Chris Pollett chris@pollett.org
* @license https://www.gnu.org/licenses/ GPL3
* @link https://www.seekquarry.com/
* @copyright 2009 - 2026
* @filesource
*/
namespace seekquarry\yioop\library;
use seekquarry\yioop\configs as C;
/**
* Abstract, base context tagger class.
* A context tagger is used to apply a sequence of labels to a sequence terms
* or characters of text based on a surrounding context. Context Taggers
* typically make use of n-gram context of a term such as the n/2 - terms
* before and after the term and maybe the earlier tags from a same phrase or
* sentence to make prediction
*
* @author Chris Pollett
*/
abstract class ContextTagger
{
/**
* Locale tag of language this recognizer is for
* @var string
*/
public $lang;
/**
* The name of the file where the tagging model should be stored and read
* from
* @var string
*/
public $tagger_file = "tagger.txt.gz";
/**
* Complete file system path to the file where the tagging model should be
* stored and read from
* @var string
*/
public $tagger_path = "";
/**
* 2D weights for features involving the prior two words to the
* current word and the next two words after the current word
* For a given word position, one has vector, that gives te
* value for each term in the complete training term set, unknown term set,
* and rule based tag term set, what its weight is
* Determined during training
* @var array
*/
public $word_feature;
/**
* The bias vector for features we are training
*
* Determined during training
* @var array
*/
public $bias;
/**
* The weights for features involving the prior two tags to the
* current word whose tag we are trying to determine
* Determined during training
* @var array
*/
public $tag_feature;
/**
* Array of strings for each possible tag for a term
* associated as [tag => tag index]
* @var array
*/
public $tag_set;
/**
* Minimum allowed value for a weight component
* @var float
*/
public $min_w;
/**
* Maximum allowed value for a weight component
* @var float
*/
public $max_w;
/**
* Tokenizer for the language this tagger tags for
* @var Tokenizer
*/
public $tokenizer;
/**
* Constructor for the ContextTagger.
* Sets the language this tagger tags for and sets up the path for
* where it should be stored
* @param string $lang locale tag of the language this tagger tags is for
*/
public function __construct($lang)
{
$lang = str_replace("-", "_", $lang);
$this->lang = $lang;
$this->tagger_path = C\LOCALE_DIR . "/$lang/resources/" .
$this->tagger_file;
$this->tokenizer = PhraseParser::getTokenizer($lang);
}
/**
* Converts training data from the format tagged sentence with terms of the
* form term_tag into a pair of arrays [[terms_in_sentence],
* [tags_in_sentence]]
* @param mixed $text_files can be a file or an array of file names
* @param string $term_tag_separator separator used to separate term and tag
* for terms in input sentence
* @param function $term_callback callback function applied to a term
* before adding term to sentence term array
* @param function $tag_callback callback function applied to a part of
* speech tag before adding tag to sentence tag array
* @param bool $tag_on_array_chars for some kinds of text processing
* it better to assume the tags are applied to each char within a term
* rather than at the term level. For example, we might want to use
* char within a term for name entity tagging. THis flag if true
* says to do this; otherwise don't
* @return array of separated sentences, each sentence having the format of
* [[terms...], [tags...]]
* Currently, the training data needs to fit Chinese Treebank format:
* term followed by a underscore and followed by the tag
* e.g. "新_VA 的_DEC 南斯拉夫_NR 会国_NN"
* To adapt to other language, some modifications are needed
*/
public static function processTexts($text_files, $term_tag_separator = "_",
$term_callback = null, $tag_callback = null,
$tag_on_array_chars = false)
{
$out = [];
foreach($text_files as $text_file) {
if (file_exists($text_file)) {
$fh = fopen($text_file, "r");
while (!feof($fh)) {
$line = fgets($fh);
if(str_contains($line, '<')) {
continue;
}
$term_tag_pairs = preg_split("/[\s ]+/u", $line);
if (!count($term_tag_pairs)) {
continue;
}
$out[] = [];
$last_out = count($out) - 1;
$out[$last_out][0] = [];
$out[$last_out][1] = [];
foreach ($term_tag_pairs as $term_tag_pair) {
$t = explode($term_tag_separator, $term_tag_pair);
if (count($t) == 2) {
$tag = $tag_callback ? $tag_callback($t[1]) : $t[1];
if ($tag_on_array_chars) {
$to_tags = preg_split('//u', $t[0], null,
PREG_SPLIT_NO_EMPTY);
} else {
$to_tags = [$t[0]];
}
foreach($to_tags as $to_tag) {
$out[$last_out][0][] = $term_callback ?
$term_callback($to_tag) : $to_tag;
$out[$last_out][1][] = $tag;
}
}
}
}
fclose($fh);
}
}
return $out;
}
/**
* Maps a term to a corresponding key if the term matches some simple
* pattern such as being a number
* @param string $term is the term to be checked
* @return mixed either the int key for those matrices of just the term
* itself if the tokenizer does not ave the method getPosKey for the
* current language
*/
public function getKey($term)
{
if (!empty($this->tokenizer) && method_exists($this->tokenizer,
"getPosKey")) {
return $this->tokenizer::getPosKey($term);
}
return $term;
}
/**
* Given a sentence (array $terms), find the key for the term at position
* $index
* @param int $index position of term to get key for
* @param array $terms an array of terms typically from and in the order of
* a sentence
* @return mixed key position in word_feature weights and bias arrays
* could be either an int, or the term itself, or the simple rule
* based part of speec it belongs to
*/
public function getIndex($index, $terms)
{
if ($index < 0) {
$k = $index - 2;
} else if ($index >= count($terms)) {
$k = $index - count($terms) - 2;
} else {
$k = $this->getKey($terms[$index]);
}
return $k;
}
/**
* Save the trained weight to disk
*/
public function saveWeights()
{
$out = [];
$out["min_w"] = $this->min_w;
$out["max_w"] = $this->max_w;
$out["w"] = [];
foreach(array_keys($this->word_feature) as $key) {
$out["w"][$key] = $this->packW($key);
}
foreach(array_keys($this->tag_feature) as $key) {
$out["t"][$key] = $this->packT($key);
}
$out["b"] = $this->packB();
$out["tag_set"] = $this->tag_set;
echo "Saving...";
file_put_contents($this->tagger_path,
gzencode(serialize($out), 9));
echo " ok\n";
}
/**
* Load the trained data from disk
* @param bool $for_training whether we are continuing to train (true) or
* whether we are using the loaded data for prediction
*/
public function loadWeights($for_training = false)
{
if (!file_exists($this->tagger_path)) {
echo "$this->tagger_path does not exist!";
exit();
}
$f = unserialize(gzdecode(file_get_contents($this->tagger_path)),
['allowed_classes' => false]);
$this->word_feature = $f["w"];
$this->tag_feature = $f["t"] ?? [];
$this->bias = $f["b"];
$this->min_w = $f["min_w"];
$this->max_w = $f["max_w"];
$this->tag_set = $f["tag_set"];
if ($for_training) {
foreach(array_keys($this->word_feature) as $key) {
$this->word_feature[$key] = $this->unpackW($key);
}
foreach(array_keys($this->tag_feature) as $key) {
$this->tag_feature[$key] = $this->unpackT($key);
}
$this->bias = $this->unpackB();
}
}
/**
* Pack the bias vector represented as an array into a string
* @return string the bias vector packed as a string
*/
public function packB()
{
return pack("f*", ...$this->bias);
}
/**
* Unpack the bias represented as a string into an array
* @return array the bias vector unpacked from a string
*/
public function unpackB()
{
return array_merge(unpack("f" . strval(count($this->tag_set)),
$this->bias));
}
/**
* Pack the tag_feature represented as an array into a string
* @param int $key in tag_feature set corresponding to a part of speech
* @return string packed tag_feature vector
*/
public function packT($key)
{
return pack("f*", ...$this->tag_feature[$key]);
}
/**
* Unpack the tag_feature represented as a string into an array
* @param int $key in tag_feature set corresponding to a part of speech
* @return array unpacked tag_feature vector
*/
public function unpackT($key)
{
return array_merge(unpack("f" . strval(count($this->tag_set)),
$this->tag_feature[$key]));
}
/**
* Pack the weights matrix to a string for a particular part of speech key
* @param int $key index corresponding to a part of speech according to
* $this->tag_set
* @return string the packed weights matrix
*/
public function packW($key)
{
$bin_str = "";
foreach ($this->word_feature[$key] as $i => $t) {
foreach ($t as $u) {
$v = 65535 * ($u - $this->min_w) /
($this->max_w - $this->min_w);
$bin_str .= pack("S", intval($v));
}
}
return $bin_str;
}
/**
* Unpack the weight matrix for a given part of speech key. This
* is a 5 x term_set_size matrix the 5 rows corresponds to
* -2, -1, 0, 1, 2, locations in a 5-gram.
* An (i, j) entry roughly gives the probability of the j term in location i
* having the part of speech given by $key
* @param int $key in word_feature set corresponding to a part of speech
* @return array of weights corresponding to that key
*/
public function unpackW($key)
{
$weights = [];
$size = count($this->tag_set);
for ($i = 0; $i < 5; $i++) {
$weights[$i - 2] = array_merge(unpack("S" . strval($size),
$this->word_feature[$key], 2 * $i * count($this->tag_set)));
for($j = 0; $j < $size; $j++) {
$weights[$i - 2][$j] = ($weights[$i - 2][$j] / 65535) *
($this->max_w - $this->min_w) + $this->min_w;
}
}
return $weights;
}
/**
* Get the bias value for a tag
* @param int $tag_index the index of tag's value within the bias string
* @return float bias value for tag
*/
public function getB($tag_index)
{
return unpack("f", $this->bias, $tag_index * 4)[1];
}
/**
* Set the bias value for tag
* @param int $tag_index the index of tag's value within the bias string
* @param float $value bias value to associate to tag
*/
public function setB($tag_index, $value)
{
$this->bias = substr_replace($this->bias, pack("f", $value),
$tag_index * 4, 4);
}
/**
* Get the tag feature value for tag
* @param int $key in tag_feature set corresponding to a part of speech
* @param int $tag_index the index of tag's value within the tag feature
* string
* @return float tag feature value for tag
*/
public function getT($key, $tag_index)
{
return unpack("f", $this->tag_feature[$key], $tag_index * 4)[1];
}
/**
* Get the weight value for term at position for tag
* @param string $term to get weight of
* @param int $position of term within the current 5-gram
* @param int $tag_index index of the particular tag we are trying to see
* the term's weight for
* @return float weight that the trained model assigns to ($term,
* $position, $tag_index), de-quantised from the 16-bit
* word_feature table back into [min_w, max_w]
*/
public function getW($term, $position, $tag_index)
{
$t = unpack("S", $this->word_feature[$term], 2 * ($position + 2) *
count($this->tag_set) + $tag_index * 2)[1] / 65535 *
($this->max_w - $this->min_w) + $this->min_w;;
return $t;
}
/**
* Tags a sequence of strings according to this tagger's predict method
* returning the tagged result as a string.
* This function is mainly used to facilitate unit testing of taggers.
* @param string $text to be tagged
* @param string $tag_separator terms in the output string will
* be the terms from the input texts followed by $tag_separator
* followed by their tag. So if $tag_separator == "_", then a term
* 中国 in the input texts might be 中国_NR in the output string
* @return string single string where terms in the input texts
* have been tagged. For example output might look like:
* 中国_NR 人民_NN 将_AD 满怀信心_VV
* 地_DEV 开创_VV 新_VA 的_DEC 业绩_NN 。_PU
*/
public function tag($text, $tag_separator = "_")
{
$tagged_text = "";
$lines = preg_split('/\r\n|\r|\n/u', $text);
foreach($lines as $line) {
$line_vector = explode(" ", trim($line));
$tag_vector = $this->predict($line_vector);
$tagged_term_vector = [];
for($i = 0; $i < count($tag_vector); $i++) {
if (is_array($tag_vector[$i])) {
list($term_element, $tag_element) = $tag_vector[$i];
} else {
$term_element = $line_vector[$i];
$tag_element = $tag_vector[$i];
}
$tagged_term_vector[$i] = $term_element . $tag_separator .
$tag_element;
}
$tagged_text .= join(" ", $tagged_term_vector);
}
return $tagged_text;
}
/**
* Uses text files to train a tagger for terms or chars in a document
* @param mixed $text_files with training data. These can be a file or
* an array of file names.
* @param string $term_tag_separator separator used to separate term and tag
* for terms in input sentence
* @param float $learning_rate learning rate when cycling over data trying
* to minimize the cross-entropy loss in the prediction of the tag of the
* middle term.
* @param int $num_epoch number of times to cycle through the
* complete data set. Default value of 1200 seems to avoid overfitting
* @param function $term_callback callback function applied to a term
* before adding term to sentence term array as part of processing and
* training with a sentence.
* @param function $tag_callback callback function applied to a part of
* speech tag before adding tag to sentence tag array as part of
* processing and training with a sentence.
* @param bool $resume when true, continue training from a
* previously-saved checkpoint instead of reinitialising the
* model from scratch
*/
public abstract function train($text_files, $term_tag_separator = "-",
$learning_rate = 0.1, $num_epoch = 1200, $term_callback = null,
$tag_callback = null, $resume = false);
/**
* Predicts a tagging for all elements of $sentence
*
* @param mixed $sentence is an array of segmented terms/chars
* or a string that will be split on white space
* @return array predicted tags. The ith entry in the returned results
* is the tag of ith element of $sentence
*/
public abstract function predict($sentence);
}