• HMM隐马尔科夫模型


    http://blog.chinaunix.net/uid-26715658-id-3453274.html

    隐马尔可夫模型 (Hidden Markov Model,HMM) 最初由 L. E. Baum 和其它一些学者发表在一系列的统计学论文中,随后在语言识别,自然语言处理以及生物信息等领域体现了很大的价值。平时,经常能接触到涉及 HMM 的相关文章,一直没有仔细研究过,都是蜻蜓点水,因此,想花一点时间梳理下,加深理解,在此特别感谢 52nlp 对 HMM 的详细介绍。

      考虑下面交通灯的例子,一个序列可能是红-红/橙-绿-橙-红。这个序列可以画成一个状态机,不同的状态按照这个状态机互相交替,每一个状态都只依赖于前一个状态,如果当前的是绿灯,那么接下来就是橙灯,这是一个确定性系统,因此更容易理解和分析,只要这些状态转移都是已知的。但是在实际当中还存在许多不确定性系统。

      在日常生活当中,我们总是希望根据当前天气的情况来预测未来天气情况,和上面的交通灯的例子不同,我们不能依靠现有知识确定天气情况的转移,但是我们还是希望能得到一个天气的模式。一种办法就是假设这个模型的每个状态都只依赖于前一个的状态,这个假设被称为马尔科夫假设,这个假设可以极大简化这个问题。显然,这个假设也是一个非常糟糕的假设,导致很多重要的信息都丢失了。

      当涉及到天气的时候,马尔科夫假设描述为,假设如果我们知道之前一些天的天气信息,那么我们就能预测今天的天气。当然,这个例子也是有些不合实际的。但是,这样一个简化的系统可以有利于我们的分析,所以我们通常接受这样的假设,因为我们知道这样的系统能让我们获得一些有用的信息,尽管不是十分准确的。

      谈到 HMM,首先简单介绍一下马尔可夫过程 (Markov Process),它因俄罗斯数学家安德烈·马尔可夫而得名,代表数学中具有马尔可夫性质的离散随机过程。该过程中,每个状态的转移只依赖于之前的 n 个状态,这个过程被称为1个 n 阶的模型,其中 n 是影响转移状态的数目。最简单的马尔科夫过程就是一阶过程,每一个状态的转移只依赖于其之前的那一个状态。注意这和确定性系统不一样,因为这种转移是有概率的,而不是确定性的。

      马尔可夫链是随机变量 X1, … , Xn 的一个数列。这些变量的范围,即他们所有可能取值的集合,被称为“状态空间”,而 Xn  的值则是在时间 n 的状态。如果 Xn+1 对于过去状态的条件概率分布仅是 Xn 的一个函数,则

      这里 x 为过程中的某个状态。上面这个恒等式可以被看作是马尔可夫性质

      马尔可夫链的在很多应用中发挥了重要作用,例如,谷歌所使用的网页排序算法(PageRank)就是由马尔可夫链定义的。

      下图展示了天气这个例子中所有可能的一阶转移:

      注意一个含有 N 个状态的一阶过程有 N个状态转移。每一个转移的概率叫做状态转移概率 (state transition probability),就是从一个状态转移到另一个状态的概率。这所有的 N个概率可以用一个状态转移矩阵来表示,其表示形式如下:

      对该矩阵有如下约束条件:

      下面就是海藻例子的状态转移矩阵

      这个矩阵表示,如果昨天是晴天,那么今天有50%的可能是晴天,37.5%的概率是阴天,12.5%的概率会下雨,很明显,矩阵中每一行的和都是1。

      为了初始化这样一个系统,我们需要一个初始的概率向量:

      这个向量表示第一天是晴天。

      到这里,我们就为上面的一阶马尔科夫过程定义了以下三个部分:

      状态:晴天、阴天和下雨

      初始向量:定义系统在时间为0的时候的状态的概率

      状态转移矩阵:每种天气转换的概率

      所有的能被这样描述的系统都是一个马尔科夫过程

      然而,当马尔科夫过程不够强大的时候,我们又该怎么办呢?在某些情况下,马尔科夫过程不足以描述我们希望发现的模式。

      例如,一个隐居的人可能不能直观的观察到天气的情况,但是民间传说告诉我们海藻的状态在某种概率上是和天气的情况相关的。在这种情况下我们有两个状态集合,一个可以观察到的状态集合(海藻的状态)和一个隐藏的状态(天气状况)。我们希望能找到一个算法可以根据海藻的状况和马尔科夫假设来预测天气的状况。

      一个更现实的例子是语音识别,我们听到的声音是声带、喉咙和一起其他的发音器官共同作用的结果。这些因素相互作用,共同决定了每一个单词的声音,而一个语音识别系统检测的声音(可以观察的状态)是人体内部各种物理变化(隐藏的状态、引申一个人真正想表达的意思)产生的。

      某些语音识别设备把内部的发音机制作为一个隐藏的状态序列,把最后的声音看成是一个和隐藏的状态序列十分相似的可以观察到的状态的序列。在这两个例子中,一个非常重要的地方是隐藏状态的数目和可以观察到的状态的数目可能是不一样的。在一个有3种状态的天气系统(sunny、cloudy、rainy)中,也许可以观察到4种潮湿程度的海藻(dry、dryish、damp、 soggy)。在语音识别中,一个简单的发言也许只需要80个语素来描述,但是一个内部的发音机制可以产生不到80或者超过80种不同的声音。

      在上面的这些情况下,可以观察到的状态序列和隐藏的状态序列是概率相关的。于是我们可以将这种类型的过程建模为有一个隐藏的马尔科夫过程和一个与这个隐藏马尔科夫过程概率相关的并且可以观察到的状态集合。这就是本文重点介绍的隐马尔可夫模型。

      隐马尔可夫模型 (Hidden Markov Model) 是一种统计模型,用来描述一个含有隐含未知参数的马尔可夫过程。其难点是从可观察的参数中确定该过程的隐含参数,然后利用这些参数来作进一步的分析。下图是一个三个状态的隐马尔可夫模型状态转移图,其中x 表示隐含状态,y 表示可观察的输出,a 表示状态转换概率,b 表示输出概率。

      下图显示了天气的例子中隐藏的状态和可以观察到的状态之间的关系。我们假设隐藏的状态是一个简单的一阶马尔科夫过程,并且他们两两之间都可以相互转换。

      对 HMM 来说,有如下三个重要假设,尽管这些假设是不现实的。

      

      假设1:马尔可夫假设(状态构成一阶马尔可夫链)

      假设2:不动性假设(状态与具体时间无关)

      假设3:输出独立性假设(输出仅与当前状态有关)

      隐藏的状态和可观察到的状态之间有一种概率上的关系,也就是说某种隐藏状态 H 被认为是某个可以观察的状态 O1 是有概率的,假设为 P(O1 | H)。如果可以观察的状态有3种,那么很显然 P(O1 | H)+P(O2 | H)+ P(O3 | H) = 1

      这样,我们也可以得到一个另一个矩阵,称为混淆矩阵 (confusion matrix)。这个矩阵的内容是某个隐藏的状态被分别观察成几种不同的可以观察的状态的概率,在天气的例子中,这个矩阵如下图:

      上边的图示都强调了 HMM 的状态变迁。而下图则明确的表示出模型的演化,其中绿色的圆圈表示隐藏状态,紫色圆圈表示可观察到状态,箭头表示状态之间的依存概率,一个 HMM 可用一个5元组 { N, M, π,A,B } 表示,其中 N 表示隐藏状态的数量,我们要么知道确切的值,要么猜测该值,M 表示可观测状态的数量,可以通过训练集获得, π={πi} 为初始状态概率,A={aij} 为隐藏状态的转移矩阵 Pr(xt(i) | xt-1(j)),B={bik} 表示某个时刻因隐藏状态而可观察的状态的概率,即混淆矩阵,Pr(ot(i) | xt(j))。在状态转移矩阵和混淆矩阵中的每个概率都是时间无关的,即当系统演化时,这些矩阵并不随时间改变。对于一个 N 和 M 固定的 HMM 来说,用 λ={ π, A, B } 表示 HMM 参数。

      在正常的马尔可夫模型中,状态对于观察者来说是直接可见的。这样状态的转换概率便是全部的参数。而在隐马尔可夫模型中,状态并不是直接可见的,但受状态影响的某些变量则是可见的。每一个状态在可能输出的符号上都有一概率分布。因此输出符号的序列能够透露出状态序列的一些信息。

      在 HMM 中有三个典型问题:

      (一) 已知模型参数,计算某一给定可观察状态序列的概率

      

    假设我们已经有一个特定的隐马尔科夫模型 λ 和一个可观察状态序列集。我们也许想知道在所有可能的隐藏状态序列下,给定的可观察状态序列的概率。当给定如下一个隐藏状态序列:

      那么在 HMM 和这个隐藏状态序列的条件下,可观察状态序列的概率为:

      而隐藏状态序列在 HMM 条件下的概率为:

      因此,隐藏状态序列和可观察状态序列的联合概率为:

      那么所有可能的隐藏状态序列上,可观察状态序列的概率为:

      例如,我们也许有一个海藻的“Summer”模型和一个“Winter”模型,因为海藻在夏天和冬天的状态应该是不同的,我们希望根据一个可观察状态(海藻的潮湿与否)序列来判断现在是夏天还是冬天。

      我们可以使用前向算法来计算在某个特定的 HMM 下一个可观察状态序列的概率,然后据此找到最可能的模型。

      这种类型的应用通常出现在语音设别中,通常我们会使用很多 HMM,每一个针对一个特别的单词。一个可观察状态的序列是从一个可以听到的单词向前得到的,然后这个单词就可以通过找到满足这个可观察状态序列的最大概率的HMM 来识别。

      下面介绍一下前向算法 (Forward Algorithm)

      如何计算一个可观察序列的概率?

      1. 穷举搜索

      给定一个 HMM,我们想计算出某个可观察序列的概率。考虑天气的例子,我们知道一个描述天气和海藻状态的 HMM,而且我们还有一个海藻状态的序列。假设这个状态中的某三天是(dry,damp,soggy),在这三天中的每一天,天气都可能是晴朗,多云或者下雨,我们可以用下图来描述观察序列和隐藏序列:

      在这个图中的每一列表示天气的状态可能,并且每个状态都指向相邻的列的每个状态,每个状态转换在状态转移矩阵中都有一个概率。每一列的下面是当天的可观察的海藻的状态,在每种状态下出现这种可观察状态的概率是由混淆矩阵给出的。

      一个可能的计算可观察概率的方法是找到每一个可能的隐藏状态的序列,这里有3= 27种,这个时候的可观察序列的概率就是 Pr(dry, damp, soggy | HMM)=Pr(dry, damp, soggy | sunny, sunny, sunny) + . . . . + Pr(dry, damp, soggy | rainy, rainy, rainy)。

      很显然,这种计算的效率非常低,尤其是当模型中的状态非常多或者序列很长的时候。事实上,我们可以利用概率不随时间变化这个假设来降低时间的开销。

      2. 使用递归来降低复杂度

      我们可以考虑给定 HMM 的情况下,递归的计算一个可观察序列的概率。我们可以首先定义一个部分概率,表示达到某个中间状态的概率。接下来我们将看到这些部分概率是如何 在time=1 和 time = n (n > 1) 的时候计算的。

      假设一个T时间段的可观察序列是:

      1) 部分概率

      下面这张图表示了一个观察序列(dry,damp,soggy)的一阶转移

      我们可以通过计算到达某个状态的所有路径的概率和来计算到达某个中间状态的概率。比如说,t=2时刻,cloudy的概率用三条路径的概率之和来表示:

      我们用 αt(j) 来表示在 t 时刻是状态 j 的概率,αt(j)=Pr(观察状态 | 隐藏状态 j ) x Pr(t 时刻到达状态 j 的所有路径)。

      最后一个观察状态的部分概率就表示了整个序列最后达到某个状态的所有可能的路径的概率和,比如说在这个例子中,最后一列的部分状态是通过下列路径计算得到的:

      因为最后一列的部分概率是所有可能的路径的概率和,所以就是这个观察序列在给定 HMM 下的概率了。

      2) 计算 t=1时候的部分概率

      当 t=1 的时候,没有路径到某个状态,所以这里是初始概率,Pr(状态 j | t=0) = π(状态 j ),这样我们就可以计算 t=1 时候的部分概率为:

      因为在初始的时候,状态 j 的概率不仅和这个状态本身相关,还和观察状态有关,所以这里用到了混淆矩阵的值,k1 表示第一个观察状态,bjk1 表示隐藏状态是 j,但是观察成 k1 的概率。

      3) 计算 t>1 时候的部分概率

      还是看计算部分概率的公式是:αt(j) = Pr(观察状态 | 隐藏状态 j) x Pr(t 时刻到达状态 j 的所有路径)。 这个公式的左边是从混淆矩阵中已知的,我只需要计算右边部分,很显然右边是所有路径的和:

      需要计算的路径数是和观察序列的长度的平方相关的,但是 t 时刻的部分概率已经计算过了之前的所有路径,所以在 t+1 时刻只需要根据 t 时刻的概率来计算就可以了:

      这里简单解释下,bjk(t+1) 就是在 t+1 时刻的第 j 个隐藏状态被认为是当前的观察状态的概率,后面一部分是所有t时刻的隐藏状态到 t+1 时候的隐藏状态j的转移的概率的和。这样我们每一步的计算都可以利用上一步的结果,节省了很多时间。

      4) 公式推导

      5) 降低计算复杂度

      我们可以比较穷举和递归算法的复杂度。假设有一个 HMM,其中有 n 个隐藏状态,我们有一个长度为 T 的观察序列。

      穷举算法的需要计算所有可能的隐藏序列:

      需要计算:

      很显然穷举算法的时间开销是和 T 指数相关的,即 NT,而如果采用递归算法,由于我们每一步都可以利用上一步的结果,所以是和 T 线性相关的,即复杂度是 N2T。

      这里我们的目的是在某个给定的 HMM 下,计算出某个可观察序列的概率。我们通过先计算部分概率的方式递归的计算整个序列的所有路径的概率,大大节省了时间。在 t=1 的时候,使用了初始概率和混淆矩阵的概率,而在t时刻的概率则可以利用 t-1 时刻的结果。

      这样我们就可以用递归的方式来计算所有可能的路径的概率和,最后,所有的部分概率的计算公式为

      使用天气的例子,计算 t=2 时刻的 cloudy 状态的概率方法如图:

      我们使用前向算法在给定的一个 HMM 下计算某个可观察序列的概率。前向算法主要采用的是递归的思想,利用之前的计算结果。有了这个算法,我们就可以在一堆 HMM 中,找到一个最满足当前的可观察序列的模型(前向算法计算出来的概率最大)。

      (二) 根据可观察状态的序列找到一个最可能的隐藏状态序列

      和上面一个问题相似的并且更有趣的是根据可观察序列找到隐藏序列。在很多情况下,我们对隐藏状态更有兴趣,因为其包含了一些不能被直接观察到的有价值的信息。比如说在海藻和天气的例子中,一个隐居的人只能看到海藻的状态,但是他想知道天气的状态。这时候我们就可以使用 Viterbi 算法来根据可观察序列得到最优可能的隐藏状态的序列,当然前提是已经有一个 HMM

      另一个广泛使用 Viterbi 算法的领域是自然语言处理中的词性标注。句子中的单词是可以观察到的,词性是隐藏的状态。通过根据语句的上下文找到一句话中的单词序列的最有可能的隐藏状态序列,我们就可以得到一个单词的词性(可能性最大)。这样我们就可以用这种信息来完成其他一些工作。

      下面介绍一下维特比算法 (Viterbi Algorithm)

      一.如何找到可能性最大的隐藏状态序列?

      通常我们都有一个特定的 HMM,然后根据一个可观察状态序列去找到最可能生成这个可观察状态序列的隐藏状态序列。

      1. 穷举搜索

      我们可以在下图中看到每个隐藏状态和可观察状态的关系。

      通过计算所有可能的隐藏序列的概率,我们可以找到一个可能性最大的隐藏序列,这个可能性最大的隐藏序列最大化了 Pr(观察序列 | 隐藏状态集)。比如说,对于上图中的可观察序列 (dry damp soggy),最可能的隐藏序列就是下面这些概率中最大的:

      Pr(dry, damp, soggy | sunny, sunny, sunny), ……,Pr(dry, damp, soggy | rainy, rainy, rainy)

      这个方法是可行的,但是计算代价很高。和前向算法一样,我们可以利用转移概率在时间上的不变性来降低计算的复杂度。

      2. 使用递归降低复杂度

      在给定了一个可观察序列和HMM的情况下,我们可以考虑递归的来寻找最可能的隐藏序列。我们可以先定义一个部分概率 δ,即到达某个中间状态的概率。接下来我们将讨论如何计算 t=1 和 t=n (n>1) 的部分概率。

      注意这里的部分概率和前向算法中的部分概率是不一样的,这里的部分概率表示的是在t时刻最可能到达某个状态的一条路径的概率,而不是所有概率之和

      1) 部分概率和部分最优路径

      考虑下面这个图以及可观察序列 (dry, damp, soggy) 的一阶转移

      对于每一个中间状态和终止状态 (t=3) 都有一个最可能的路径。比如说,在 t=3 时刻的三个状态都有一个如下的最可能的路径:

      我们可以称这些路径为部分最优路径。这些部分最优路径都有一个概率,也就是部分概率 δ。和前向算法中的部分概率不一样,这里的概率只是一个最可能路径的概率,而不是所有路径的概率和。

      我们可以用 δ(i, t) 来表示在t时刻,到状态i的所有可能的序列(路径)中概率最大的序列的概率,部分最优路径就是达到这个最大概率的路径,对于每一个时刻的每一个状态都有这样一个概率和部分最优路径。

      最后,我们通过计算 t=T 时刻的每一个状态的最大概率和部分最优路径,选择其中概率最大的状态和它的部分最优路径来得到全局的最优路径。

      2) 计算 t=1 时刻的部分概率

      当 t=1 时刻的时候,到达某个状态最大可能的路径还不存在,但是我们可以直接使用在 t=1 时刻某个状态的概率和这个状态到可观察序列 k1 的转移概率:

      3) 计算 t>1 时刻的部分概率

      接下来我们可以根据 t-1 时刻的部分概率来求 t 时刻的部分概率

      我们可以计算所有到状态 X 的路径的概率,找到其中最可能的路径,也就是局部最优路径。注意到这里,到达X的路径必然会经过 t-1 时刻的 A、B 和 C,所以我们可以利用之前的结果。达到X的最可能的路径就是下面三个之一:

      (状态序列),. . .,A,X (状态序列),. . .,B,X (状态序列),. . .,C,X

      我们需要做的就是找到以 AX、BX 和 CX 结尾的路径中概率最大的那个。

      根据一阶马尔科夫的假设,一个状态的发生之和之前的一个状态有关系,所以X在某个序列的最后发生的概率只依赖于其之前的一个状态:

    Pr (到达A的最优路径) . Pr (X | A) . Pr (观察状态 | X)

      有个了这个公式,我们就可以利用t-1时刻的结果和状态转移矩阵和混淆矩阵的数据:

      将上面这个表达式推广一下,就可以得到 t 时刻可观察状态为 kt 的第 i 个状态的最大部分概率的计算公式:

      其中 aji 表示从状态 j 转移到状态 i 的概率,bikt 表示状态i被观察成 kt 的概率。

      4) 后向指针

      考虑下图

      在每一个中间状态和结束状态都有一个部分最优概率 δ(i, t)。但是我们的目的是找到最可能的隐藏状态序列,所以我们需要一个方法去记住部分最优路径的每一个节点。

      考虑到要计算 t 时刻的部分概率,我们只需要知道 t-1 时刻的部分概率,所以我们只需要记录那个导致了 t 时刻最大部分概率的的状态,也就是说,在任意时刻,系统都必须处在一个能在下一时刻产生最大部分概率的状态。如下图所示:

      我们可以利用一个后向指针 φ 来记录导致某个状态最大局部概率的前一个状态,即

      这里 argmax 表示能最大化后面公式的j值,同样可以发现这个公式和 t-1 时刻的部分概率和转移概率有关,因为后向指针只是为了找到“我从哪里来”,这个问题和可观察状态没有关系,所以这里不需要再乘上混淆矩阵因子。全局的行为如下图所示:

      5) 优点

      使用 viterbi 算法对一个可观察状态进行解码有两个重要的优点:

      a) 通过使用递归来减少复杂度,这点和之前的前向算法是一样的

      b) 可以根据可观察序列找到最优的隐藏序列,这个的计算公式是:

    其中 

      这里就是一个从左往右翻译的过程,通过前面的翻译结果得到后面的结果,起始点是初始向量 π。

      3. 补充

      但在序列某个地方有噪声干扰的时候,某些方法可能会和正确答案相差的较远。但是 Viterbi 算法会查看整个序列来决定最可能的终止状态,然后通过后向指针来找到之前的状态,这对忽略孤立的噪声非常有用。

      Viterbi 算法提供了一个根据可观察序列计算隐藏序列的很高效的方法,它利用递归来降低计算复杂度,并且使用之前全部的序列来做判断,可以很好的容忍噪声。

      在计算的过程中,这个算法计算每一个时刻每一个状态的部分概率,并且使用一个后向指针来记录达到当前状态的最大可能的上一个状态。最后,最可能的终止状态就是隐藏序列的最后一个状态,然后通过后向指针来查找整个序列的全部状态。

      (三) 根据观察到的序列集来找到一个最有可能的 HMM。 

      在很多实际的情况下,HMM 不能被直接的判断,这就变成了一个学习问题,因为对于给定的可观察状态序列 O 来说,没有任何一种方法可以精确地找到一组最优的 HMM 参数 λ 使 P(O | λ) 最大,于是人们寻求使其局部最优的解决办法,而前向后向算法(也称为Baum-Welch算法)就成了 HMM 学习问题的一个近似的解决方法。

      前向后向算法首先对于 HMM 的参数进行一个初始的估计,但这个很可能是一个错误的猜测,然后通过对于给定的数据评估这些参数的的有效性并减少它们所引起的错误来更新 HMM 参数,使得和给定的训练数据的误差变小,这其实是机器学习中的梯度下降的思想。

      对于网格中的每一个状态,前向后向算法既计算到达此状态的“前向”概率,又计算生成此模型最终状态的“后向”概率,这些概率都可以通过前面的介绍利用递归进行高效计算。可以通过利用近似的 HMM 模型参数来提高这些中间概率从而进行调整,而这些调整又形成了前向后向算法迭代的基础。

      另外,前向后向算法是 EM 算法的一个特例,它避免了 EM 算法的暴力计算,而采用动态规划思想来解决问题,Jelinek 在其书《Statistical Methods for Speech Recognition》中对前向后向算法与 EM 算法的关系进行了详细描述,有兴趣的读者可以参考这本书。

      类似于上面讲到的前向算法,我们也可以定义后向变量 βt(i) 来计算给定当前隐藏状态 i 时,部分观察序列 ot+1,ot+2,…,oT的概率,即:

      与前向算法类似,我们也可以通过迭代算法有效计算 βt(i),计算公式如下:

      其中

      进一步我们可以发现

      因此

      下面开始介绍前向后向算法

      首先我们需要定义两个辅助变量,这两个变量可以用前文介绍过的前向变量和后向变量进行定义。

      第一个变量定义为 t 时状态 i 和 t+1 时状态 j 的概率,即

      该变量在网格中所代表的关系如下图所示:

      

      该等式等价于

      利用前向变量和后向变量,上式可以表示为

      第二个变量定义为后验概率,也就是在给定观察状态序列和 HMM 的情况下,t 时状态 i 的概率,即

      利用前向变量和后向变量,上式可以表示为

      因此,下式为在任意时刻状态 i 的期望,也就是从状态 i 转移到观察状态 o 的期望

      同样,下式也就是从状态 i 转移到状态 j 的期望

      我们可以发现定义的这两个变量之间的关系为

      下面介绍前向后向算法的参数学习过程,在学习的过程中,不断更新 HMM 的参数,从而使得 P(O | λ) 最大。我们假设初始的 HMM 参数为  λ={ π, A, B },首先计算前向变量 α 和后向变量 β,再根据刚刚介绍的公式计算期望 ξ 和 ζ,最后,根据下面的3个重估计公式更新 HMM 参数。

      如果我们定义当前的 HMM 模型为 λ={ π,A,B },那么可以利用该模型计算上面三个式子的右端;我们再定义重新估计的 HMM 模型为,那么上面三个式子的左端就是重估的 HMM 模型参数。Baum 及他的同事在70年代证明了,因此如果我们迭代地计算上面三个式子,由此不断地重新估计 HMM 的参数,那么在多次迭代后可以得到 HMM 模型的一个最大似然估计。不过需要注意的是,前向后向算法所得的这个最大似然估计是一个局部最优解。

  • 相关阅读:
    TAOCP读书笔记——Search(1)
    JAVA进程间通信
    C++ Primer 阅读总结 (1)
    初来乍到
    [C#]手把手教你打造Socket的TCP通讯连接(二)
    [C#]手把手教你打造Socket的TCP通讯连接(三)
    数字ToString作为货币显示并且带小数
    [C#]手把手教你打造Socket的TCP通讯连接(一)
    [C#]手把手教你打造Socket的TCP通讯连接(四)
    WPF中使用ItemTemplate把所有Item都控制为指定大小
  • 原文地址:https://www.cnblogs.com/mmziscoming/p/5016927.html
Copyright © 2020-2023  润新知