使用马尔可夫链生成随机名称
本文介绍了马尔可夫链的构建及其在生成随机名称或单词中的应用。
引言
最近我需要一个可以生成随机、可读名称的应用程序。我的搜索让我发现了马尔可夫链,以及如何构建和使用它们来生成随机单词或名称。
我找到了一篇关于 C++ 实现的旧帖子,我在此基础上进行了改进。地图构建实现主要继承自这个 C++ 实现,但我完全改变了随机生成部分,我认为它(依赖于巨大的字符串操作)效率不高,至少对于 .NET 受管解决方案来说是这样。
本文介绍了最终的实现,并围绕它提供了一个基本的 Winforms 应用程序。
背景
我不会在这里描述马尔可夫链的基本概念;这已经由比我更有专业知识的人完成,包括 CodeProject 上的文章。您可以在下面的 链接 部分找到有关它们的相關文章。
让我们来解释一下随机生成的一般原理
- 首先需要提供一个已知/可接受的单词列表,以便构建地图。
- 构建一个地图,该地图引用每个字母序列(给定深度),以及它在样本中出现的频率,以及序列在单词中的出现位置(开头、中间或结尾)。
- 然后使用此地图生成随机单词。可以提供一个已生成和/或禁止的单词列表,以便算法不会生成重复的单词。
地图是什么样的?
地图将一个或多个字母的序列与可能的下一个字符列表相关联;对于每个可能的后续字符,都会维护一个计数器,记录其出现次数以及在字符串中的位置。
让我们看一下基于单个单词“test”构建的深度为 1 的地图。它看起来像这样
Sequence Next First Inner Last
-------- ---- ----- ----- ----
t e 1 0 0
e s 0 1 0
s t 0 0 1
如果我们增加地图的深度到两个字符,将会在上述列表中添加以下出现情况
Sequence Next First Inner Last
-------- ---- ----- ----- ----
te s 1 0 0
es t 0 0 1
如何使用地图生成随机单词?
- 首先,从所有可用序列中随机选择一个序列。
- 然后,构建一个所有后续字符(对于所有序列,不仅仅是随机选择的序列)的列表;如果一个字符属于所选序列的可能后续字符,则其频率从地图中的频率中获取;如果它不属于该序列的可能后续字符,则赋予其一个最小频率(可选择)。这样,样本中不可用的组合仍然有微小的机会出现在结果中。
- 最后,从上述列表中随机选择一个字符,其被选中的几率与其频率成正比。
实现
Position 枚举
Position
枚举用于查询地图以获取特定的频率值。
public enum Position : byte
{
First,
Inner,
Last
}
StateFrequency 类
StateFrequency
类很简单,只包含三个整数变量来存储给定状态的频率信息。它还提供了一些方法来增加内部字段。
public class StateFrequency : IEquatable<StateFrequency>
{
public int FirstCount { get; private set; }
public int InnerCount { get; private set; }
public int LastCount { get; private set; }
public StateFrequency()
: this(0, 0, 0) { }
private StateFrequency(int firstCount, int innerCount, int lastCount)
{
FirstCount = firstCount;
InnerCount = innerCount;
LastCount = lastCount;
}
public void Add(StateFrequency other)
{
IncrementFirst(other.FirstCount);
IncrementInner(other.InnerCount);
IncrementLast(other.LastCount);
}
public bool Equals(StateFrequency other)
{
if (other == null)
{
return false;
}
if (ReferenceEquals(this, other))
{
return true;
}
return ((other.FirstCount == FirstCount) && (other.InnerCount == InnerCount) && (other.LastCount == LastCount));
}
public override bool Equals(object obj)
{
return (obj is StateFrequency) ? Equals((StateFrequency)obj) : false;
}
public overrride bool GetHashCode()
{
int hash = 351;
hash += FirstCount.GetHashCode();
hash *= 13;
hash += InnerCount.GetHashCode();
hash *= 13;
return hash + LastCount.GetHashCode();
}
public int GetValue(Position position)
{
switch (position)
{
case Position.First:
return FirstCount;
case Position.Last:
return LastCount;
case Position.Inner:
default:
return InnerCount;
}
}
public void Increment(int count = 1)
{
IncrementFirst(count);
IncrementInner(count);
IncrementLast(count);
}
public void IncrementFirst(int count = 1)
{
FirstCount += count;
}
public void IncrementInner(int count = 1)
{
InnerCount += count;
}
public void IncrementLast(int count = 1)
{
LastCount += count;
}
public override string ToString()
{
return $"First: {FirstCount}; Inner: {InnerCount}; Last: {LastCount}";
}
}
Map 类
Map
类继承自 Dictionary<TKey, TValue>
类,并公开了一个方法,允许在不关心预先存在的键的情况下向字典添加元素。这使得后续编写操作更加简单。下面介绍的两个类都将继承这个基础的 Map
类。
public class Map<TKey, TValue> : Dictionary<TKey, TValue>
{
// Constructors which follow base class' ones (not shown)
public bool EventuallyAdd(TKey key, TValue value)
{
bool add = !ContainsKey(key);
if (add)
{
Add(key, value);
}
return add;
}
}
FrequencyMap 类
FrequencyMap<T>
类被定义为 Map<T, StateFrequency>
。T
是我们将要处理的状态类型(此处为 string
)。
它提供了一个 Merge
方法,该方法允许将一个 FrequencyMap<T>
与任何 IDictionary<T, StateFrequency>
合并,并返回实际添加的键值对数量。
public class FrequencyMap<T> : Map<T, StateFrequency>
{
// Constructors which follow base class' ones (not shown)
public int Merge(IDictionary<T, StateFrequency> dictionary)
{
int count = 0;
T key;
StateFrequency value;
foreach (var pair in dictionary)
{
key = pair.Key;
value = pair.Value;
if (!EventuallyAdd(key, value))
{
this[key].Add(value);
}
else
{
count++;
}
}
return count;
}
}
MarkovChainMap 类
MarkovChainMap<T>
类被定义为 Map<T, FrequencyMap<T>>
。这就是我前面描述整个随机生成过程时提到的地图类。
它实现了 ISerializable
接口,以便能够保存/加载到/从文件中。
它还公开了(此处未显示)方法来
- 将地图转储为字符串。
- 将地图转储为字符串并保存到文本文件。
- 获取地图中记录的总频率数。
- 获取地图中每个条目的平均频率数。
- 从二进制文件中加载和保存地图。
- 将地图保存到
byte
数组。
public sealed class MarkovChainMap<T> : Map<T, FrequencyMap<T>>, ISerializable
{
public const int DefaultDepth = 1;
public int Depth { get; private set; }
public MarkovChainMap(int depth = DefaultDepth)
: base()
{
Depth = depth;
}
private MarkovChainMap(SerializationInfo info, StreamingContext context)
: base(info, context)
{
Depth = info.GetInt32(nameof(Depth));
}
public List<T> GetNextStates()
{
HashSet<T> states = new HashSet<T>();
T key;
foreach (var pair in this)
{
key = pair.Key;
foreach (var pair2 in pair.Value)
{
states.Add(pair2.Key);
}
}
return states.ToList();
}
public override void GetObjectData(SerializationInfo info, StreamingContext context)
{
base.GetObjectData(info, context);
info.AddValue(nameof(Depth), Depth);
}
public List<T> GetStartStates()
{
return Keys.ToList();
}
public void Merge(MarkovChainMap<T> other)
{
T key;
foreach (var pair in other)
{
key = pair.Key;
EventuallyAdd(key, new FrequencyMap<T>());
this[key].Merge(pair.value);
}
}
}
委托(Delegates)
为了构建给定类型的频率图,我们将需要两个函数,它们允许
- 将
T
值(序列)划分为单个部分。 - 聚合两个或多个
T
值以形成序列。
这两个方法都由两个委托定义,分别是
public delegate T[] PartitionFunc<T>(T value);
public delegate T AggregationFunc<T>(IEnumerable<T> values, int start, int count);
MarkovChainMapBuilder 和 MarkovChainStringMapBuilder 类
MarkovChainMapBuilder
类是一个泛型类,它包含上述定义的委托。还可以指定所需地图的深度。
public class MarkovChainMapBuilder<T>
{
private AggregationFunc<T> _aggregate;
private PartitionFunc<T> _partition;
private int _depth;
protected AggregationFunc<T> Aggregate
{
get { return _aggregate; }
set
{
if (value == null)
{
throw new ArgumentNullException(nameof(value));
}
_aggregate = value;
}
}
protected PartitionFunc<T> Partition
{
get { return _partition; }
set
{
if (value == null)
{
throw new ArgumentNullException(nameof(value));
}
_partition = value;
}
}
public virtual int Depth
{
get { return _depth; }
set
{
if (value < 1)
{
throw new ArgumentException(nameof(value));
}
_depth = value;
}
}
public MarkovChainMapBuilder(int depth, PartitionFunc<T> partition, AggregationFunc<T> aggregate)
{
if (depth < 1)
{
throw new ArgumentException(nameof(depth));
}
if (partition == null)
{
throw new ArgumentNullException(nameof(partition));
}
if (aggregate == null)
{
throw new ArgumentNullException(nameof(aggregate));
}
_depth = depth;
_partition = partition;
_aggregate = aggregate;
}
}
它还公开了一个公共 Train
方法,该方法从样本列表中返回一个 MarkovChainMap<T>
,以及一个受保护的 Train
方法,用于使用给定的单个样本来限定现有的 MarkovChainMap<T>
。
public MarkovChainMap<T> Train(IEnumerable<T> samples)
{
MarkovChainMap<T> map = new MarkovChainMap<T>(_depth);
foreach (T sample in samples)
{
if (sample != null)
{
Train(map, sample);
}
}
return map;
}
protected virtual void Train(MarkovChainMap<T> map, T sample)
{
T[] array = _partition(sample);
int length = array.Length;
int depth = 1;
FrequencyMap<T> frequencyMap;
StateFrequency stateFrequency;
T key, value;
int position, current, limit;
while (depth <= _depth)
{
if (depth < length)
{
limit = length - depth;
for (position = 0; (current = position + depth) < length; position++)
{
key = _aggregate(array, position, depth);
value = array[position + depth];
map.EventuallyAdd(key, new FrequencyMap<T>());
frequencyMap = map[key];
frequencyMap.EventuallyAdd(value, new StateFrequency());
stateFrequency = frequencyMap[value];
if (position == 0)
{
stateFrequency.IncrementFirst();
}
else if (current < limit)
{
stateFrequency.IncrementInner();
}
else
{
stateFrequency.IncrementLast();
}
}
}
depth++;
}
}
MarkovChainStringMapBuilder
类是 MarkovChainMapBuilder
的子类,专门用于 string
类型。
它提供了基本的 PartitionFunc
和 AggregationFunc
函数,专用于 string
类型。
它还允许指定如何处理样本中的空格。我们希望跳过空格(即在空格处将样本分割成几个部分)还是不跳过(即空格是最终输出中要考虑的有效字符)。
它提供了一个 TrainFromFile
方法,允许通过提供样本文本文件的路径来构建地图。还可以指定注释分隔符;以该字符串开头的行将被视为非样本而被跳过。
PartitionFunc
和 AggregationFunc
函数实现为静态方法。
它最终提供了一个从 StreamReader
读取单词的 ReadWord
方法;这允许在样本包含空格时将其分割,并且 SkipWhitespace
属性设置为 true
。
还有一个静态的 Lazy<StringBuilder>
,避免了在 ReadWord
方法中每次需要时都创建和销毁 StringBuilder
。
public class MarkovChainStringMapBuilder : MarkovChainMapBuilder<string>
{
private static readonly Lazy<StringBuilder> _sb = new Lazy<StringBuilder>(() => new StringBuilder(16));
public bool SkipWhitespace { get; set; }
public MarkovChainStringMapBuilder(int depth, bool skipWhitespace = true)
: this(depth, (v) => ToStringArray(v), (v, s, c) => Join(v, s, c), skipWhitespace) { }
public MarkovChainStringMapBuilder(int depth, PartitionFunc<string> partition, AggregationFunc<string> aggregate, bool skipWhitespace = true)
: base(depth, partition, aggregate)
{
SkipWhitespace = skipWhitespace;
}
public MarkovChainMap<string> TrainFromFile(string path, string commentSpecifier = null)
{
commentSpecifier = commentSpecifier ?? string.Empty;
List<string> values = new List<string>();
string value;
using (StreamReader reader = new StreamReader(path))
{
while (!reader.EndOfStream)
{
value = (SkipWhitespace) ? ReadWord(reader) : reader.ReadLine();
if (!string.IsNullOrWhiteSpace(value))
{
if (string.IsNullOrEmpty(commentSpecifier) || !value.StartsWith(commentSpecifier))
{
values.Add(value);
}
}
}
}
return Train(values);
}
private static string Join(IEnumerable<string> slices, int start, int count)
{
string result = string.Empty;
switch (slices.Count())
{
case 0:
break;
case 1:
result = slices.First();
break;
default:
string[] values = slices.Skip(start).Take(count).ToArray();
result = string.Join(string.Empty, values);
break;
}
return result;
}
private static string ReadWord(StreamReader reader)
{
StringBuilder sb = _sb.Value;
sb.Clear();
char c;
int read;
while ((read = reader.Read()) != -1)
{
c = (char)read;
if (char.IsWhiteSpace(c))
{
break;
}
sb.Append(c);
}
return sb.ToString();
}
private static string[] ToStringArray(string input)
{
List<string> characters = new List<string>(input.Length);
foreach (char c in input)
{
characters.Add(c.ToString());
}
return characters.ToArray();
}
}
Sieve 类
Sieve<T>
类负责从指定的 MarkovChainMap<T>
和 Position
返回一个随机的 T
值。
它通过创建一个 int
数组来实现这一点,该数组的元素数量与地图中的元素数量相同,并且每个元素是地图中频率的累积总和(减去 MinFrequency
属性)。
然后,它在零和数组的最后一个索引处的值之间选择一个随机数,并返回与该随机数对应的 T
值。
public sealed class Sieve<T> { public const int DefaultMinFrequency = 1; private int _minFrequency; private Random _random; public int MinFrequency { get { return _minFrequency; } set { _minFrequency = Math.Max(0, value); } } public Random Random { get { return _random; } set { _random = value ?? new Random(); } } public Sieve(int minFrequency = DefaultMinFrequency, Random random = null) { Random = random; MinFrequency = minFrequency; } public int[] GetBuckets(FrequencyMap<T> map, Position position) { int[] buckets = new int[map.Count]; int sum = 0; int index = 0; StateFrequency frequency; int value; foreach (var pair in map) { frequency = pair.Value; value = Math.Max(_minFrequency, frequency.GetValue(position)); sum += value; buckets[index++] = sum; } return buckets; } public T GetValue(FrequencyMap<T> map, Position position) { int[] buckets = GetBuckets(map, position); int length = buckets.Length; int total = buckets[length - 1]; int value = _random.Next(0, total); int index; for (index = 0; index < length; index++) { if (value < buckets[index]) { break; } } return map.Keys.ToList()[index]; } }
MarkovChainsNameGenerator 类
MarkovChainsNameGenerator
类是负责使用上述对象生成随机单词的类。对于只对功能感兴趣的人来说,这是唯一需要使用的类。
它允许指定
- 是否要大写生成的名称;
- 一个已构建的
MarkovChainMap<string>
以便使用,可选。 - 地图的深度。
- 所需输出的最小和最大字符数。
- 生成随机名称时要考虑的最小频率。
- 一个
Random
对象以供使用,可选。 - 在构建地图时是否要跳过样本中的空格。
再次说明,这里提供的代码是解决方案中实际代码的简化版本。属性设置器实际上会验证提供的值,以防止一些常见的错误。
- 每当向
Map
属性的设置器提供有效地图时,就会设置MapDepth
属性。 MapDepth
属性不能小于一。MinLength
属性的下限为一。MaxLength
属性的下限为MinLength
属性。MinFrequency
属性的下限为零。- 当向设置器提供 null 值时,
Random
属性会初始化一个新的默认Random
对象。 - 设置
Map
属性也会相应地设置MapDepth
属性。 - 设置
MinFrequency
属性会设置内部Sieve<string>
对象的相应属性。 - 设置
SkipWhitespace
属性会设置内部MarkovChainStringMapBuilder
对象的相应属性。
public sealed class MarkovChainsNameGenerator
{
private const int DefaultDepth = MarkovChainMap<string>.DefaultDepth;
private const int DefaultMinFrequency = Sieve<string>.DefaultMinFrequency;
private const int DefaultMaxLength = 9;
private const int DefaultMinLength = 3;
private const int Maximum = int.MaxValue - 1;
private readonly HashSet<string> _blacklistedNames;
private readonly MarkovChainStringMapBuilder _mapBuilder;
private readonly StringBuilder _sb;
private readonly Sieve<string> _sieve;
public bool Capitalize { get; set; }
public MarkovChainMap<string> Map { get; set; }
public int MapDepth { get; set; }
public int MaxLength { get; set; }
public int MinFrequency { get; set; }
public int MinLength { get; set; }
public Random Random { get; set; }
public bool SkipWhitespace { get; set; }
public int Seed
{
set { Random = new Random(value); }
}
private double RangeLength
{
get { return MaxLength - MinLength + 1d; }
}
public MarkovChainsNameGenerator(
Random random = null,
int mapDepth = DefaultDepth,
int minFrequency = DefaultMinFrequency,
int minLength = DefaultMinLength,
int maxLength = DefaultMaxLength,
bool capitalize = true,
bool skipWhitespace = true,
IEnumerable<string> blacklistedNames = null)
{
Random = random ?? new Random(Environment.TickCount);
MapDepth = mapDepth;
MinFrequency = Math.Max(0, minFrequency);
MinLength = Math.Max(1, minLength);
MaxLength = Math.Max(MinLength, maxLength);
Capitalize = capitalize;
SkipWhitespace = skipWhitespace;
_mapBuilder = new MarkovChainStringMapBuilder(MapDepth, SkipWhitespace);
_sieve = new Sieve<string>(MinFrequency, Random);
_sb = new StringBuilder(_maxLength);
_blacklistedNames = new HashSet<string>(blacklistedNames) ?? new HashSet<string>();
}
public MarkovChainsNameGenerator(
MarkovChainMap<string> map,
Random random = null,
int mapDepth = DefaultDepth,
int minFrequency = DefaultMinFrequency,
int minLength = DefaultMinLength,
int maxLength = DefaultMaxLength,
bool capitalize = true,
bool skipWhitespace = true,
IEnumerable<string> blacklistedNames = null)
: this(random, map.Depth, minFrequency, minLength, maxLength, capitalize, skipWhitespace, blacklistedNames)
{
_map = map;
}
public string GetName()
{
StringBuilder sb = _sb;
sb.Clear();
List<string> startLetters = Map.GetStartStates();
List<string> nextCharacters = Map.GetNextStates();
int startLetterCount = startLetters.Count;
int nextCharacterCount = nextCharacters.Count;
int totalLength, currentLength, selectionLength;
string initial, key, result;
do
{
totalLength = (int)(MinLength + RangeLength * Random.Next() / Maximum);
initial = startLetters[(int)(startLetterCount * (double)Random.Next() / Maximum)];
sb.Append(initial);
currentLength = initial.Length;
while (currentLength < totalLength)
{
selectionLength = Random.Next(1, Math.Min(currentLength, MapDepth) + 1);
do
{
key = sb.ToString().Substring(currentLength - selectionLength, selectionLength);
} while (!Map.ContainsKey(key) && (--selectionLength > 0));
if (selectionLength == 0)
{
key = nextCharacters[(int)(nextCharacterCount * (double)Random.Next() / Maximum)];
}
sb.Append(_sieve.GetValue(Map[key], (currentLength == 1) ? Position.First : (currentLength == totalLength - 1) ? Position.Last : Position.Inner));
currentLength++;
}
result = sb.ToString();
if (Capitalize)
{
result = MakeProperCase(result);
}
} while (_blacklistedNames.Contains(result));
_blacklistedNames.Add(result);
return result;
}
public IEnumerable<string> GetNames(int count)
{
if (count < 1)
{
yield break;
}
for (int i = 0; i < count; i++)
{
yield return GetName();
}
}
public void TrainMapBuilder(string path, string commentSpecifier = null)
{
Map = _mapBuilder.TrainFromFile(path, commentSpecifier);
}
private static string MakeProperCase(string input)
{
string initial = input.Substring(0, 1).ToUpper(Thread.CurrentThread.CurrentCulture);
return $"{initial}{input.Substring(1, input.Length - 1)}";
}
}
使用代码
MarkovChainsNameGenerator
类是唯一需要使用的类。
首先,需要添加对 RandomNameGenerator.Core.dll 库的引用。
然后,该库可以这样使用
using RandomNameGenerator.Core;
int Main()
{
// Here I am presenting an initialization without any parameter specified.
// It means the generator will be initialiazed with its default values.
// You can customize these.
MarkovChainsNameGenerator generator = new MarkovChainsNameGenerator();
generator.TrainMapBuilder(@"C:\path\to\the\samples\file");
IEnumerable<string> names = generator.GetNames(100);
// From there you will get a list of a hundred random names generated from provided samples file.
}
使用提供的 Windows Forms 应用程序
应用程序主屏幕看起来是这样的
- 将显示生成名称的 ListView。列表项可以被选中或不选中。
- 在这里,您可以指定希望保存接受名称和拒绝名称列表的目录。只能指定接受名称的文件名,拒绝名称的文件名是相对于前者构建的。
- 这里有一个已构建并保存的地图列表,以及管理它们的按钮。必须先激活一个特定的地图(通过相应的按钮),然后才能开始生成名称。
- 在这里,您可以配置您想要的名称生成方式。Batch size 对应于点击下面的“生成名称”和“提交”按钮时生成的名称数量。Total 对应于您最终需要的总名称数量。其他参数已经解释过。
- 点击此按钮后,会生成随机名称并填充列表。先前显示的列表将被丢弃,并且不会将任何内容添加到接受和拒绝的名称列表中。
- 点击此按钮后,会打开一个窗口显示到目前为止接受的名称列表。
- 点击此按钮后,会打开一个窗口显示到目前为止拒绝的名称列表。
- 点击此按钮后,列表中选中的名称将被添加到接受名称列表,未选中的将被添加到拒绝名称列表。最后,列表将填充一批新的随机名称,这些名称不能已在接受和拒绝名称列表中。
- 状态栏显示一些计数器:最终需要的总名称数,到目前为止生成的名称数(以及相对于总数的比例),还需要接受的名称数(以及相对于总数的比例),到目前为止接受的名称数(以及相对于已生成名称数的比例),到目前为止拒绝的名称数(以及相对于已生成名称数的比例),构建地图所花费的时间,以及生成名称所花费的时间。
还有一个选项屏幕
它允许配置
- 地图保存文件的扩展名。
- 地图字符串转储文件的扩展名。
- 生成时是否应该转储地图。
- 样本文件的可选注释分隔符。
- 地图生成时是否应跳过空格。
- 输出文件(接受和拒绝名称)的扩展名。
- 拒绝名称的文件名添加的标志。
- 在重置接受和拒绝列表时,备份文件的扩展名。
- 主屏幕名称列表的字体。
在此屏幕上,您还可以重置列表和计数器,以恢复到应用程序的初始状态。
这是地图创建屏幕
Dictionary 对应于样本名称文件。
最后,这是致谢屏幕
列表中的蓝色项目是可点击的链接,它们指向的链接显示为工具提示。
注释
- 理论上,提供的通用解决方案可以用于字符串以外的其他类型。不过,我没有尝试过,也不打算将来这样做。如果您想使用/将其改编为其他类型的序列,您必须为给定类型提供相应的委托(
PartitionFunc<T>
和AggregationFunc<T>
)。 - 本文中提供的代码是解决方案中代码的简化版本。核心实现没有改变,但解决方案中的代码(几乎)是完全文档化的,至少对于可重用部分是这样。然而,Windows Forms 项目没有文档,因为它不是本文的主题。
- 为了简化起见,我在本文提供的代码中使用了自动实现的属性。但在可下载的解决方案中,我使用的是私有字段来存储属性值。
- 感谢 Phillip Piper [^] 提供了他出色的 ObjectListView [^],它每天都证明其价值。
- 我包含了两个样本文件,如果您不想花时间搜索一个。它们位于解决方案根文件夹的 Samples 文件夹中。不过,我没有包含与这些样本文件相关的生成地图。您必须从应用程序中生成地图。
未来可能的改进
- 能够提供函数来限制特定的字符组合(例如,如果结果包含两个以上连续的辅音,或者包含特定的字符组合,则丢弃该结果)。
- 在 Winforms 应用程序中提供预先构建的地图,用于给定的名字、姓氏,可能与特定文化相关。
- 实现一个解决方案来生成随机全名(名字 + 姓氏),可能与特定文化相关。
- 实现
GetName
和GetNames
方法的async
版本。
链接
历史
- 2016/10/29:版本 1.0.0.0 - 首次发布