Сервис классификации сообщений

Мне очень захотелось сделать спам-фильтр, но с чего начать я не знал. Чисто случайно наткнулся я на книгу «Программируем коллективный разум». В частности в «Главе 6» написана как раз про данные алгоритмы. Вот их я и решил попробовать реализовать. А чтобы было интереснее, а не простое перенабивание кода, я решил сделать простой сервис классификации сообщений.

Реализация

В книге написано как реализовать 2 алгоритма: Наивный метод (Байесовский) и метод Фишера. Что это за алгоритмы и как они работают прекрасно все расписано. Так же в ней написана реализация на Python, но… Мне захотелось попробовать сделать на другом языке программирования и выбрал я Golang.

Сам алгоритм и классы практически переписываются один в один (строчка в строку). Тут ничего сверхординарного нет. Все работает.


type Classifier struct {
	Words       map[string]*Word   `json:"words"`
	Cats        map[string]int     `json:"cats"`
	Thresholds  map[string]float32 `json:"thresholds"`
	Minimums    map[string]float32 `json:"minimums"`
	getFeatures func(str string) (result []string, err error)
}

func NewClassifier(f func(str string) (result []string, err error)) *Classifier {
	classifier := Classifier{}
	classifier.initclassifier(f)
	return &classifier
}

func (classifier *Classifier) initclassifier(f func(str string) (result []string, err error)) {
	classifier.getFeatures = f
	classifier.Cats = make(map[string]int)
	classifier.Words = make(map[string]*Word)
	classifier.Thresholds = make(map[string]float32)
	classifier.Minimums = make(map[string]float32)
}

func (classifier *Classifier) SetThreshold(cat string, t float32) {
	classifier.Thresholds[cat] = t
}

func (classifier *Classifier) GetThreshold(cat string) float32 {
	if _, ok := classifier.Thresholds[cat]; !ok {
		return 1.0
	}
	return classifier.Thresholds[cat]
}

// Тренировка
func (classifier *Classifier) Train(str string, cat string) {

	if len(cat) > 0 {

		words, err := classifier.getFeatures(str)
		if err != nil {
			log.Println(err)
			return
		}

		if len(words) > 0 {
			for _, word := range words {
				classifier.incf(word, cat)
			}
			classifier.incc(cat)
		}

	}

}

// Сколько образцов отнесено к данной категории
func (classifier *Classifier) catcount(cat string) int {
	if _, ok := classifier.Cats[cat]; !ok {
		return 0
	}
	return classifier.Cats[cat]
}

// Общее число образцов
/*func (classifier *Classifier) totalcount() int {
	sum := 0
	if len(classifier.Cats) > 0 {
		for _, val := range classifier.Cats {
			sum += val
		}
	}
	return sum
}*/

// Список всех категорий
func (classifier *Classifier) Categories() []string {
	res := make([]string, 0, len(classifier.Cats))
	for k := range classifier.Cats {
		res = append(res, k)
	}
	return res
}

// Увеличить счетчик применений категории
func (classifier *Classifier) incc(cat string) {
	if _, ok := classifier.Cats[cat]; ok {
		classifier.Cats[cat]++
	} else {
		classifier.Cats[cat] = 1
	}
}

// Увеличить счетчик пар признак/категория
func (classifier *Classifier) incf(word string, cat string) {
	if _, ok := classifier.Words[word]; !ok {
		classifier.Words[word] = &Word{}
	}
	classifier.Words[word].IncCat(cat)
}

// Сколько раз признак появлялся в данной категории
func (classifier *Classifier) fcount(word string, cat string) int {
	if _, ok := classifier.Words[word]; ok {
		if _, ok := classifier.Words[word].Categories[cat]; ok {
			return classifier.Words[word].Categories[cat]
		}
	}
	return 0
}

// Условная вероятность
func (classifier *Classifier) fprob(word string, cat string) float32 {
	//if len(docclass.cats) == 0 {
	if classifier.catcount(cat) == 0 {
		return 0.0

	}
	tmp := float32(classifier.fcount(word, cat)) / float32(classifier.catcount(cat))
	return tmp
}

func (classifier *Classifier) Sampletrain() {
	classifier.Train("Клара у Карла украла караллы.", "good")
	classifier.Train("Шла Саша по шоссе и сосала сушку.", "good")
	classifier.Train("Я вычислю тебя по IP!", "bad")
	classifier.Train("Вычислю тебя", "bad")
}

type WeighteProbeStruct struct {
	Word   string
	Cat    string
	Prf    func(word string, cat string) float32
	Weight float32
	Ap     float32
}

// Взвешенная вероятность
func (classifier *Classifier) weighteprobe(weighteProbeArgs WeighteProbeStruct) float32 {
	if weighteProbeArgs.Weight == 0.0 {
		weighteProbeArgs.Weight = 1.0
	}
	if weighteProbeArgs.Ap == 0 {
		weighteProbeArgs.Ap = 0.5
	}

	basicprob := weighteProbeArgs.Prf(weighteProbeArgs.Word, weighteProbeArgs.Cat)

	totals := 0

	for _, categ := range classifier.Categories() {
		totals += classifier.fcount(weighteProbeArgs.Word, categ)
	}

	bp := ((weighteProbeArgs.Weight * weighteProbeArgs.Ap) + (float32(totals) * basicprob)) / (weighteProbeArgs.Weight + float32(totals))
	return bp
}

func (classifier *Classifier) SetMininun(cat string, val float32) {
	classifier.Minimums[cat] = val
}

func (classifier *Classifier) GetMininun(cat string) float32 {
	val, ok := classifier.Minimums[cat]
	if ok {
		return val
	}

	return 0.0
}

func (classifier *Classifier) Save() (res string, err error) {
	var bytes []byte
	bytes, err = json.Marshal(classifier)
	if err != nil {
		return
	}
	res = string(bytes)
	return
}

func (classifier *Classifier) Load(data string) (err error) {
	err = json.Unmarshal([]byte(data), classifier)
	if err != nil {
		return
	}
	return
}

// Вероятность того, что образец с указанным признаком принадлежит указанной категории, в предположении, что в каждой категории будет одинаковое число образцов
func (fisher *Classifier) cprob(word string, cat string) float32 {
	clf := fisher.fprob(word, cat)
	if clf == 0 {
		return 0.0
	}
	var freqsum float32
	for _, c := range fisher.Categories() {
		freqsum += fisher.fprob(word, c)
	}
	return clf / freqsum
}

func (fisher *Classifier) fisherprobe(str string, cat string) float32 {
	p := float32(1.0)
	words, err := fisher.getFeatures(str)
	if err != nil {
		log.Println(err)
		return 0.0
	}
	for _, word := range words {
		p *= fisher.weighteprobe(WeighteProbeStruct{
			Word: word,
			Cat:  cat,
			Prf:  fisher.cprob,
		})
	}
	fscore := float32(-2.0 * math.Log(float64(p)))
	return fisher.invchi2(fscore, len(words)*2)
}

func (fisher *Classifier) invchi2(chi float32, df int) float32 {
	m := chi / 2.0
	sum := float32(math.Exp(float64(-1.0 * m)))
	term := sum
	for i := 1; i <= int(df/2); i++ {
		term *= m / float32(i)
		sum += term
	}
	if sum < 1.0 {
		return sum
	}
	return 1.0
}

func (fisher *Classifier) FisherClassify(str string, def string) string {
	best := def
	var max float32
	for _, cat := range fisher.Categories() {
		p := fisher.fisherprobe(str, cat)
		if p > fisher.GetMininun(cat) && p > max {
			best = cat
			max = p
		}
	}
	return best
}

func (naivebayes *Classifier) docprobe(str string, cat string) float32 {
	p := float32(1.0)
	words, err := naivebayes.getFeatures(str)
	if err != nil {
		log.Println(err)
		return 0
	}
	for _, word := range words {
		p *= naivebayes.weighteprobe(WeighteProbeStruct{
			Word: word,
			Cat:  cat,
			Prf:  naivebayes.fprob,
		})
	}
	return p
}

func (naivebayes *Classifier) Probe(str string, cat string) float32 {
	catprob := naivebayes.catcount(cat)
	docprobe := naivebayes.docprobe(str, cat)
	return docprobe * float32(catprob)
}

func (naivebayes *Classifier) Classify(str string, def string) string {
	probs := make(map[string]float32)
	max := float32(0.0)
	var best string
	for _, cat := range naivebayes.Categories() {
		probs[cat] = naivebayes.Probe(str, cat)
		if probs[cat] > max {
			max = probs[cat]
			best = cat
		}
	}

	for cat := range probs {
		if cat == best {
			continue
		}
		if probs[cat]*naivebayes.GetThreshold(best) > probs[best] {
			return def
		}
	}
	return best
}

Так же у нас есть структура для групп слов:


type Word struct {
	Categories map[string]int `json:"categories"`
}

func (word *Word) IncCat(cat string) {
	if word.Categories == nil {
		word.Categories = make(map[string]int)
	}
	(*word).Categories[cat]++
}

В книге написано как подготовить входящее сообщение для дальнейшей работы. Алгоритм достаточно прост: разделяем текст на слова с удалением всех знаков препинания и пробелов, составляем массив слов и убираем дублирующие слова, затем прогоняем через цикл и убираем все слова, которые меньше либо равно 2ум символам и больше либо равно 20 символам. Все просто. Но мне не хватило чего-то…

Немного покопавшись в своем сознании я вспомнил, что в Python есть пакет NLTK, который отвечает за обработку естественного языка. В частности в нем есть стеммер. В кратце это такая штука, которая ищет основу слова для заданного слова. К примеру «Саша» может быть записано «Саше», «Сашей», «Сашка», «Сашочек» и т.д. В общем вариантов масса. Так вот вероятность того что одно и тоже слово будет повторять начинает постепенно снижаться. Чтобы обучить нашу модель нуже достаточно большой корпус обучения. Для снижения такого объема и для улучшения качества работы модели можно попробовать применить стеммер.

Библиотека называется SnowBall и написана она на С. Есть и для Go, но хотелось еще что-то. В общем нашел я другую библиотеку и мне она подошла. По факту она так же использует Snowball, только выполнено в виде пакета.

Теперь давайте приведем разбиение текста на слова:


/// Подготовка текста и разбивка на слова
func GetWords(str string, lang string) (result []string, err error) {
	// Подготовим полученный текст
	var stemmer *snowball.Stemmer
	stemmer, err = snowball.New(lang)
	if err != nil {
		return
	}
	var reSplitWords *regexp.Regexp
	switch lang {
	case "ru":
		reSplitWords = regexp.MustCompile(`[^А-Яа-я]+`)
	case "en":
		reSplitWords = regexp.MustCompile(`[^A-Za-z]+`)
	}
	split := reSplitWords.Split(strings.ToLower(str), -1)
	splitted := make(map[string]*string)

	for _, word := range split {
		word = stemmer.Stem(word)
		if len([]rune(word)) > 2 && len([]rune(word)) < 20 {
			if _, ok := splitted[word]; !ok {
				// Нет слова в списке. Добавим
				splitted[word] = nil //&structs.Word{}
			}
		}
	}
	result = make([]string, 0, len(splitted))
	for k := range splitted {
		result = append(result, k)
	}
	return
}

Использование:


сlassifier := structs.NewClassifier(func(str string) (result []string, err error) {
	return GetWords(str, Config.Lang)
})

Тренировка

Наша задача вызвать функцию Train и передать ей 2 параметра: сообщение и группа, к которой относится сообщение. Вот функция:


// Тренировка данных
func train(classifier *structs.Classifier) {
	// TODO
	f, err := os.Open(libs.Config.TraintSetPath)
	if err != nil {
		panic(err)
	}
	defer f.Close()

	csvReader := csv.NewReader(f)

	for {
		row, err := csvReader.Read()
		if err != nil {
			if err == io.EOF {
				err = nil
			}
			return
		}
		classifier.Train(row[0], row[1])
	}
}

Сохранение и загрузка

Сохранять и загружать подготовленные данные можно по разному. Так можно использовать СУБД, а можно напрямую в файлы. Я решил сохранять в файл в формат JSON. Собственно сохранение:


fo, err := os.Create(fmt.Sprintf("./database/%s.json", libs.Config.Lang))
		if err != nil {
			panic(err)
		}
		defer func() {
			if err := fo.Close(); err != nil {
				panic(err)
			}
		}()

		var j string
		j, err = libs.Config.Classifier.Save()
		if err != nil {
			panic(err)
		}
		fo.WriteString(j)

Загрузка:


if _, err := os.Stat(fmt.Sprintf("./database/%s.json", Config.Lang)); !errors.Is(err, os.ErrNotExist) {
		fi, err := os.Open(fmt.Sprintf("./database/%s.json", Config.Lang))
		if err != nil {
			log.Fatal(err)
		}
		defer func() {
			if err = fi.Close(); err != nil {
				log.Fatal(err)
			}
		}()

		b, err := ioutil.ReadAll(fi)
		if err != nil {
			panic(err)
		}
		err = Config.Classifier.Load(string(b))
		if err != nil {
			panic(err)
		}
	}

Сервис

Для работы с программой как с сервисом сделаем для него HTTP-сервер и привяжем функции. Я буду использовать пакет Echo. Подготавливаем:


e := echo.New()
e.Use(middleware.BodyLimit("1M"))
e.Use(middleware.Logger())
e.Use(middleware.Recover())
e.POST("/api/v1/naivebayes", naivebayes)
e.POST("/api/v1/fisher", fisher)

func naivebayes(c echo.Context) error {
	var err error
	var text struct {
		Text string `json:"text"`
	}
	err = json.NewDecoder(c.Request().Body).Decode(&text)
	if err != nil {
		return c.JSON(http.StatusInternalServerError, structs.Result{
			Result:  false,
			Code:    http.StatusInternalServerError,
			Message: libs.PointerString("Не удалось прочитать запрос"),
		})
	}

	return c.JSON(http.StatusOK, structs.Result{
		Result: true,
		Code:   http.StatusOK,
		Data:   libs.PointerString(libs.Config.Classifier.Classify(text.Text, "unknow")),
	})
}

func fisher(c echo.Context) error {
	var err error
	var text struct {
		Text string `json:"text"`
	}
	err = json.NewDecoder(c.Request().Body).Decode(&text)
	if err != nil {
		return c.JSON(http.StatusInternalServerError, structs.Result{
			Result:  false,
			Code:    http.StatusInternalServerError,
			Message: libs.PointerString("Не удалось прочитать запрос"),
		})
	}

	return c.JSON(http.StatusOK, structs.Result{
		Result: true,
		Code:   http.StatusOK,
		Data:   libs.PointerString(libs.Config.Classifier.FisherClassify(text.Text, "unknow")),
	})
}

И моя функция конфигурации:



type Result struct {
	Result  bool    `json:"result"`
	Code    int     `json:"code"`
	Message *string `json:"message"`
	Data    *string `json:"data"`
}

type Config struct {
	IsTrain       bool
	TraintSetPath string

	Lang string

	IsNaivebayes bool
	IsFisher     bool

	Port int

	Classifier *Classifier
}

func LoadConfig() {
	var exists bool
	Config.Lang = "ru"

	var tmp string

	if tmp, exists = os.LookupEnv("PORT"); exists {
		var err error
		Config.Port, err = strconv.Atoi(tmp)
		if err != nil {
			panic(err)
		}
	} else {
		Config.Port = 3000
	}

	prepareArguments(os.Args[1:])

	Config.Classifier = structs.NewClassifier(func(str string) (result []string, err error) {
		return GetWords(str, Config.Lang)
	})

	if _, err := os.Stat(fmt.Sprintf("./database/%s.json", Config.Lang)); !errors.Is(err, os.ErrNotExist) {
		fi, err := os.Open(fmt.Sprintf("./database/%s.json", Config.Lang))
		if err != nil {
			log.Fatal(err)
		}
		defer func() {
			if err = fi.Close(); err != nil {
				log.Fatal(err)
			}
		}()

		b, err := ioutil.ReadAll(fi)
		if err != nil {
			panic(err)
		}
		err = Config.Classifier.Load(string(b))
		if err != nil {
			panic(err)
		}
	}
}

func prepareArguments(args []string) {
	if len(args) > 0 {
		switch args[0] {
		case "train":
			Config.IsTrain = true
			Config.TraintSetPath = args[1]
			prepareArguments(args[2:])
		case "lang":
			Config.Lang = args[1]
			prepareArguments(args[2:])
		case "--naivebayes":
			Config.IsNaivebayes = true
			prepareArguments(args[1:])
		case "--fisher":
			Config.IsFisher = true
			prepareArguments(args[1:])
		}
	}
}

Собственно все.

Пример работы запросов и выдачи результатов.
Пример работы

Где можно применить

Такой сервис можно разместить у себя на маленьком сервере и использоваться для фильтрации, например, чатов чтобы выявлять оскорбляющих и добавлять блокировки. То что у меня получилось можно посмотреть тут.

Поделиться
Вы можете оставить комментарий, или ссылку на Ваш сайт.

Оставить комментарий

Вы должны быть авторизованы, чтобы разместить комментарий.