-
Notifications
You must be signed in to change notification settings - Fork 9
/
chapter-23.tex
1157 lines (984 loc) · 89.7 KB
/
chapter-23.tex
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
\chapter{Практика: спам-фильтр}
\label{ch:23}
\thispagestyle{empty}
В~2002 году Пол Грэхем, имея некоторое количество свободного времени пос\-ле продажи
Viaweb Yahoo, написал статью <<A Plan for Spam>>\pclfootnote{Она доступна по адресу
\url{http://www.paulgraham.com/spam.html} и в книге <<Hackers \& Painters: Big Ideas from
the Computer Age>> (O'Reilly, 2004).}, которая привела к небольшой революции в технологии
фильтрации спама. До этой статьи, большинство спам-фильтров были написаны в терминах
рукописных правил: если сообщение имеет в заголовке слово XXX, то, вероятно, оно является
спамом; если в сообщении имеются три или больше слов в строке написанных ЗАГЛАВНЫМИ
БУКВАМИ, то вероятно, что это тоже спам. Грэхем провёл несколько месяцев пытаясь написать
фильтр, который бы использовал такие правила, до того, как осознал, что это фундаментально
неправильная задача.
Для того чтобы узнать индивидуальные признаки спама, вы должны попытаться влезть в шкуру
спамера, и я публично заявляю, что я хочу провести как можно меньше времени в этом
качестве.
Чтобы не пытаться думать как спамер, Грэхем решил попробовать отделять спам от не спама,
используя статистику, собранную о том, какие слова появляются в обоих типах сообщений.
Фильтр может отслеживать то, как часто отдельные слова появляются и в спаме и в не спаме,
и затем использовать частоты вхождения этих слов в сообщения, чтобы вычислить вероятность
того, к какой группе относится сообщение. Он назвал этот подход байесовской фильтрацией
(Bayesian filtering) по ассоциации с названием статистического подхода, который он
использовал для вычисления частот слов\pclfootnote{Есть некоторые возражения на тему,
действительно ли подход, предложенный Грэхемом, является <<байесовским>>. Однако имя уже
стало привычным, и оно становится синонимом для названия <<статистический>>, когда речь
идёт о спам-фильтрах.}.
\section{Сердце спам-фильтра}
В~этой главе вы реализуете основную функциональность системы фильтрации спама. Вы не
будете писать полноценное приложение; вместо этого вы сосредоточитесь на функциях для
классификации новых сообщений и тренировки фильтра.
Это приложение будет достаточно большим, так что было бы удобным определение нового пакета
для того, чтобы избежать конфликта имён. Например, в исходном коде, который вы можете
загрузить с сайта данной книги, я использую имя пакета \lstinline{COM.GIGAMONKEYS.SPAM},
определяя пакет, который использует и стандартный пакет \lstinline{COMMON-LISP}, и пакет
\lstinline{COM.GIGAMONKEYS.PATHNAMES} из главы~\ref{ch:15}. Определение выглядит сле\-дую\-щим
образом:
\begin{myverb}
(defpackage :com.gigamonkeys.spam
(:use :common-lisp :com.gigamonkeys.pathnames))
\end{myverb}
Любой файл, содержащий код для данного приложения, должен начинаться со строки:
\begin{myverb}
(in-package :com.gigamonkeys.spam)
\end{myverb}
Вы можете продолжать использовать это имя пакета или можете заменить
\lstinline{com.gigamonkeys} на домен, который находится под вашим контролем\footnote{Плохой
практикой является распространение версии этого приложения, используя пакет, имя
которого начинается с \lstinline{com.gigamonkeys}, поскольку вы не управляете данным
доменом.}\hspace{\footnotenegspace}.
Вы можете также ввести данное выражение в REPL, чтобы переключиться на этот пакет и
протестировать функции, которые вы пишете. В~SLIME это приведёт к смене строки
приглашения с \lstinline{CL-USER>} на \lstinline{SPAM>}, вот так:
\begin{myverb}
CL-USER> (in-package :com.gigamonkeys.spam)
#<The COM.GIGAMONKEYS.SPAM package>
SPAM>
\end{myverb}
После того как вы определили пакет, вы можете начать писать код. Основная функция,
которую вы должны реализовать, выполняет простую работу~--- получает текст сообщения в
качестве аргумента и классифицирует сообщение как спам, не спам или неопределённое. Вы
можете легко реализовать эту функцию путём определения её в терминах других функций,
которые вы напишете позже.
\begin{myverb}
(defun classify (text)
(classification (score (extract-features text))))
\end{myverb}
Читая данный код, начиная с самых вложенных функций, первым шагом в классификации сообщения
будет извлечение свойств (features), которые затем будут переданы функции \lstinline{score}. В
функции \lstinline{score} вы вычислите значение, которое может быть преобразовано в одну из
классификаций (спам, не спам или неопределённое) функцией \lstinline{classification}. Из этих
трёх функций функция \lstinline{classification} является самой простой. Вы можете
предположить, что \lstinline{score} будет возвращать значение около \lstinline{1}, если сообщение
является спамом, около \lstinline{0}, если оно не является спамом, и около \lstinline{.5}, если
система не может корректно классифицировать его.
Так что вы можете реализовать \lstinline{classification} следующим образом:
\begin{myverb}
(defparameter *max-ham-score* .4)
(defparameter *min-spam-score* .6)
(defun classification (score)
(cond
((<= score *max-ham-score*) 'ham)
((>= score *min-spam-score*) 'spam)
(t 'unsure)))
\end{myverb}
Функция \lstinline{extract-features} также достаточно проста, хотя она требует большего
количества кода для реализации. В~настоящее время свойствами, которые вы будете
извлекать из сообщения, будут слова из текста сообщения. Для каждого слова вам необходимо
отслеживать количество вхождений в сообщения, указанные как спам и не спам. Удобным
способом хранения всех этих данных вместе со словом является определение класса
\lstinline{word-feature} с тремя слотами.
\begin{myverb}
(defclass word-feature ()
((word
:initarg :word
:accessor word
:initform (error "Must supply :word")
:documentation "The word this feature represents.")
(spam-count
:initarg :spam-count
:accessor spam-count
:initform 0
:documentation "Number of spams we have seen this feature in.")
(ham-count
:initarg :ham-count
:accessor ham-count
:initform 0
:documentation "Number of hams we have seen this feature in.")))
\end{myverb}
Вы будете хранить все свойства в хеш-таблице, так что вы сможете легко находить объект,
представляющий заданное свойство. Вы можете определить специальную переменную,
\lstinline{*feature-database*}, для хранения указателя на данную таблицу.
\begin{myverb}
(defvar *feature-database* (make-hash-table :test #'equal))
\end{myverb}
Вы должны использовать \lstinline{DEFVAR} вместо \lstinline{DEFPARAMETER}, поскольку вы не хотите,
чтобы \lstinline{*feature-database*} была очищена, если в ходе работы вы заново загрузите файл,
содержащий определение этой переменной,~--- она может содержать данные, которые вы не
хотите потерять. Конечно, это означает, что если вы хотите очистить накопленные данные,
то вы не можете просто заново вычислить выражение \lstinline{DEFVAR}. Так что вы должны
определить функцию \lstinline{clear-database}.
\begin{myverb}
(defun clear-database ()
(setf *feature-database* (make-hash-table :test #'equal)))
\end{myverb}
Для нахождения свойств, присутствующих в заданном сообщении, код должен будет выделить
отдельные слова и затем найти соответствующий объект \lstinline{word-feature} в таблице
\lstinline{*feature-database*}. Если \lstinline{*feature-database*} не содержит такого свойства, то вам
необходимо создать новый объект \lstinline{word-feature}, чтобы хранить данные о новом слове. Вы
можете поместить эту логику в отдельную функцию, \lstinline{intern-feature}, которая получает
слово и возвращает соответствующее свойство, создавая его, если это необходимо.
\begin{myverb}
(defun intern-feature (word)
(or (gethash word *feature-database*)
(setf (gethash word *feature-database*)
(make-instance 'word-feature :word word))))
\end{myverb}
Вы можете выделить из сообщения отдельные слова с помощью регулярных выражений. Например,
используя библиотеку Common Lisp Portable Perl-Compatible Regular Expression
(\lstinline{CL-PPCRE}), написанную Weitz, вы можете написать \lstinline{extract-words} следующим
образом\footnote{Библиотека \lstinline{CL-PPCRE} включена в исходные тексты для книги,
доступные с сайта, посвящённого книге. Или вы можете скачать её с сайта разработчика по
адресу \url{http://www.weitz.de/cl-ppcre/}.}\hspace{\footnotenegspace}:
\begin{myverb}
(defun extract-words (text)
(delete-duplicates
(cl-ppcre:all-matches-as-strings "[a-zA-Z]{3,}" text)
:test #'string=))
\end{myverb}
Теперь всё, что вам остаётся реализовать в \lstinline{extract-features},~--- это совместить
вместе \lstinline{extract-words} и \lstinline{intern-feature}. Поскольку \lstinline{extract-words}
возвращает список строк и вы хотите получить список, в котором каждая строка преобразована
в соответствующий объект \lstinline{word-feature}, то тут самое время применить \lstinline{MAPCAR}.
\begin{myverb}
(defun extract-features (text)
(mapcar #'intern-feature (extract-words text)))
\end{myverb}
Вы можете проверить эти функции в интерпретаторе, например вот так:
\begin{myverb}
SPAM> (extract-words "foo bar baz")
("foo" "bar" "baz")
\end{myverb}
И вы можете убедиться, что \lstinline{DELETE-DUPLICATES} работает правильно:
\begin{myverb}
SPAM> (extract-words "foo bar baz foo bar")
("baz" "foo" "bar")
\end{myverb}
Вы также можете проверить работу \lstinline{extract-features}.
\begin{myverb}
SPAM> (extract-features "foo bar baz foo bar")
(#<WORD-FEATURE @ #x71ef28da> #<WORD-FEATURE @ #x71e3809a>
#<WORD-FEATURE @ #x71ef28aa>)
\end{myverb}
Однако, как вы можете видеть, стандартный метод печати произвольных объектов не особо
информативен. В~процессе работы над этой программой было бы полезно иметь возможность
печатать объекты \lstinline{word-feature} в более понятном виде. К счастью, как я упоминал в
главе~\ref{ch:17}, печать объектов реализована в терминах обобщённой функции
\lstinline{PRINT-OBJECT}, так что для изменения способа печати объектов \lstinline{word-feature} вам
нужно определить метод для \lstinline{PRINT-OBJECT}, специализированный для
\lstinline{word-feature}. Для того чтобы сделать реализацию подобных методов более лёгкой,
Common Lisp предоставляет макрос \lstinline{PRINT-UNREADABLE-OBJECT}\footnote{Основная причина
использования \lstinline{PRINT-UNREADABLE-OBJECT} заключается в том, что он берёт на себя
заботу о выдаче ошибки, если кто-то пытается напечатать ваш объект в форме, подходящей
для последующего считывания, например при использовании директивы \lstinline{~S} функции
\lstinline{FORMAT}.}\hspace{\footnotenegspace}.
Использование \lstinline{PRINT-UNREADABLE-OBJECT} выглядит следующим образом:
\begin{myverb}
(print-unreadable-object (object stream-variable &key type identity)
body-form*)
\end{myverb}
Аргумент \lstinline{object} является выражением, которое вычисляется в объект, который должен
быть напечатан. Внутри тела \lstinline{PRINT-UNREADABLE-OBJECT} \lstinline{stream-variable}
связывается с потоком, в который вы можете напечатать все, что вам нужно. Всё, что вы
на\-пе\-ча\-тае\-те в этот поток, будет выведено в \lstinline{PRINT-UNREADABLE-OBJECT} и заключено в
стандартный синтаксис для нечитаемых объектов~---
\lstinline!#<>!\footnote{\lstinline{PRINT-UNREADABLE-OBJECT} также выдаёт ошибку, если он
используется в то время, когда переменная контроля печати \lstinline{*PRINT-READABLY*} имеет
истинное значение. Так что метод \lstinline{PRINT-OBJECT}, состоящий только из
\lstinline{PRINT-UNREADABLE-OBJECT}, будет корректно реализовывать поведение
\lstinline{PRINT-OBJECT} с учётом состояния переменной \lstinline{*PRINT-READABLY*}.}\hspace{\footnotenegspace}.
\lstinline{PRINT-UNREADABLE-OBJECT} также позволяет вам включать в вывод тип объекта и признак
уникальности путём указания именованных параметров \lstinline{type} и
\lstinline{identity}. Если они имеют не \lstinline{NIL}-значение, то вывод будет начинаться с имени
класса и заканчиваться признаком уникальности объекта, точно так же, как
это делается стандартным методом \lstinline{PRINT-OBJECT} для объектов, унаследованных от
\lstinline{STANDARD-OBJECT}. Для \lstinline{word-feature} вы, вероятно, захотите определить метод
\lstinline{PRINT-OBJECT}, который будет включать в вывод тип, но не включать признак
уникальности, а также значения слотов \lstinline{word}, \lstinline{ham-count} и
\lstinline{spam-count}. Такой метод может выглядеть вот так:
\begin{myverb}
(defmethod print-object ((object word-feature) stream)
(print-unreadable-object (object stream :type t)
(with-slots (word ham-count spam-count) object
(format stream "~s :hams ~d :spams ~d" word ham-count spam-count))))
\end{myverb}
Теперь вы можете протестировать работу \lstinline{extract-features} в интерпретаторе и увидите,
какие свойства были выделены из сообщения.
\begin{myverb}
SPAM> (extract-features "foo bar baz foo bar")
(#<WORD-FEATURE "baz" :hams 0 :spams 0>
#<WORD-FEATURE "foo" :hams 0 :spams 0>
#<WORD-FEATURE "bar" :hams 0 :spams 0>)
\end{myverb}
\section{Тренируем фильтр}
Теперь, когда у вас имеется способ отслеживания отдельных свойств, вы почти готовы для
реализации функции \lstinline{score}. Но сначала вам нужно написать код, который вы будете
использовать для тренировки фильтра, так что \lstinline{score} будет иметь хоть какие-то данные
для использования. Вы можете определить функцию \lstinline{train}, которая получает некоторый
текст и символ, определяющий, к какому типу относится это сообщение (спам или не спам), и
которая для всех свойств, присутствующих в заданном тесте, увеличивает либо счётчик для
спама, либо счётчик не спама, а также глобальный счётчик обработанных сообщений. Снова
вы можете использовать подход разработки <<сверху вниз>> (top-down) и реализовать эту
функцию в терминах других, ещё не существующих функций.
\begin{myverb}
(defun train (text type)
(dolist (feature (extract-features text))
(increment-count feature type))
(increment-total-count type))
\end{myverb}
Вы уже написали \lstinline{extract-features}, так что следующим шагом будет реализация
\lstinline{increment-count}, которая получает объект \lstinline{word-feature} и тип сообщения, и
увеличивает соответствующий слот данного свойства. Поскольку нет причин думать, что
логика увеличения этих счётчиков будет применяться для различных видов объектов, то вы
можете написать её как обычную функцию\footnote{Если вы позже решите, что вам нужны
разные версии \lstinline{increment-feature} для разных классов, то вы можете переопределить
\lstinline{increment-count} как обобщённую функцию, а текущую реализацию объявить методом,
специализированным для \lstinline{word-feature}.}\hspace{\footnotenegspace}. Поскольку вы определили и \lstinline{ham-count},
и \lstinline{spam-count} с опциями \lstinline{:accessor}, то для увеличения соответствующего слота
вы можете использовать вместе \lstinline{INCF} и функции доступа, созданные при вычислении
\lstinline{DEFCLASS}.
\begin{myverb}
(defun increment-count (feature type)
(ecase type
(ham (incf (ham-count feature)))
(spam (incf (spam-count feature)))))
\end{myverb}
Конструкция \lstinline{ECASE} является вариантом конструкции \lstinline{CASE}, которые обе похожи на
конструкцию \lstinline{case} в языках, произошедших от Algol (переименованный в \lstinline{switch} в
C и его производных). Обе эти конструкции вычисляют свой первый аргумент и затем находят
выражение, чей первый элемент (ключ) имеет то же самое значение в соответствии с логикой
сравнения \lstinline{EQL}. В~нашем случае это означает, что вычисляется переменная
\lstinline{type}, возвращая значение, переданное как второй аргумент функции
\lstinline{increment-count}.
Ключи поиска не вычисляются. Другими словами, значение переменной \lstinline{type} будет
сравниваться с непосредственными объектами (literal objects), считанными процедурой чтения
Lisp как часть выражения \lstinline{ECASE}. В~этой функции это означает, что ключи являются
символами \lstinline{ham} и \lstinline{spam}, а не значениями переменных с именами \lstinline{ham} и
\lstinline{spam}. Так что если \lstinline{increment-count} будет вызвана вот так:
\begin{myverb}
(increment-count some-feature 'ham)
\end{myverb}
\noindent{}то значением \lstinline{type} будет символ \lstinline{ham}, и будет вычислено первое выражение
\lstinline{ECASE}, что приведёт к увеличению счётчика для не спама. С другой стороны, если мы
вызовем эту функцию вот так:
\begin{myverb}
(increment-count some-feature 'spam)
\end{myverb}
\noindent{}то будет выполнено второе выражение, увеличивая счётчик для спама. Заметьте, что при
вызове \lstinline{increment-count} символы \lstinline{ham} и \lstinline{spam} маскируются, иначе это
приведёт к тому, что они будут считаться именами переменных. Но они не маскируются, когда
они используются в \lstinline{ECASE}, поскольку \lstinline{ECASE} не вычисляет ключи
сравнения\footnote{С технической точки зрения, ключи сравнения в выражениях \lstinline{CASE}
или \lstinline{ECASE} рассматриваются как указатели списков, которые могут обозначать списки
объектов. Отдельный объект, не являющийся списком, рассматривается как указатель
списка, состоящий только из одного объекта, в то время как список обозначает сам себя.
Таким образом, каждое выражение может иметь несколько ключей сравнения; \lstinline{CASE} и
\lstinline{ECASE} будут выбирать выражения, чей список ключей содержит нужное значение.
Например, если вы хотите сделать \lstinline{good} синонимом для \lstinline{ham}, а \lstinline{bad}~---
синонимом для \lstinline{spam}, то вы можете записать \lstinline{increment-count} в следующем
виде:
\begin{myverb}
(defun increment-count (feature type)
(ecase type
((ham good) (incf (ham-count feature)))
((spam bad) (incf (spam-count feature)))))
\end{myverb}
}\hspace{\footnotenegspace}.
Буква \lstinline{E} в \lstinline{ECASE} обозначает <<исчерпывающий>> (<<exhaustive>>) или
<<ошибка>> (<<error>>), обозначая, что \lstinline{ECASE} должен выдать ошибку, если сравниваемое
значение не совпадает ни с одним из перечисленных ключей. Обычное выражение \lstinline{CASE}
возвращает \lstinline{NIL}, если не было найдено совпадений.
Для реализации \lstinline{increment-total-count} вам нужно решить, где вы будете хранить
счётчики; в настоящий момент достаточно использовать две глобальные переменные:
\lstinline{*total-spams*} и \lstinline{*total-hams*}.
\begin{myverb}
(defvar *total-spams* 0)
(defvar *total-hams* 0)
(defun increment-total-count (type)
(ecase type
(ham (incf *total-hams*))
(spam (incf *total-spams*))))
\end{myverb}
Вы должны использовать \lstinline{DEFVAR} для определения этих двух переменных по той же
причине, что и для переменной \lstinline{*feature-database*},~--- они будут хранить данные,
которые вы не хотите потерять лишь потому, что вы в процессе разработки заново считали
исходный код. Но вы можете захотеть, чтобы эти переменные также сбрасывались при очистке
\lstinline{*feature-database*}, так что вы должны добавить несколько строк в функцию
\lstinline{clear-database}, как это показано здесь:
\begin{myverb}
(defun clear-database ()
(setf
*feature-database* (make-hash-table :test #'equal)
*total-spams* 0
*total-hams* 0))
\end{myverb}
\section{Пословная статистика}
Сердцем статистического спам-фильтра являются функции, которые вычисляют статистические
вероятности. Математические нюансы\pclfootnote{Говоря о математических нюансах,
закоренелые статистики могут быть оскорблены легкомысленным использованием слова
<<вероятность>> в данной главе. Однако, поскольку даже профессора, которые делятся на
байесианцев и вероятностников, не могут прийти к согласию о значении термина
вероятность, то я не буду об этом беспокоиться. Это книга о программировании, а не о
статистике.} того, как эти вычисления производятся, не являются темой данной книги~---
заинтересованные читатели могут обратиться к нескольким статьям Гэри Робинсона (Gary
Robinson)\pclfootnote{К~статьям Robinson, которые относятся к теме данной главы, можно
отнести <<A Statistical Approach to the Spam Problem>> (опубликованная в Linux Journal и
доступная с \url{http://www.linuxjournal.com/article.php?sid=6467}, а также в более
коротком варианте, в его блоге по адресу
\url{http://radio.weblogs.com/0101454/stories/2002/09/16/spamDetection.html}) и <<Why
Chi? Motivations for the Use of Fisher's Inverse Chi-Square Procedure in Spam
Classification>> (доступная по адресу \url{http://garyrob.blogs.com/whychi93.pdf}).
Другой полезной статьёй может быть <<Handling Redundancy in Email Token Probabilities>>
(доступна с \url{http://garyrob.blogs.com//handlingtokenredundancy94.pdf}). Архив
списка рассылки проекта SpamBayes (\url{http://spambayes.sourceforge.net/}) также
содержит большое количество полезной информации об алгоритмах и подходах к тестированию
спам-фильтров.}. Однако я сосредоточусь на том, как это все реализуется.
Начальной точкой для статистических вычислений является набор измеренных значений~---
частоты, сохранённые в переменных \lstinline{*feature-database*}, \lstinline{*total-spams*} и
\lstinline{*total-hams*}. Предполагая, что набор сообщений, на которых происходила тренировка,
является статистически репрезентативным, мы можем рассматривать полученные частоты как
вероятности появления соответствующих свойств в спаме и не спаме.
Основная идея классификации сообщения заключается в выделении всех свойств, вычислении
вероятностей для отдельных свойств и затем объединении всех вычисленных вероятностей в
значение для всего сообщения. Сообщения с большим количеством <<спамовых>> свойств и малым
количеством <<не спамовых>> будут иметь значения около \lstinline{1}, а сообщения с большим
количеством <<не спамовых>> свойств и малым количеством <<спамовых>> получат значение около
\lstinline{0}.
Сначала вам нужно иметь статистическую функцию, которая вычисляет базовую вероятность, что
сообщение, содержащее данное свойство, является спамом. С нашей точки зрения,
вероятность, что сообщение, содержащее заданное свойство, является спамом, равно отношению
числа спам-сообщений, содержащих данное свойство, к общему количеству сообщений,
содержащих данное свойство. Так что это значение будет вычисляться вот так:
\begin{myverb}
(defun spam-probability (feature)
(with-slots (spam-count ham-count) feature
(/ spam-count (+ spam-count ham-count))))
\end{myverb}
Проблема с вычисляемым этой функцией значением заключается в том, что оно сильно зависит
от полной вероятности, что любое сообщение будет считаться как спам или как не спам.
Например, предположим, что у вас в девять раз больше не спама, чем спама. Тогда полностью
нейтральное свойство в соответствии с данной функцией будет появляться в одном спамовом
сообщении против девяти не спамовых сообщений, давая вам вероятность спама, равную
\lstinline{1/10}.
Но вы более заинтересованы в вероятности, что данное свойство будет появляться в спамовых
сообщениях, независимо от общей вероятности получения спама или не спама. Таким образом,
вам нужно разделить число вхождений в спам на количество спамовых сообщений, на которых
происходила тренировка, и то же самое сделать для числа вхождений в не спамовые сообщения.
Для того чтобы избежать получения ошибок \lstinline{division-by-zero} (деление на ноль), если
либо \lstinline{*total-spams*}, либо \lstinline{*total-hams*} равно нулю, вам необходимо считать
соответствующие частоты равными нулю. (Если общее число спамовых или не спамовых сообщений
равно нулю, то соответствующие счётчики в свойствах также должны быть равны нулю, так что
вы можете рассматривать полученную частоту равной нулю.)
\begin{myverb}
(defun spam-probability (feature)
(with-slots (spam-count ham-count) feature
(let ((spam-frequency (/ spam-count (max 1 *total-spams*)))
(ham-frequency (/ ham-count (max 1 *total-hams*))))
(/ spam-frequency (+ spam-frequency ham-frequency)))))
\end{myverb}
Эта версия страдает от другой проблемы~--- она не обращает внимания на число
проанализированных сообщений. Предположим, что вы производили обучение на 2000 сообщений,
половина спама и половина не спама. Теперь рассмотрим два свойства, которые входят только
в сообщения со спамом. Одно из них входит во все 1000 спамовых сообщений, а второе~---
только в одно из них. В~соответствии с текущей реализацией \lstinline{spam-probability}
появление любого из свойств в сообщении сообщает, что оно является спамом с вероятностью
\lstinline{1}.
Однако все равно возможно, что свойство, которое встречается только в одном, даже
спамовом сообщении, в действительности является нейтральным свойством~--- оно достаточно
редко встречается в спаме и не спаме, всего в одном сообщении из 2000. Если вы проведёте
обучение на следующих двух тысячах сообщений, может быть, что оно встретитсяся ещё раз,
теперь~--- в не спаме, и станет нейтральным, с вероятностью вхождения в спам, равной
\lstinline{.5}.
Так что, наверное, вам хочется вычислять вероятность, которая учитывает количество
случаев, когда встречается каждое свойство (number of data points). В~своих статьях
Робинсон предложил функцию, основанную на байесовском понимании включения наблюдаемых
данных в априорные знания или предположения. Проще говоря, вы вычисляете новую вероятность,
начиная с предполагаемой априорной вероятности и веса, данного этой вероятности, а затем
добавляя новую информацию. Функция, предложенная Робинсоном, выглядит вот так:
\begin{myverb}
(defun bayesian-spam-probability (feature &optional
(assumed-probability 1/2)
(weight 1))
(let ((basic-probability (spam-probability feature))
(data-points (+ (spam-count feature) (ham-count feature))))
(/ (+ (* weight assumed-probability)
(* data-points basic-probability))
(+ weight data-points))))
\end{myverb}
Робинсон предложил значения \lstinline{1/2} для \lstinline{assumed-probability} и \lstinline{1} для
\lstinline{weight}. Используя эти значения, свойство, которое один раз встретилось в спаме и
ни разу в не спаме, будет иметь значение \lstinline{bayesian-spam-probability}, равное
\lstinline{0.75}, а свойство, которое встречается 10 раз в спаме и ни разу в не спаме, будет
иметь значение \lstinline{bayesian-spam-probability}, приблизительно равное \lstinline{0.955}, а то,
которое входит в 1000 спамовых сообщений и ни разу в не спам, будет иметь вероятность,
приблизительно равную \lstinline{0.9995}.
\section{Комбинирование вероятностей}
Теперь, когда вы можете вычислить \lstinline{bayesian-spam-probability} для каждого из свойств
в сообщении, последним шагом будет реализация функции \lstinline{score} для комбинирования
отдельных вероятностей в одно значение в диапазоне между 0 и 1.
Если бы вероятности отдельных свойств были бы независимы, то можно было бы перемножить их
для получения общей вероятности. Но, к сожалению, в действительности они зависимы~---
некоторые свойства появляются вместе с другими, а некоторые никогда не появляются с
другими\pclfootnote{Техники, которые комбинируют не независиммые вероятности таким образом,
как будто они являются независимыми, называются <<наивными байесовскими>>. Оригинальное
предложение Грэхема в действительности было наивным байевским классификатором с
использованием некоторых <<эмпирически выведенных>> констант.}.
Робинсон предложил использовать метод комбинации вероятности, предложенный статистиком
Фишером (R. A. Fisher). Не вдаваясь в детали того, как этот метод работает, он выглядит
следующим образом: сначала вы комбинируете вероятности путём их умножения. Это даёт вам
число, близкое к нулю, если в вашей выборке много свойств с низкими вероятностями. Потом
вы берёте логарифм данного числа и умножаете его на -2. Фишер в 1950~г. показал, что если
отдельные вероятности были независимыми и соответствовали равномерному распределению
между 0 и 1, то результирующее значение будет соответствовать chi-квадрат
распределению. Это значение и удвоенное число вероятностей может быть передано в обратную
функцию chi-квадрат, которая вернёт вероятность, отражающую вероятность получения
значения, которое больше значения, полученного комбинированием того же числа произвольно
выбранных вероятностей. Когда обратная функция chi-квадрат возвращает маленькую
вероятность, это означает, что использовалось неправильное число малых значений (либо
большое число значений с относительно малой вероятностью, либо несколько очень малых
значений) в отдельных вероятностях.
Для использования этой вероятности для определения, является сообщение спамом или нет,
вы должны начать с \textit{нулевой гипотезы} (\textit{null hypothesis}), предполагаемого
предположения, несостоятельность которого вы надеетесь доказать. Нулевая гипотеза
заключается в том, что классифицируемое сообщение в действительности является произвольным
набором свойств. Если это так, то отдельные вероятности (вероятности того, что каждое
свойство может появиться в спаме) также будут произвольными. Так что произвольная
выборка свойств обычно будет содержать некоторые свойства с большой вероятностью появления
в спаме, а другие свойства будут иметь низкую вероятность появления в спаме. Если вы
скомбинируете эти произвольно выбранные вероятности в соответствии с методом Фишера, то вы
должны получить усреднённое значение, для которого обратная функция chi-квадрат сообщит
вероятность успеха. Но если обратная функция chi-квадрат возвращает низкую вероятность,
то это означает, что, к сожалению, вероятности, которые вошли в объединенное значение, были
выбраны непроизвольным образом; использовалось слишком много значений с низкой
вероятностью, чтобы это было случайным. Так что вы можете избавиться от нулевой гипотезы и
вместо этого использовать альтернативную гипотезу, что свойства были взяты из
противоположного примера~--- с несколькими свойствами с высокой вероятностью и большим
числом с низкой вероятностью. Другими словами, это должно быть не спамовое сообщение.
Однако метод Фишера не является симметричным, поскольку обратная функция chi-квадрат
возвращает вероятность, что заданное число вероятность, выбранных произвольным образом,
может быть скомбинировано таким образом, чтобы значение было больше, чем полученное, путём
объединения настоящих вероятностей. Эта асимметрия работает на вас, поскольку когда вы
отвергаете нулевую гипотезу, вы знаете, что существует более правильная гипотеза. Когда вы
комбинируете отдельные вероятности с помощью метода Фишера и вам говорят, что существует
высокая вероятность, что нулевая гипотеза является неправильной (что сообщение не является
произвольным набором слов), то это означает, что сообщение, вероятно, является не спамом.
Возвращённое число не является вероятностью, что сообщение не является спамом, но, по
крайней мере, является хорошим признаком этого. И наоборот, комбинация отдельных
вероятностей не спамовых свойств, по Фишеру, даёт вам признак того, что сообщение обладает
свойствами спама.
Для получения окончательного результата вам необходимо объединить эти два значения в одно
число, которое даст вам рейтинг <<спам/не спам>> в диапазоне от~0~до~1. Метод,
рекомендованный Робинсоном, заключается в добавлении к числу 1/2 половины разницы между
значениями вероятности отнесения к спаму и не спаму, или, другими словами, среднее
значение вероятности отнесения к спаму и единицы минус вероятность отнесения к не спаму.
Это приводит к нужному эффекту, так что если два значения согласованы (высокая вероятность
спама и низкая не спама, или наоборот), то вы получите чёткий индикатор, близкий по
значению к 0 или 1. Но когда оба значения высоки или низки, то вы получите окончательное
значение, приблизительно равное 1/2, что будет рассматриваться как <<неопределённое>>.
Функция \lstinline{score}, которая реализует эту схему, выглядит следующим образом:
\begin{myverb}
(defun score (features)
(let ((spam-probs ()) (ham-probs ()) (number-of-probs 0))
(dolist (feature features)
(unless (untrained-p feature)
(let ((spam-prob (float (bayesian-spam-probability feature) 0.0d0)))
(push spam-prob spam-probs)
(push (- 1.0d0 spam-prob) ham-probs)
(incf number-of-probs))))
(let ((h (- 1 (fisher spam-probs number-of-probs)))
(s (- 1 (fisher ham-probs number-of-probs))))
(/ (+ (- 1 h) s) 2.0d0))))
\end{myverb}
Вы берёте список свойств и выполняете цикл, строя два списка вероятностей~--- один список
вероятностей, что сообщение, содержащее каждое из свойств, является спамом, и другой
список, что сообщение не является спамом. Для оптимизации вы также можете подсчитать
количество вероятностей и передать это число функции \lstinline{fisher}, чтобы избежать
подсчёта в самой функции \lstinline{fisher}. Число, возвращённое функцией \lstinline{fisher}, будет
маленьким, если отдельные вероятности содержат слишком много малых вероятностей, чтобы
рассматриваться как произвольный текст. Так что малое число Фишера для <<спамовых>>
вероятностей означает, что там содержится много не спамовых свойств; вычитая число Фишера
из 1, вы получаете вероятность того, что сообщение не является спамом. Соответственно,
вычитая число Фишера для <<не спамовых>> вероятностей из 1, вы получаете вероятность того, что
сообщение является спамом. Комбинируя эти два числа, вы получаете общую вероятность
принадлежности к спаму в диапазоне между 0 и 1.
Внутри цикла вы можете использовать функцию \lstinline{untrained-p} для пропуска тех свойств,
которые не встречались в процессе обучения. Эти свойства будут иметь счётчики спама и не
спама, равные нулю. Функция \lstinline{untrained-p} является тривиальной.
\begin{myverb}
(defun untrained-p (feature)
(with-slots (spam-count ham-count) feature
(and (zerop spam-count) (zerop ham-count))))
\end{myverb}
Новой функцией является \lstinline{fisher}. Если предположить, что вы уже имеете функцию
\lstinline{inverse-chi-square}, \lstinline{fisher} является достаточно простой.
\begin{myverb}
(defun fisher (probs number-of-probs)
"The Fisher computation described by Robinson."
(inverse-chi-square
(* -2 (log (reduce #'* probs)))
(* 2 number-of-probs)))
\end{myverb}
К сожалению, существует небольшая проблема с этой прямолинейной реализацией. Хотя
использование \lstinline{REDUCE} является кратким и идиоматичным способом умножения списка
чисел, но в этом конкретном приложении существует опасность, что произведение будет
слишком маленьким, чтобы быть представленным как число с плавающей запятой. В~этом случае
результат окажется нулём. И если произведение
вероятностей будет равно нулю, то всё будет напрасно, поскольку вызов \lstinline{LOG} для нуля
либо выдаст ошибку, либо, в некоторых реализациях, приведёт к получению специального
значения <<отрицательная бесконечность>>, которое приведёт к тому, что все последующие
вычисления станут бессмысленными. Это очень нежелательно, поскольку метод Фишера очень
чувствителен к малым значениям (близким к нулю) и при умножении часто возникает
вероятность потери значимых разрядов.
К счастью, для того чтобы избежать данной проблемы, вы можете использовать немного знаний
из школьной математики. Вспомните, что логарифм произведения является суммой логарифмов
соответствующих членов. Так что вместо умножения вероятностей и затем вычисления
логарифма вы можете использовать сумму логарифмов вероятностей. А поскольку
\lstinline{REDUCE} может принимать именованный параметр \lstinline{:key}, то вы можете использовать
ее для проведения всех вычислений. Так что вместо этого кода:
\begin{myverb}
(log (reduce #'* probs))
\end{myverb}
\noindent{}напишите вот этот:
\begin{myverb}
(reduce #'+ probs :key #'log)
\end{myverb}
\section{Обратная функция chi-квадрат}
Реализация функции \lstinline{inverse-chi-square}, приведённая в данном разделе, является
практически прямым переводом на Lisp версии функции, написанной Робинсоном на Python.
Точное математическое значение этой функции здесь не рассматривается, но вы можете
получить примерное знание о том, что она делает, путём размышления о том, как значения,
которые вы передаёте функции \lstinline{fisher}, будут влиять на результат: большее количество
малых значений, переданных \lstinline{fisher}, приведёт к меньшему значению произведения
вероятностей. Логарифм от малого числа приведёт к получению отрицательного числа с
большим абсолютным значением, которое после умножения на минус 2 станет ещё большим
положительным числом. Таким образом, чем больше будет вероятностей с малым значением
передано \lstinline{fisher}, тем большее значение будет передано
\lstinline{inverse-chi-square}. Конечно, число используемых вероятностей также влияет на
значение, переданное \lstinline{inverse-chi-square}. Поскольку вероятности, по определению,
имеют значение, меньшее или равное 1, то большее количество вероятностей, входящих в
произведение, будет приводить к меньшему значению вероятности и, соответственно, к
большему числу, переданному функции \lstinline{inverse-chi-square}. Так что функция
\lstinline{inverse-chi-square} должна возвращать низкую вероятность в тех случаях, когда число
Фишера является ненормально большим для числа вероятностей, которые входят в него.
Следующая функция вот что:
\begin{myverb}
(defun inverse-chi-square (value degrees-of-freedom)
(assert (evenp degrees-of-freedom))
(min
(loop with m = (/ value 2)
for i below (/ degrees-of-freedom 2)
for prob = (exp (- m)) then (* prob (/ m i))
summing prob)
1.0))
\end{myverb}
Возвращаясь к главе~\ref{ch:10}, вспоминаем, что функция \lstinline{EXP} возводит число
\lstinline{e} (основание натурального алгоритма) в заданную степень. Таким образом, чем больше
используемое значение, тем меньше будет начальное значение \lstinline{prob}. Но это начальное
значение затем будет выравнено вверх для каждой из степеней свободы, пока \lstinline{m} больше,
чем число степеней свободы. Поскольку значение, возвращаемое функцией
\lstinline{inverse-chi-square}, рассматривается как другая вероятность, то важно ограничить
значение возвращаемое \lstinline{MIN}, так как ошибки округления при умножении и возведении в
степень могут привести к тому, что \lstinline{LOOP} вернёт сумму, которая больше 1.
\section{Тренируем фильтр}
Поскольку вы написали \lstinline{classify} и \lstinline{train} таким образом, чтобы они принимали
аргумент-строку, то вы можете работать с ними интерактивно. Если вы ещё это не сделали,
то вы должны переключиться на пакет, в рамках которого вы писали код, путём вычисления
формы \lstinline{IN-PACKAGE} в строке ввода или используя сокращённую форму, реализованную в
SLIME,~--- \lstinline{change-package}. Для использования этой возможности SLIME наберите
запятую, и затем наберите имя в строке ввода. Нажатие \lstinline{Tab} при наборе имени пакета
приведёт к автоматическому дополнению имени, основываясь на именах пакетов, которые знает
Lisp. Теперь вы можете выполнить любую функцию, которая является частью спам-фильтра.
Сначала вы должны убедиться, что база данных свойств пуста.
\begin{myverb}
SPAM> (clear-database)
\end{myverb}
Теперь вы можете тренировать фильтр с помощью конкретного текста.
\begin{myverb}
SPAM> (train "Make money fast" 'spam)
\end{myverb}
И посмотреть, что думает по этому поводу функция классификации.
\begin{myverb}
SPAM> (classify "Make money fast")
SPAM
SPAM> (classify "Want to go to the movies?")
UNSURE
\end{myverb}
Хотя вам нужны лишь результаты классификации, было бы хорошо видеть и вычисленную оценку.
Самым простым способом получения обоих значений, не затрагивая остального кода, будет
изменение функции \lstinline{classification} таким образом, чтобы она возвращала несколько
значений.
\begin{myverb}
(defun classification (score)
(values
(cond
((<= score *max-ham-score*) 'ham)
((>= score *min-spam-score*) 'spam)
(t 'unsure))
score))
\end{myverb}
Вы можете сделать это изменение и затем перекомпилировать лишь одну функцию. Поскольку
функция \lstinline{classify} возвращает то, что вернула функция \lstinline{classification}, то она
также будет возвращать два значения. Но поскольку основное возвращаемое значение не
затрагивается, то пользователи данной функции, ожидающие лишь одного значения, никак не
будут затронуты данными изменениями. Теперь, когда вы будете тестировать \lstinline{classify},
вы сможете увидеть, какое значение было передано функции \lstinline{classification}.
\begin{myverb}
SPAM> (classify "Make money fast")
SPAM
0.863677101854273D0
SPAM> (classify "Want to go to the movies?")
UNSURE
0.5D0
\end{myverb}
И теперь вы сможете увидеть, что произойдёт, если потренируете фильтр с некоторым
количеством не спамового текста.
\begin{myverb}
SPAM> (train "Do you have any money for the movies?" 'ham)
1
SPAM> (classify "Make money fast")
SPAM
0.7685351219857626D0
\end{myverb}
Этот текст все равно считается спамом, но с меньшей оценкой, поскольку слово \lstinline{money}
входило в не спамовый текст.
\begin{myverb}
SPAM> (classify "Want to go to the movies?")
HAM
0.17482223132078922D0
\end{myverb}
А сейчас этот текст правильно распознаётся как не спам из-за наличия в нем слова
\lstinline{movies}, которое теперь считается не спамовым свойством.
Однако вряд ли вы хотите тренировать фильтр вручную. Что вам действительно нужно~---
простой способ указать на пачку файлов и провести обучение фильтра на них. И если вы
хотите проверить, насколько хорошо фильтр работает, вы можете использовать его для
классификации другой пачки файлов заранее известного типа и проанализировать результаты.
Так что последним кусочком кода, который вы напишете в данной главе, будет набор тестов
для фильтра, которые будут тестировать его на кучке заданных сообщений известных типов,
используя часть сообщений для обучения, а затем измеряя точность, с которой фильтр
классифицирует оставшуюся часть.
\section{Тестируем фильтр}
Для тестирования фильтра вам нужны наборы сообщений известных типов. Вы можете
использовать сообщения из вашего почтового ящика или можете взять один из наборов
сообщений, размещённых в Интернете. Например, набор сообщений
SpamAssassin\pclfootnote{Несколько наборов спамовых сообщений, включая набор сообщений от
SpamAssassin, можно найти по адресу
\url{http://nexp.cs.pdx.edu/~psam/cgi-bin/view/PSAM/CorpusSets}.} содержит несколько
тысяч сообщений, классифицированных вручную на спам, явный не спам и не спам, который
тяжело отличить от спама. Чтобы сделать пользование тестами более простым, вы можете
определить вспомогательные функции, которые управляются массивом пар имя файла/тип. Вы
можете определить функцию, которая принимает имя файла и тип и добавляет их в набор
следующим образом:
\begin{myverb}
(defun add-file-to-corpus (filename type corpus)
(vector-push-extend (list filename type) corpus))
\end{myverb}
Значение \lstinline{corpus} (набор) должно быть изменяемым вектором с указателем заполнения.
Например, вы можете создать новый набор следующим образом:
\begin{myverb}
(defparameter *corpus* (make-array 1000 :adjustable t :fill-pointer 0))
\end{myverb}
Если у вас спам и не спам уже находятся в разных каталогах, то вы можете захотеть добавить
все файлы в каталоге, используя один и тот же тип. Вот функция, которая использует функцию
\lstinline{list-directory} из главы~\ref{ch:15}, чтобы выполнить эту задачу:
\begin{myverb}
(defun add-directory-to-corpus (dir type corpus)
(dolist (filename (list-directory dir))
(add-file-to-corpus filename type corpus)))
\end{myverb}
Например, предположим, что у вас есть каталог \lstinline{mail}, содержащий два подкаталога,
\lstinline{spam} и \lstinline{ham}, каждый содержащий сообщения соответствующего типа (спам и
не спам); вы можете добавить все файлы из этих двух каталогов к набору, хранящемуся в
\lstinline{*corpus*}, используя следующие команды:
\begin{myverb}
SPAM> (add-directory-to-corpus "mail/spam/" 'spam *corpus*)
NIL
SPAM> (add-directory-to-corpus "mail/ham/" 'ham *corpus*)
NIL
\end{myverb}
Теперь вам нужна функция для проверки классификатора. Основная стратегия работы будет
заключаться в выборе произвольной части набора сообщений для обучения фильтра, а затем
тестирования работы путём классификации оставшейся части набора, сравнивая классификацию,
возвращённую нашей функцией, с известными результатами. Главной вещью, которую вы захотите
узнать, является то, насколько правильно работает классификатор~--- сколько процентов
сообщений классифицировано правильно. Но вы, вероятно, также будете заинтересованы в
информации о том, какие сообщения были неправильно классифицированы и в чем заключается
ошибка~--- больше неправильных пропусков или фальшивых срабатываний? Для того чтобы
сделать более простым выполнение различных видов анализа поведения классификатора, вы
должны определить функции тестирования для построения списка вычисленных значений,
которые вы затем сможете проанализировать, как захотите.
Основная функция тестирования будет выглядеть следующим образом:
\begin{myverb}
(defun test-classifier (corpus testing-fraction)
(clear-database)
(let* ((shuffled (shuffle-vector corpus))
(size (length corpus))
(train-on (floor (* size (- 1 testing-fraction)))))
(train-from-corpus shuffled :start 0 :end train-on)
(test-from-corpus shuffled :start train-on)))
\end{myverb}
Эта функция начинает работу с очистки базы свойств\footnote{Если вы хотите проводить
тестирование, не затрагивая существующую базу свойств, то вы должны связать
\lstinline{*feature-database*}, \lstinline{*total-spams*} и \lstinline{*total-hams*}, используя
\lstinline{LET}, но вы не будете иметь возможности просмотра этих баз после окончания работы
данной функции, до тех пор, пока вы не будете возвращать эти значения как результат
работы функции.}\hspace{\footnotenegspace}. Затем она перемешивает набор писем, используя функцию, которую мы
реализуем далее, и определяет, на основе параметра \lstinline{testing-fraction}, сколько
значений мы будем использовать для обучения и сколько мы оставим для тестирования. Две
вспомогательные функции \lstinline{train-from-corpus} и \lstinline{test-from-corpus} будут
принимать именованные параметры \lstinline{:start} и \lstinline{:end}, что позволит работать над
частью заданного набора сообщений.
Функция \lstinline{train-from-corpus} достаточно проста~--- это цикл по соответствующей части
набора сообщений с использованием \lstinline{DESTRUCTURING-BIND} для выделения имени файла и
типа из списка, находящегося в каждом элементе, и затем передача данных параметров для
обучения. Поскольку некоторые почтовые сообщения, особенно такие, которые имеют вложения,
имеют достаточно большой размер, то вы должны ограничить количество знаков, которое будет
выделяться из сообщения. Функция будет получать текст с помощью функции
\lstinline{start-of-file}, которую мы реализуем далее, которая будет принимать в качестве
параметров имя файла и максимальное количество возвращаемых знаков.
\lstinline{train-from-corpus} выглядит примерно так:
\begin{myverb}
(defparameter *max-chars* (* 10 1024))
(defun train-from-corpus (corpus &key (start 0) end)
(loop for idx from start below (or end (length corpus)) do
(destructuring-bind (file type) (aref corpus idx)
(train (start-of-file file *max-chars*) type))))
\end{myverb}
Функция \lstinline{test-from-corpus} выглядит аналогично, за тем исключением, что вы захотите
возвращать список, содержащий результаты каждой из операции классификации, так что вы в
последующем сможете проанализировать эти результаты. Так что вы должны захватывать и
определённый тип сообщения, и вычисленное значение, возвращённые функцией \lstinline{classify},
и собирать эти данные в список, состоящий из имени файла, известного типа, типа,
возвращённого функцией \lstinline{classify}, и вычисленного значения. Чтобы сделать результаты
более понятными для человека, вы можете включить в список именованные параметры,
описывающие соответствующие значения.
\begin{myverb}
(defun test-from-corpus (corpus &key (start 0) end)
(loop for idx from start below (or end (length corpus)) collect
(destructuring-bind (file type) (aref corpus idx)
(multiple-value-bind (classification score)
(classify (start-of-file file *max-chars*))
(list
:file file
:type type
:classification classification
:score score)))))
\end{myverb}
\section{Набор вспомогательных функций}
Для окончания реализации функции \lstinline{test-classifier} вам необходимо написать две
вспомогательные функции, которые напрямую не относятся к фильтрации спама,~---
\lstinline{shuffle-vector} и \lstinline{start-of-file}.
Простым и эффективным способом реализации \lstinline{shuffle-vector} будет использование
алгоритма Фишера---Ятеса (Fisher---Yates)\pclfootnote{Этот алгоритм назван в честь Фишера,
который предложил метод, используемый для комбинации вероятностей, и Франка Ятеса, его
соавтора по книге <<Statistical Tables for Biological, Agricultural and Medical
Research>> (Oliver \& Boyd, 1938), в которой, согласно высказыванию Кнута, они впервые
опубликовали описание данного алгоритма.}. Вы можете начать с реализации функции
\lstinline{nshuffle-vector}, которая перемешивает вектор, используя то же самое хранилище.
Имя функции соответствует тому же самому соглашению по именованию деструктивных функций,
таких как \lstinline{NCONC} и \lstinline{NREVERSE}. Она выглядит следующим образом:
\begin{myverb}
(defun nshuffle-vector (vector)
(loop for idx downfrom (1- (length vector)) to 1
for other = (random (1+ idx))
do (unless (= idx other)
(rotatef (aref vector idx) (aref vector other))))
vector)
\end{myverb}
Недеструктивная версия просто делает копию оригинального вектора и передаёт его в
деструктивную версию.
\begin{myverb}
(defun shuffle-vector (vector)
(nshuffle-vector (copy-seq vector)))
\end{myverb}
Другая вспомогательная функция~--- \lstinline{start-of-file},~--- также достаточно проста.
Наиболее эффективным способом считывания содержимого файла в память будет создание
массива соответствующего размера и использования функции \lstinline{READ-SEQUENCE} для его
заполнения. Так что вы можете создать массив знаков с размером, равным размеру файла, или
максимальному количеству считываемых знаков, в зависимости от того, какое из значений
будет меньше. К сожалению, как я упоминал в главе~\ref{ch:14}, функция \lstinline{FILE-LENGTH}
не особенно хорошо работает для текстовых потоков, поскольку количество знаков в файле
может зависеть от используемой кодировки знаков и конкретного текста. В~наихудшем случае
единственным способом точного определения количества знаков в файле является считывание
всего файла. Таким образом, не ясно, что вернёт \lstinline{FILE-LENGTH} для текстового потока; в
большинстве реализаций \lstinline{FILE-LENGTH} всегда возвращает число байт в файле, которое
может быть больше, чем число знаков, которое может быть прочитано из файла.
Однако \lstinline{READ-SEQUENCE} возвращает количество прочитанных знаков. Так что вы можете
попробовать считать количество знаков, определённое с помощью \lstinline{FILE-LENGTH}, и вернуть
подстроку, если количество считанных знаков было меньше.
\begin{myverb}
(defun start-of-file (file max-chars)
(with-open-file (in file)
(let* ((length (min (file-length in) max-chars))
(text (make-string length))
(read (read-sequence text in)))
(if (< read length)
(subseq text 0 read)
text))))
\end{myverb}
\section{Анализ результатов}
Теперь вы готовы к написанию кода для анализа результатов, сгенерированных
\lstinline{test-classifier}. Мы должны вспомнить, что \lstinline{test-classifier} возвращает
список, возвращённый \lstinline{test-from-corpus}, в котором каждый элемент является списком
свойств (plist), описывающим результаты классификации одного файла. Этот список содержит
имя файла, известный тип, результат классификации и вычисленное значение, возвращённое
функцией \lstinline{classify}. Первой частью нашего аналитического кода, который вы должны
написать, является функция, которая будет возвращать признак того, была ли классификация
правильной или нет (пропущенный спам или не спам и т.~п.). Вы можете использовать
\lstinline{DESTRUCTURING-BIND} для получения элементов \lstinline{:type} и \lstinline{:classification}
списка результатов (используя опцию \lstinline!&allow-other-keys! для того, чтобы
\lstinline{DESTRUCTURING-BIND} игнорировал другие пары имя-значение), а затем используя
вложенные выражения \lstinline{ECASE} для преобразования отдельных сочетаний в конкретный
символ.
\begin{myverb}
(defun result-type (result)
(destructuring-bind (&key type classification &allow-other-keys) result
(ecase type
(ham
(ecase classification
(ham 'correct)
(spam 'false-positive)
(unsure 'missed-ham)))
(spam