Работая при университете, недавно столкнулся с интересной задачей, связанной с поиском академического плагиата во внутренней системе контестов по программированию, ставшей основой для преподавания основ алгоритмики студентам первого курса. Позже, начав поиск русскоязычных материалов, я был очень расстроен отсутствием каких-либо обобщающих статей на эту тему, поэтому незамедлительно решил восполнить этот пробел и рассказать о своем опыте создания модуля антиплагиата.

Введение

Академический плагиат — это очень распространенное явление, особенно если дать студентам много свободы в создании своих программ. По некоторым источникам, больше половины студентов технических специальностей хотя бы раз нечестно сдавали задания по программированию. Разумеется, с такой проблемой столкнулся и мой институт. Преподаватели и их ассистенты для большей части решений, прошедших проверку в системе автоматического тестирования, вынуждены открывать последние сданные исходники других студентов в рамках этой же задачи с целью выявить потенциальное наличие в них плагиата. Когда на потоке мало студентов и преподаватель условно знает "кто с кем дружит", то эта проверка не занимает длительного времени, но если на первый курс приходит более сотни студентов, то данную проблему быстро решить уже не получится. К счастью, этот процесс можно автоматизировать.

Начнём с того, что уже существует великое множество открытых и проприетарных решений, так или иначе решающих данную задачу: из опенсурсных популярны, например, MOSS и JPlag. Однако ни одно из них не удовлетворяет нашим требованиям: одни из них работают с лимитированным количеством языков программирования, другие сканируют половину интернета на наличие похожестей частей кода и прочее. Вот же краткий список требований к модулю, составленный нашей командой разработки:

  1. Поддержка языков программирования платформы для контестов (C++, Python, Java, C# и Pascal);

  2. Скоринг решений учеников, прошедших автоматическую проверку, в рамках одной задачи (то есть должна быть реализована push- или pull-модель взаимодействия с платформой);

  3. Выдача преподавателю списка решений, наиболее похожих на целевое, для дальнейшего вынесения собственного вердикта о наличии списывания.

Как можно заметить, система просто должна автоматически выполнять ту работу, которую на данный момент вручную проделывает преподаватель. Остаётся лишь главный вопрос: как будет проходить непосредственно сам скоринг решений? И это уже детали реализации, которые мы сейчас разберем.

Типы заимствований исходного кода

Для начала стоит определиться, а что вообще считается академическим плагиатом? Существует прекрасная монография под названием A Survey on Software Clone Detection Research*, написанная ещё в 2007 году, и в дальнейшем повествовании я буду практически полностью опираться на неё, потому что она покрывает все необходимые нам темы. В ней описывается 4 основных типа заимствования исходного кода:

  1. Программный код скопирован без каких-либо изменений (иными словами, идентичен оригиналу с точностью до комментариев);

  2. Код скопирован с "косметическими" заменами идентификаторов (имен функций и переменных, типов данных, строковых литералов);

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

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

Качественная система антиплагиата должна хорошо определять первые три типа плагиата из классификации выше. Заимствования четвертого типа крайне затруднительны для выявления (см. статью) и часто представляют собой копирование самого алгоритма, а не частей кода оригинальной программы, что не является плагиатом как таковым.

Другой проблемой является то, что любой алгоритм антиплагиата будет давать ложноположительный результат на коротких и простых задачах (например, алгоритм Евклида или сортировка пузырьком), ведь если студент в здравом уме, то он явно не напишет что-то оригинальное и отличное от других решений. С этим недоразумением можно справиться несколькими способами: либо не запускать алгоритм на коротких задачах в принципе, либо использовать какой-то алгоритм "с пенальти" на короткие программы, либо вообще не решать данную проблему: преподаватель сам понимает, что задача простая, поэтому, вероятнее всего, сразу проставит ей нужную оценку.

Методы сравнения исходных кодов

В литературе встречается множество способов сравнения программ. Вот пять наиболее универсальных и распространенных подходов, которые в дальнейшем были успешно применены в нашей системе:

  1. Text-based метод заключается в сравнении текстовых представлений программ на основе некоторой метрики, например, расстояния Левенштейна или Джаро-Виклера. Данный способ отличается своей скоростью и простотой, но результативность быстро снижается даже на простейших "косметических" модификациях.

  2. Token-based метод основан на преобразовании ключевых слов программы в последовательность лексем языка программирования (далее просто токенов). Полученные токены сравниваются любым доступным способом. Этот алгоритм гораздо точнее предыдущего, так как игнорирует все изменения второго типа, однако он всё ещё не справляется со структурными изменениями.

  3. Metric-based метод определяет на полученных в предыдущем методе токенах некоторые метрики (например, кол-во используемых циклов и условных конструкций), а далее считает схожесть программ на основе количества совпадающих метрик. В целом, алгоритм отлично дополняет другие методы антиплагиата, однако он часто даёт ошибочный результат (в частности, метод успешно отрабатывает на программах с переименованием переменных и изменением условий и циклов, но моментально деградирует при добавлении в код NO-OPов по типу объявления пустого цикла или инициализации неиспользуемых значений).

  4. Tree-based подход требует представления кода в виде абстрактного синтаксического дерева (далее просто AST, Abstract Syntax Tree). В таком формате программы сравниваются любым доступным способом: от "наивного" подсчёта совпадающих узлов до продвинутых методов на основе расстояния Zhang-Shasha. Часто данный способ считается наиболее эффективным, но в то же время его сложнее внедрить: помимо построения самих AST, нужна реализация алгоритма для их сравнения.

  5. Binary-based метод требует прохождения кодом этапа компиляции (или интерпретации). Полученное бинарное представление (ассемблерный листинг или байткод) служит основой для дальнейшего сравнения программ. Помимо высокой точности на заимствованиях 1-2 типа (мелкие структурные изменения часто оптимизируются компилятором, а разные реализации одного функционала, дают одинаковый ассемблерный листинг), алгоритм может дать неплохой результат на модификациях третьего типа, но в то же время может и легко ломаться. Было выявлено, что банальное изменение типов данные может уронить коэф. схожести на несколько десятков процентов.

Конечно же, это не все существующие алгоритмы. Есть множественные модификации каждого из методов, так или иначе повышающие эффективность скоринга (в этом плане может быть интересен метод Шинглов), а также совершенно другие подходы, которые могут основываться, например, на поведении программы во время исполнения (behavior-based метод) ини на графе её зависимостей (PDG-based метод). Кстати, упомянутая выше монография наполнена сравнительными таблицами как самих методов, так и их модификаций, что очень интересно для изучения.

В дополнении приведу ещё один интересный подход: если вы знаете специфику действий своего пользователя (например, с каким среднем промежутком времени сдаются задачи на платформе или сколько попыток нужно потратить для успешной сдачи), то сможете выделить ряд метрик, на основе которых можно будет определять подозрительность поведения конкретного пользователя. Например, если студент сдаёт к ряду несколько задач и они проходят все тесты, то это отличный триггер на жульничество с его стороны. К сожалению, такой подход не является универсальным, а результат зависит от выделенных для использования метрик.

Очевидно, что каждый из рассмотренных выше методов имеет свои преимущества и недостатки и некоторые из них могут быть эффективнее на определенных модификациях условного кода. Так что же мешает нам использовать лучшие стороны каждого из них? Алгоритм антиплагиата на основе нескольких подходов называется гибридным, и данный подход действительно значительнее устойчивее каждого из методов по отдельности. Для вынесения итогового вердикта можно использовать либо максимальный, либо средний скоринг для каждого из примененных алгоритмов. Второй вариант как раз таки и был применен в нашей дальнейшей реализации модуля антиплагиата.

Разработка алгоритма антиплагиата

Гибридный алгоритм антиплагита может быть реализован следующими простыми шагами:

  1. Форматирование и нормализация (в т.ч. удаление комментариев) исходного кода для повышения точности текстовых сравнений;

  2. Получение промежуточных представлений программы, а именно её токенов, AST и ассемблерного листинга;

  3. Непосредственно сравнение полученных форматов вышеперечисленными методами;

  4. Вынесение вердикта на основе полученных коэффициентов схожести;

  5. (Опционально) Вычисление дополнительных полезных статистик, например, дисперсии и квантилей распределения этих коэффициентов для всей выборки решений.

С первым шагом особых проблем не возникает: нормализация может быть выполнена средствами языка программирования (в т.ч. регулярными выражениями), а для форматирования можно использовать сторонний инструмент (обратите внимание на clang-format), однако данный шаг опционален. Но вот с получением промежуточных представлений программы, а именно списка лексем и AST, могут возникнуть проблемы. Конечно, никто не мешает вам написать собственный лексер и парсер для каждой необходимой вам грамматики (так, например, сделали разработчики JPlag), но если у вас сжатые сроки и ленивые руки, то есть одно менее элегантное, но эффективное решение: использование парсера с открытым исходным кодом ANTLR.

ANTLR отлично зарекомендовал себя среди разработчиков различных грамматик и утилит для работы с ними, а сообщество уже добавило поддержку более двух сотен популярных языков программирования. Данный парсер сможет без проблем превратить вашу программу в список токенов, а также построить (и визуализировать!) абстрактное синтаксическое дерево на их основе. Небольшой платой за готовый синтаксический разбор будет долгое обращение к самой утилите (занимает порядка секунды для небольшой программы).

Итак, мы имеем все промежуточные представления на руках: ассемблерный листинг (или байткод) нам выдал компилятор/интерператор, токены и AST мы получили из ANTLR, а текстовое представление мы имеем по умолчанию. Исходники мы даже немного улучшили посредством нормализации, нечто подобное желательно сделать и с бинарным представлением, а именно удалить offsetы пямяти, оставив только исполняемые инструкции (тут достаточно задействовать утилиту grep).

Дальше нас ждет третий шаг, а именно скоринг полученных представлений. Со сравнением текстов и бинарного формата проблем не выходит: мы берем любую доступную метрику и сравниваем два набора символов. По моим наблюдениям, лучше всего показала себя комбинация расстояния Дамерау-Левенштейша и LCS. Со скорингом AST возникают некоторые трудности: если нужно это сделать эффективно, то в любом случае придётся считать редакторское расстояние для деревьев (кстати, есть одна каноническая реализация алгоритма на питоне), а если мы хотим сделать это быстро, то сойдёт и наивная стратегия, основывающаяся либо на прохождении по ветвям дерева до первого несовпадения элементов, либо на подсчете отношения количества совпавших узлов ко всем узлам (данная стратегия и легла в основу нашей реализации).

Полученный результат скоринга со всех методов, лежащий в промежутке от 0 до 1, фактически показывает, насколько два решения похожи друг на друга по некоторой метрике. Это можно усреднить и делать предположение по полученному значению: если коэффициент схожести программ выше 95%, то они практически идентичны. Дополнительные статистики, подсчитанные по выборке сданных на задачу решений, могут быть тоже очень полезны. К примеру, медианное значение схожести близкое к максимальному и низкая дисперсия говорят о простоте задачи — в этом случае можно уверенно давать отрицательный вердикт на наличие в решении заимствований.

Полученные результаты

Для тестирования полученного гибридного алгоритма была использована небольшая синтетическая выборка, включающая некоторые модификации программ на языках Java и C++ (~750 символов) для всех четырех типов заимствований. И вот как показали себя примененные методы по отдельности (коэф. схожести усреднен и округлен):

Модификация / процент схожести программ

Text-based

Token-based

Metric-based

Tree-based

Binary-based

Изменение комментариев (тип 1)

100%

100%

100%

100%

99%

Реформатирование кода (тип 2)

97%

97%

92%

93%

100%

Добавление неиспользуемых зависимостей (тип 2)

89%

92%

85%

91%

99%

Переименование идентификаторов (тип 2)

85%

100%

100%

96%

98%

Изменение типов данных (тип 2-3)

97%

99%

100%

98%

85%

Изменение порядка исполнения кода (тип 3)

78%

86%

95%

91%

81%

Выделение частей кода в функции (тип 3-4)

65%

74%

72%

51%

67%

В прошлой таблице важны не цифры, а сама динамика поведения различных методов. По результатам итогового тестирования, алгоритм в среднем выдавал следующие проценты схожести программ с заимствованиями различных типов (в зависимости от силы изменений):

  1. Заимствования типа 1: 99-100%

  2. Заимствования типа 2: 94-98%

  3. Заимствования типа 3: 87-96%

  4. Заимствования типа 4: 65-82%

Кэширование промежуточных представлений

Основным узким местом стало получение промежуточных форматов данных: обращение к ANTLR для каждой программы занимало примерно секунду, в то время как алгоритмическая часть работала почти моментально. Решением проблемы стало использование кэширования представлений посредством общеизвестного Redis, позволившее получать результат антиплагиата примерно за 1-2 секунды для выборки из 50 решений с "прогретым" кэшем. Это примерно соответствует среднему количеству решений для задачи на нашей платформе в пределах одной группы студентов.

Существующая проблема "холодного старта" решается использованием очередей и брокера сообщений, например, RabbitMQ: в рамках контестов можно спокойно дать алгоритму возможность поработать пару минут на первых решениях, чтобы затем доставать нужные представления из кэша на последующих программах. Скорость в нашем случае не имеет значение, ведь сданная задача может быть проверена как через пару минут, так и к концу месяца: всё зависит от загрузки преподавателя.

Заключение

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

Стоит отметить огромный потенциал использования ANTLR: он предоставляет все необходимые промежуточные представления программ, для сравнения которых можно внедрить любую желаемую стратегию, а также позволяет без труда можно добавить поддержку многих языков программирования (спасибо за то большому комьюнити). Важно заметить, что использование кэширования обращений к ANTLR может ускорить процессинг исходников в десятки раз!

Цель данной статьи: дать небольшой обзор популярных методик, а также поделиться своим опытом внедрения модуля антиплагиата. Искренне надеюсь, что я этого достиг. Не вижу смысла оставлять здесь большой список литературы: всем заинтересованным в данном направлении я настоятельно советую обратиться к упомянутой выше монографии, а прочие источники я добавил в местах использования.

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

Комментарии (17)


  1. Oplkill
    17.10.2021 14:24
    +2

    Статье не хватает примеров, подобно показанному на превью, а также было бы отличным, если устроить небольшое соревнование среди, допустим первых 10 человек, суть которого заключается в том, что дан код выполняющий конкретную задачу, а людям на месте "школьника/студента который хочет обойти антиплагиат, использовав этот код". Условием можно поставить то, что написание кода с нуля не разрешено, необходимо изменить имеющийся код так, чтобы обойти проверку или получить наименьшее число баллов плагиата.

    Что думаете по этому поводу?


    1. forthuser
      18.10.2021 11:30

      Есть ресурс с решениями каких то задач/алгоритмов на разных языках rosettacode.org (в том числе и языков предложенных в статье для анализа на плагиат)
      На самом ресурсе, при решении этих задач, допустимо использовать решения из других языков (желательно с указанием чья взята основа)

      Вопрос:
      Насколько предложенное решение антиплагиата кода отработает на выборке задач с этого ресурса? (был бы интересный анализ)

      P.S. И, как понимаю, для автора статьи, важнее чтобы не было «корреляций» предложенных решений в обучаемой группе и желательно без совпадений с другими группами уже прошедшими этот курс ранее (и, возможно, выявления «купленных» решений).
      Тогда программное решение должно ещё как то пополняться актуальной базой данных плагиата кода и его источников. :)


      1. somnoynadno Автор
        18.10.2021 11:38
        +1

        Ой, а я забыл упомнянуть тот момент, что мы запускаем скоринг только для решений на одном языке программирования (например, только на шарпах).

        Дело в том, что если студент переписал чужое решение на другой язык программирования (например, с шарпа на плюсы), то это уже не должно считаться плагиатом как таковым, ведь он как-то понял структуру и логику работы программы, чтобы перенести её в совершенно другое окружение.

        Вы действительно правы, что антиплагиат должен проверять и решения других групп, ранее усвоивших данный предмет. У нас он тоже это делает, ведь база данных одна для всего института. Были даже интересные случаи сливов эталонных (преподавательских) решений, но это просто прецеденты на фоне списываний внутри одной группы :)


    1. Guul
      18.10.2021 11:35

      Вангую что его можно не просто обойти, а засегфолтить. Банально std::cond_t<std::numric_limits<signed char>::max() / 2 == 100+27, char, double> foo(); нельзя нормально распарсить без интерпретации кода во время парсинга. Если на подобное завязать логику кода, то наколеночный парсер понятия не будет иметь что хотел сказать автор


      1. somnoynadno Автор
        18.10.2021 12:28

        Если программа сегфолтнет на этапе исполнения, то она не пройдёт тесты в системе контестов, а следовательно даже и не дойдет до модуля антиплагиата.


        1. Guul
          18.10.2021 12:44
          +1

          Причём здесь сегфолт программы? Я говорю про систему антиплагиата и недопарсер с++ который в лучшем случае будет считать корректные программы ошибочными. В худшем - ctd.


          1. somnoynadno Автор
            18.10.2021 12:52

            А, понял вопрос. Могу ответить только то, что тут вся ответственность за это ложится на ANTLR. Он не занимается интерпретацией кода, а список лексем выдает в соответствии с описанием грамматики языка программирования в специализированном формате (можете ознакомиться, вот пример для плюсов).

            Если он не может понять определенный токен (например, добавленый в новой мажорной версии языка программирования), то он просто добавляет неизвестный идентификатор и продолжает свою работу, а не падает с ошибкой.


      1. KvanTTT
        18.10.2021 13:06
        +2

        Ну распарсится это выражение с ошибкой, но ведь есть синхронизирующий токен (;), после которого процесс парсинга можно восстановить. Не думаю, что такое часто встречается в коде, а если и встречает, то возникнет другой вопрос — а зачем так писать?


  1. KvanTTT
    17.10.2021 18:58
    +1

    Я человек простой — вижу упоминание ANTLR, ставлю плюс статье и в карму :)


    Небольшой платой за готовый синтаксический разбор будет долгое обращение к самой утилите (занимает порядка секунды для небольшой программы).

    А как у вас происходит обращение к ANTLR, какой рантайм используете? Одна секунда — это очень много. Кстати, ANTLR имеет внутренний кеш, так что последующие обращения должны быстрее отрабатывать.


    Основным узким местом стало получение промежуточных форматов данных: обращение к ANTLR для каждой программы занимало примерно секунду, в то время как алгоритмическая часть работала почти моментально. Решением проблемы стало использование кэширования представлений посредством общеизвестного Redis, позволившее получать результат антиплагиата примерно за 1-2 секунды для выборки из 50 решений с "прогретым" кэшем.

    А что конкретно кешируется и зачем для этого Redis? Опять-таки ANTLR и так использует внутренний кеш, что увеличивает скорость при повторном обращении.


    Если после успешного внедрения появятся интересные результаты, то я обязательно ими поделюсь, а пока предлагаю вам поделиться в комментариях своими мыслями и наработками в данной области, если таковые имеются, а также задать интересующие вопросы.

    Забавно, если студенты начнут взламывать такую систему. Хотя за такое надо скорей поощрять, а не наказывать :)


    А еще можно нескольким студентам дать курсовую на разработку системы антиплагиата, а потом запустить ее на их же работах. Если программы будут работать хорошо или плохо, при этом покажут плагиат, значит студенты не получат хороших оценок :) Потому что в первом случаи они списали, во втором — разработали плохой алгоритм.


    1. somnoynadno Автор
      18.10.2021 11:29
      +1

      Замечания очень верные и, вероятно, проблем с производительностью бы не было, если бы обращения шли не из рантайма языка Go :)

      Для каждой программы, помимо её исходного кода, на вход антиплагиата поступает её уникальный ID. И первым делом проверяется, не поступала ли эта программа ранее. Здесь же гораздо проще и быстрее обратиться к in-memory хранилке за этим вопросом, чем заново прогонять код через ANTLR.

      Если говорить конкретнее, то в кэш попадает весь выход ANTLR. Например, AST туда прилетает в формате (compilation_unit (using_directives (using_directive using (namespace_or_type_name (identifier System)), а затем (при необходимости) повторно парсится и загружается в память сотней строк на Go и становится снова готовым для скоринга. К сожалению, не могу предоставить исходники, потому что это немного коммерческий продукт.

      Кстати, мы действительно задумывались о проблеме взлома системы антиплагиата, как только об этом прознают студенты. Штош, нам самим будет интересно посмотреть). Мы даже готовимся к некоторой гонке вооружений, когда нашу систему будут пытаться обойти, а мы будем придумывать новые эвристики для поиска таких хитрицов.


      1. KvanTTT
        18.10.2021 13:14

        Замечания очень верные и, вероятно, проблем с производительностью бы не было, если бы обращения шли не из рантайма языка Go :)

        Как раз не так давно производительность Go рантайма была сильно улучшена. Так попробуйте потом версию 4.9.3, она выйдет довольно скоро.


        Если говорить конкретнее, то в кэш попадает весь выход ANTLR. Например, AST туда прилетает в формате (compilation_unit (using_directives (using_directive using (namespace_or_type_name (identifier System))

        Такое, кстати, лучше в бинарном виде хранить, будет еще быстрей работать.


  1. gameplayer55055
    18.10.2021 08:24

    А не легче спросить что этот код делает и как он работает?

    Я своему другу тоже так помогал, код был отменный, но он его не защитил (хотя я ему объяснял суть, но он забыл почти все)


  1. wataru
    18.10.2021 12:11
    +1

    преподаватель сам понимает, что задача простая, поэтому, вероятнее всего, сразу проставит ей нужную оценку.

    Очень вряд ли. С текстовым антиплагиатом возникают проблемы, когда диссертацию отправляют на доработку, а исправленная версия потом не проходит проверку антиплагиатом, ибо она на 99% скопирована с предыдущей совей версии. А проблема лишь в том, что какой-то имбецил при отправке на проверку всегда ставит галочку "добавить текст в базу". И формальные требования 95% (или сколько там) уникальности бездумно везде появились.


    Так что не будет преподаватель разбираться. Если система взлетит, то будет тупо формальное требование — x% уникальности и не волнует.


    Необходимо защиту от таких крайних случаев вставлять в систему.


    1. KvanTTT
      18.10.2021 13:16

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


      1. wataru
        18.10.2021 13:35

        По идее — да. Но разработчики не предусмотрели именно этот случай. Во всех темах, где этот вопрос всплывает, создатели так и пишут — что не надо так делать, надо смотреть весь отчет, там будет все видно. Там же, если расширенный отчет открыть, то видно же, что все заимствования из предыдущей версии диссертации, но это надо куда-то тыкать и читать что-то, кроме одного числа. Людям это делать лень.


        1. KvanTTT
          18.10.2021 13:48

          Что мешает это исправить в последующих версиях?


          1. wataru
            18.10.2021 14:15

            Не знаю. Разработчкики эту проблему не признают. Потому что есть воркараунд — почитать, таки, отчет. Видимо, слишкмо много возни добавлять и проверять мета-данные, чтобы автоматически исключать предыдущие версии. Потом, наверно, будут опять же криворукие секретарши на кафедрах, которые будут эти теги вбивать с опечатками и автоматическая система не сработает.


            Я это лишь все привожу как пример, что надеятся на пользователя тут нельзя.