使用神经网络识别编程语言(C#)






4.74/5 (10投票s)
本文介绍如何使用神经网络识别编程语言,这是 CodeProject 的机器学习和人工智能挑战赛的参赛作品。
目录
引言
本文是我为 CodeProject 的 机器学习和人工智能挑战赛[^] 中的语言检测部分提交的作品。挑战赛的目标是基于提供的包含 677 个代码样本的训练数据集,训练一个模型来识别编程语言。
我使用了 C#。该解决方案包含一个 LanguageRecognition.Core
项目,这是一个包含机器学习代码的库,以及一个 LanguageRecognition
项目,这是一个测试代码的控制台应用程序。该项目依赖于 SharpLearning[^](更具体地说,是 SharpLearning.Neural)。
算法
我决定使用 神经网络[^] 来训练我的模型。神经网络以浮点数向量作为输入,这是我们要分类的对象的特征。该向量也称为输入层。层的单元也称为神经元。神经网络还有一个输出层。对于为分类而训练的网络(如本项目中的网络),输出层将具有与分类类别一样多的元素,其值表示网络将给定输入归类到哪个类别。(例如,一个有 3 个类别的分类结果可能是 [0.15 0.87 0.23]
,表示网络倾向于第二个类别)。在输入层和输出层之间,还可以有一个或多个隐藏层,其单元数可以自行选择。如何从一层到下一层?通过将第一层与权重矩阵进行矩阵乘法[^],然后将结果通过激活函数[^],我们就得到了下一层的值。(对于本文中的网络,使用了整流器(rectifier)[^],因为 SharpLearning 使用它。)然后,该层用于计算下一层的值,依此类推。对于最后一层,我们将使用softmax 函数[^],而不仅仅是激活函数(一个重要区别是激活函数独立作用于层的每个单元,而 softmax 函数必须应用于一组值)。每两个层“之间”都有一个不同的权重矩阵。这些权重矩阵的值确实决定了网络的输出会是什么样子。因此,如果一个神经网络要被训练,这些权重矩阵的值将被调整,以便实际输出更好地匹配预期输出。SharpLearning 使用梯度下降[^]来实现这一点(更具体地说,是小批量梯度下降[^])。
我将不会深入探讨神经网络的细节和数学原理,因为 SharpLearning 会处理这些。我将重点关注如何将其应用于识别编程语言。如果您有兴趣了解更多信息,有大量的资料可供参考;上一段中的链接可以作为起点。
我提到神经网络以浮点向量(即特征)作为输入。在这里,这些特征是什么呢?对于本次挑战,特征的数量(以及特征本身)不能预先定义,因为这需要对我们要分类的语言数量和具体语言做出假设。我们不想做这种假设;相反,我们将从用作训练的代码样本中派生出特征的数量。派生特征是我们训练过程的第一步。
首先,分别派生出对每种语言都很重要的特征。我决定派生三种类型的特征:代码中最常见的符号、最常见的单词以及最常见的两个单词的组合。对我来说,这些特征似乎最重要。例如,在 HTML 中,<
和 >
符号很重要,body
、table
和 div
等关键字也很重要。关键字 import
对于 Java 和 Python 都很重要,这时组合会有很大帮助:像 import java
这样的组合对于 Java 很重要,而像 import os
这样的组合对于 Python 很重要。
在为每种语言派生出这些特征后,我们将它们合并:我们希望让我们的神经网络了解所有可能指向特定语言的特征的存在(或不存在)。输入神经元的总数将是为每种语言选择的所有符号、关键字和组合的总和(重复项当然会被过滤掉;例如,我们不需要多个输入神经元来表示关键字 import
的存在)。输出神经元的数量将是训练数据集中出现的语言数量。
让我举个例子来澄清。假设训练集中只有 3 种语言:C#、Python 和 JavaScript。对于所有这些语言,都选择最常见的 10 个符号、最常见的 20 个单词和最常见的 30 个组合。每种语言有 60 个特征,因此三种语言加起来共有 180 个特征。然而,大多数符号和一些关键字/组合会是重复的。为了举例说明,假设总共有 11 个唯一的符号,54 个唯一的单词和 87 个唯一的组合,那么我们的神经网络将接收 11+54+87 个值作为输入。每个输入值对应一个符号/单词/组合,该值将是在任意一段代码中该符号/单词/组合的出现次数。
那么隐藏层呢?也就是输入层和输出层之间的层?我选择了 4 个隐藏层:如果 S
是所有符号、关键字、组合以及可能的输出语言的总和,那么隐藏层分别有 S / 2
、S / 3
、S / 4
和 S / 5
个单元。为什么选择这些数字?因为在测试模型时,这些数字给了我最好的结果之一——没有太多更深层的原因。在所有这四个层中使用 S
个单元得到了类似的结果(甚至平均而言可能略好),但训练速度慢了很多。
在选择了要使用的特征后,就到了实际训练的阶段。对于神经网络的数学运算,我使用了 SharpLearning
库。对于每个代码样本,都会计算先前选择的符号/单词/组合,并用作神经网络输入。所有待识别的语言都会被赋予一个索引,这些索引将作为训练数据的输出传递给 SharpLearning
。
训练完成后,我们就拥有了一个能够识别代码样本语言的模型。要预测一种语言,输入的代码样本将被转换为一个输入向量,这与训练样本的预处理方式完全相同(即计算特定符号、单词和组合的计数),然后 SharpLearning
将负责进行数学计算,返回预测的编程语言的索引。
实现
CharExtensions:定义“符号”
在前一节中,我说过我们将选择给定语言中最常见的符号作为神经网络特征的一部分。让我们先定义在这种情况下“符号”的含义。对我来说,以下定义似乎是合理的:如果一个 char
不是字母、不是数字、不是空白字符,也不是下划线(因为下划线在变量名中是完全有效的),那么它就是一个符号。将其转换为代码。
static class CharExtensions
{
internal static bool IsProgrammingSymbol(char x)
{
return !char.IsLetterOrDigit(x) && !char.IsWhiteSpace(x) && x != '_';
}
}
LanguageTrainingSet:派生每种语言的特征
接下来,我们将处理那些用于从给定代码样本中派生特征的类。正如我之前所说,我们将首先按语言进行此操作,然后合并特征。LanguageTrainingSet
类负责前者,并保存一种语言的所有训练样本。该类具有以下属性来跟踪样本和符号/关键字/组合计数。
List<string> samples = new List<string>();
public List<string> Samples { get => samples; }
Dictionary<char, int> symbolCounters = new Dictionary<char, int>();
Dictionary<string, int> keywordCounters = new Dictionary<string, int>();
Dictionary<string, int> wordCombinationCounters = new Dictionary<string, int>();
当新的训练样本添加到训练集中时,这些集合将被填充。这就是 AddSample
方法的作用。
public void AddSample(string code)
{
code = code.ToLowerInvariant();
samples.Add(code);
var symbols = code.Where(CharExtensions.IsProgrammingSymbol);
foreach (char symbol in symbols)
{
if (!symbolCounters.ContainsKey(symbol))
{
symbolCounters.Add(symbol, 0);
}
symbolCounters[symbol]++;
}
string[] words = Regex.Split(code, @"\W").Where
(x => !string.IsNullOrWhiteSpace(x)).ToArray();
foreach (string word in words)
{
if (!keywordCounters.ContainsKey(word))
{
keywordCounters.Add(word, 0);
}
keywordCounters[word]++;
}
for (int i = 0; i < words.Length - 1; i++)
{
string combination = words[i] + " " + words[i + 1];
if (!wordCombinationCounters.ContainsKey(combination))
{
wordCombinationCounters.Add(combination, 0);
}
wordCombinationCounters[combination]++;
}
}
让我们一步一步地过一遍。
- 代码转换为小写。对于不区分大小写的语言,如果不这样做很可能会损害识别结果。而对于区分大小写的语言,这影响不大。
- 训练样本被添加到
samples
列表中。 - 使用 LINQ 的
Where
方法和我们之前创建的IsProgrammingSymbol
方法,从代码样本中提取所有符号。 - 我们遍历所有找到的符号,并为每个符号,在
symbolCounters
字典中增加与该符号关联的值。 - 代码按“非单词字符”(即所有不是 A-Z、a-z、0-9 或下划线的字符)拆分,以提取所有单词。
- 与符号一样,
keywordCounters
字典中的计数器会增加。 - 我们对所有连续出现的两个单词的组合也进行同样的操作。
当使用此方法添加更多样本时,计数器会逐渐增加,我们可以得到一个良好的排名,表明哪些关键字出现频率最高,哪些出现频率较低。最终,我们想知道哪些关键字、符号和组合出现频率最高,并将它们用作神经网络的特征。为了选择这些,该类有一个 ChooseSymbolsAndKeywords
方法。它是 internal
的,因为我们希望能够从 LanguageRecognition.Core
程序集中的其他类调用它,但不能在程序集外部调用。
const int SYMBOLS_NUMBER = 10;
const int KEYWORDS_NUMBER = 20;
const int COMBINATIONS_NUMBER = 30;
internal IEnumerable<char> Symbols { get; private set; }
internal IEnumerable<string> Keywords { get; private set; }
internal IEnumerable<string> Combinations { get; private set; }
internal void ChooseSymbolsAndKeywords()
{
Symbols = symbolCounters.OrderByDescending(x => x.Value).Select
(x => x.Key).Take(SYMBOLS_NUMBER);
Keywords = keywordCounters.OrderByDescending(x => x.Value).Select
(x => x.Key).Where(x => !int.TryParse(x, out int _)).Take(KEYWORDS_NUMBER);
Combinations = wordCombinationCounters.OrderByDescending
(x => x.Value).Select(x => x.Key).Take(COMBINATIONS_NUMBER);
}
.Where
调用选择关键字的目的是排除仅仅是数字的“关键字”。这些完全没有用处。字母与数字的组合(例如 1px
仍然有用)不被排除。
TrainingSet:整合 LanguageTrainingSets
TrainingSet
类管理所有 LanguageTrainingSet
,因此当您使用 LanguageRecognition.Core
库时,无需担心这一点。当 LanguageRecognizer
类(我们稍后会讨论)想要执行神经网络训练时,TrainingSet
类将合并由每个 LanguageTrainingSet
的 ChooseSymbolsAndKeywords
选择的 .Symbols
、.Keywords
和 .Combinations
,因此我们还会有 TrainingSet.Symbols
、TrainingSet.Keywords
和 TrainingSet.Combinations
— 这些是我们将用于神经网络的特征。
public class TrainingSet
{
Dictionary<string, LanguageTrainingSet> languageSets =
new Dictionary<string, LanguageTrainingSet>();
internal Dictionary<string, LanguageTrainingSet> LanguageSets { get => languageSets; }
internal char[] Symbols { get; private set; }
internal string[] Keywords { get; private set; }
internal string[] Combinations { get; private set; }
internal string[] Languages { get; private set; }
public void AddSample(string language, string code)
{
language = language.ToLowerInvariant();
if (!languageSets.ContainsKey(language))
{
languageSets.Add(language, new LanguageTrainingSet());
}
languageSets[language].AddSample(code);
}
internal void PrepareTraining()
{
List<char> symbols = new List<char>();
List<string> keywords = new List<string>();
List<string> combinations = new List<string>();
foreach (KeyValuePair<string, LanguageTrainingSet> kvp in languageSets)
{
LanguageTrainingSet lts = kvp.Value;
lts.ChooseSymbolsAndKeywords();
symbols.AddRange(lts.Symbols);
keywords.AddRange(lts.Keywords);
combinations.AddRange(lts.Combinations);
}
Symbols = symbols.Distinct().ToArray();
Keywords = keywords.Distinct().ToArray();
Combinations = combinations.Distinct().ToArray();
Languages = languageSets.Select(x => x.Key).ToArray();
}
}
当 LanguageRecognizer
类需要知道网络输入的全部特征以及输出的可能语言时,它将调用 PrepareTraining
方法。
LanguageRecognizer:训练和预测
LanguageRecognizer
类是实际工作的发生地:训练神经网络,并获得一个可以用来预测代码样本语言的模型。让我们先看看这个类的字段。
[Serializable]
public class LanguageRecognizer
{
NeuralNet network;
char[] symbols;
string[] keywords;
string[] combinations;
string[] languages;
ClassificationNeuralNetModel model = null;
首先,请注意该类是 Serializable
的:如果您已经训练了模型并想稍后重用它,您就不必重新训练,只需将其序列化并在以后恢复即可。symbols
、keywords
、combinations
和 languages
字段是神经网络输入的特征 — 它们将从 TrainingSet
中获取。NeuralNet
是 SharpLearning
中的一个类,ClassificationNeuralNetModel
也是,后者是训练好的模型,前者用于训练。
接下来,我们有一个静态的 CreateFromTraining
方法,它接受一个 TrainingSet
并返回一个 LanguageRecognizer
实例。我决定使用静态方法而不是构造函数,因为构造函数指南[^]建议在构造函数中执行最少的工作,而训练模型的工作量并不算“最少”。
LanguageRecognizer.CreateFromTraining
方法按照我在本文前面描述的方式构建神经网络及其层。它将遍历所有训练样本,并将代码转换为输入向量。这些输入向量被合并成一个输入矩阵,然后与预期输出一起传递给 SharpLearning
。
public static LanguageRecognizer CreateFromTraining(TrainingSet trainingSet)
{
LanguageRecognizer recognizer = new LanguageRecognizer();
trainingSet.PrepareTraining();
recognizer.symbols = trainingSet.Symbols;
recognizer.keywords = trainingSet.Keywords;
recognizer.combinations = trainingSet.Combinations;
recognizer.languages = trainingSet.Languages;
recognizer.network = new NeuralNet();
recognizer.network.Add(new InputLayer(recognizer.symbols.Length +
recognizer.keywords.Length + recognizer.combinations.Length));
int sum = recognizer.symbols.Length + recognizer.keywords.Length +
recognizer.combinations.Length + recognizer.languages.Length;
recognizer.network.Add(new DenseLayer(sum / 2));
recognizer.network.Add(new DenseLayer(sum / 3));
recognizer.network.Add(new DenseLayer(sum / 4));
recognizer.network.Add(new DenseLayer(sum / 5));
recognizer.network.Add(new SoftMaxLayer(recognizer.languages.Length));
ClassificationNeuralNetLearner learner =
new ClassificationNeuralNetLearner(recognizer.network, loss: new AccuracyLoss());
List<double[]> inputs = new List<double[]>();
List<double> outputs = new List<double>();
foreach (KeyValuePair<string, LanguageTrainingSet> languageSet in trainingSet.LanguageSets)
{
string language = languageSet.Key;
LanguageTrainingSet set = languageSet.Value;
foreach (string sample in set.Samples)
{
inputs.Add(recognizer.PrepareInput(sample));
outputs.Add(recognizer.PrepareOutput(language));
}
}
F64Matrix inp = inputs.ToF64Matrix();
double[] outp = outputs.ToArray();
recognizer.model = learner.Learn(inp, outp);
return recognizer;
}
此方法引用 PrepareInput
和 PrepareOutput
。PrepareOutput
非常简单:对于给定的语言,它返回该语言在已知语言列表中的索引。PrepareInput
构建一个 double[]
,其中包含要馈送到神经网络的特征:我们关心的符号、关键字和关键字组合的数量。
double[] PrepareInput(string code)
{
code = code.ToLowerInvariant();
double[] prepared = new double[symbols.Length + keywords.Length + combinations.Length];
double symbolCount = code.Count(CharExtensions.IsProgrammingSymbol);
for (int i = 0; i < symbols.Length; i++)
{
prepared[i] = code.Count(x => x == symbols[i]);
}
string[] codeKeywords = Regex.Split(code, @"\W").Where(x => keywords.Contains(x)).ToArray();
int offset = symbols.Length;
for (int i = 0; i < keywords.Length; i++)
{
prepared[offset + i] = codeKeywords.Count(x => x == keywords[i]);
}
string[] words = Regex.Split(code, @"\W").ToArray();
Dictionary<string, int> cs = new Dictionary<string, int>();
for (int i = 0; i < words.Length - 1; i++)
{
string combination = words[i] + " " + words[i + 1];
if (!cs.ContainsKey(combination))
{
cs.Add(combination, 0);
}
cs[combination]++;
}
offset = symbols.Length + keywords.Length;
for (int i = 0; i < combinations.Length; i++)
{
prepared[offset + i] = cs.ContainsKey(combinations[i]) ? cs[combinations[i]] : 0;
}
return prepared;
}
double PrepareOutput(string language)
{
return Array.IndexOf(languages, language);
}
最后,在创建和训练了识别器之后,我们显然希望使用它来实际识别语言。这是一段非常简单的代码:只需使用 PrepareInput
将输入转换为输入向量,然后将其传递给 SharpLearning 的训练模型,该模型将输出一个索引。
public string Recognize(string code)
{
return languages[(int)model.Predict(PrepareInput(code))];
}
测试
可下载的 LanguageRecognition
包含两个项目:LanguageRecognition.Core
作为包含所有学习相关代码的库,以及 LanguageRecognition
作为控制台应用程序,它根据 CodeProject 提供的的数据集训练识别器。数据集包含 677 个样本。其中 577 个用于训练,其余 100 个用于测试模型的性能。
测试代码提取代码样本,将其混淆,选取前 577 个进行训练,然后测试模型的序列化和反序列化,最后执行预测测试。
static void Main(string[] args)
{
// Reading and parsing training samples:
string sampleFileContents = File.ReadAllText("LanguageSamples.txt").Trim();
string[] samples = sampleFileContents.Split(new string[] { "</pre>" },
StringSplitOptions.RemoveEmptyEntries);
List<Tuple<string, string>> taggedSamples = new List<Tuple<string, string>>();
foreach (string sample in samples)
{
string s = sample.Trim();
string pre = s.Split(new char[] { '>' }, 2)[0];
string language = pre.Split('"')[1];
s = WebUtility.HtmlDecode(s.Replace(pre + ">", "")); // The code samples
// are HTML-encoded because they are in pre-tags.
taggedSamples.Add(new Tuple<string, string>(language, s));
taggedSamples = taggedSamples.OrderBy(x => Guid.NewGuid()).ToList();
}
// Setting up training set and performing training:
TrainingSet ts = new TrainingSet();
foreach (Tuple<string, string> sample in taggedSamples.Take(577))
{
ts.AddSample(sample.Item1, sample.Item2);
}
LanguageRecognizer recognizer = LanguageRecognizer.CreateFromTraining(ts);
// Serialization testing:
BinaryFormatter binaryFormatter = new BinaryFormatter();
LanguageRecognizer restored;
using (MemoryStream stream = new MemoryStream())
{
binaryFormatter.Serialize(stream, recognizer);
stream.Seek(0, SeekOrigin.Begin);
restored = (LanguageRecognizer)binaryFormatter.Deserialize(stream);
}
// Prediction testing:
int correct = 0;
int total = 0;
foreach (Tuple<string, string> sample in taggedSamples.Skip(577))
{
if (restored.Recognize(sample.Item2) == sample.Item1.ToLowerInvariant())
{
correct++;
}
total++;
}
Console.WriteLine($"{correct}/{total}");
}
结果
平均而言,对未知样本的准确率约为 85%。然而,每次运行测试应用程序时,准确率都会有所不同,因为代码样本是混淆的(因此选择的特征会略有不同),并且神经网络每次都以不同的随机权重进行初始化。有时,准确率略低于 80%,有时也略高于 90%。我想测试更大的训练集,但我没有时间收集这些。但我相信这会提高准确率,因为更大的训练集意味着更好的特征选择和更好的神经网络训练。
历史
- 2018 年 3 月 3 日:初始版本