[深度学习] RNN对于变长序列的处理方法, 为什么RNN需要mask

[深度学习] RNN对于变长序列的处理方法, 为什么RNN需要mask一Padding文本数据在处理的时候,由于各样本的长度并不一样,有的句子长有的句子短。抛开动态图、静态图模型的差异,由于需要进行矩阵运算,句长需要是等长的才可以,这就需要padding操作。padding一般是用最长的句子长度为最大长度,然后其他样本补0到最大长度,这样样本就是等长的了。但是注意padding后的样本如果不作处理只用普通的循环神经网络来做的话其实是有影响的,因为即使输入…

大家好,又见面了,我是你们的朋友全栈君。如果您正在找激活码,请点击查看最新教程,关注关注公众号 “全栈程序员社区” 获取激活教程,可能之前旧版本教程已经失效.最新Idea2022.1教程亲测有效,一键激活。

Jetbrains全系列IDE使用 1年只要46元 售后保障 童叟无欺

一 Padding

文本数据在处理的时候,由于各样本的长度并不一样,有的句子长有的句子短。抛开动态图、静态图模型的差异,由于需要进行矩阵运算,句长需要是等长的才可以,这就需要padding操作。padding一般是用最长的句子长度为最大长度,然后其他样本补0到最大长度,这样样本就是等长的了。

但是注意padding后的样本如果不作处理只用普通的循环神经网络来做的话其实是有影响的,因为即使输入的是0,做了embedding后也不是0,而且还有上一时刻隐藏层,所以输出不会是0。但是在实际使用中,padding的这种操作如果不做特殊处理,模型也是可以学到它是无用的padding。

 

[深度学习] RNN对于变长序列的处理方法, 为什么RNN需要mask

但是这会有一个问题,什么问题呢?比如上图,句子“Yes”只有一个单词,但是padding了5的pad符号,这样会导致LSTM对它的表示通过了非常多无用的字符,这样得到的句子表示就会有误差,更直观的如下图:

[深度学习] RNN对于变长序列的处理方法, 为什么RNN需要mask

 

结论:直接填充0,在数据运算上没有问题,但是从序列的整个含义来说,这是不合理的,所以一般情况下不能这么做。

 

RNN

在使用RNN based model处理序列的应用中,如果使用并行运算batch sample,我们几乎一定会遇到变长序列的问题。
通常解决变长的方法主要是将过长的序列截断,将过短序列用0补齐到一个固定长度(例如max_length)。
最后由n个sample组成的dataset能形成一个shape == (n, max_length)的矩阵。然后可以将这个矩阵传递到后续的模型中使用。
然而我们可以很明显,如果用0或者其他整数补齐,势必会影响到模型自身(莫名其妙被输入很多个0,显然是有问题的)。有什么方法能够做到“能够使用一个二维矩阵作为输入数据集,从而达到并行化的同时,还能让RNN模型自行决定真正输入其中的序列的长度。
 

Mask主要用于解决RNN中输入有多种长度的问题。要输入RNN中的是尺寸固定的张量,即批尺寸(batch size) * 序列长度(sequence length) * 嵌入大小(embedding size)。因为RNN在计算状态向量时不仅考虑当前,也考虑前一次的状态向量,如果为了维持真实长度,采用补0的方式,在进行状态向量计算的时候也会包含进用0补上的位置,而且这种方式无法进行彻底的屏蔽。由于喂到模型中是fixed-size tensor(如256*100*50, batch size * sequence length * embedding size), RNN需要用mask来maintain序列真实长度,从而在计算loss的时候去除掉padding的部分。

相比于补0,Mask会得到不同的状态向量。对于每一个用0初始化的的样本,我们建立一个Mask,并使其长度与数据集中最长的序列相同。然后样本中所有有数值的地方,我们用1把Mask中对应的位置填充起来。

举个例子,数据集中最长的序列长度为10,所以所有的Mask将以如下的方式初始化:

mask = [ 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.]

同时,我们还有一个如下的样本:

a = [ 2., 0. ,5. ,6. ]

现在我们用1将Mask中所有有数值的地方填充起来,因而得到以下的Mask:

mask_a = [ 1., 1., 1., 1., 0., 0., 0., 0., 0., 0.]

在这之后,我们将样本与mask输入RNN中,RNN将会把所有没有值的的地方加上0,所以a变成了:

a_hood = [ 2., 0. ,5. ,6., 0., 0., 0., 0., 0., 0.]

但是如果我们任由RNN用这种补0的方式,RNN会认为所有的序列长度都为10,并且在计算时用上所有的补上的0。

mask_a = [ 1., 1., 1., 1., 0., 0., 0., 0., 0., 0.]

而此时mask_a的作用就是让RNN跳过所有Mask为0的输入,复制cell中前一次的隐藏状态;对于Mask为1的输入RNN将按常规处理。

CNN

对于CNN来说,首先它的输入已经是固定尺寸,不需要Mask,其次就算用上Mask,结果和补0一样,所以采用补0这种方便的方法,而CNN是卷积操作,补0的位置对卷积结果没有影响,即补0和mask两种方式的结果是一样的,因此大家为了省事起见,就普遍在CNN使用补0的方法了。CNN的的输入本身就是fixed-size,所以不需要mask。

 

2. Keras

keras中对变长rnn的使用应该是最简单的了,只需设置embedding的参数mask_zero为true就可以了,注意设置为true后,需要后面的所有层都能够支持mask,比如LSTM之类的层。这里详细说一下当用预训练的词向量的时候,需要在训练好的词向量权重前补一个标签0的向量,并把embedding层设置为trainable=false

 

2.1 padding 0

import keras as ks
import numpy as np
 
'''
#这是原始的输入数据,一共四组样本(四个句子),没组样本的时间跨度为3,即timesteps=3,每一个数字表示一个单词
#现在我想把每一个数字(即单词)转化成一个三维向量
#即
4->[#,#,#]
10->[#,#,#]
5->[#,#,#]
2->[#,#,#]
.
..依次下去
'''
input_array=np.array([[4,10,5],[2],[3,7,9],[2,5]])
X=ks.preprocessing.sequence.pad_sequences(input_array,maxlen=3,padding='post')
print(X)
 
model = ks.models.Sequential()
model.add(ks.layers.Embedding(100, 3, input_length=3,mask_zero=False))
rnn_layer=ks.layers.SimpleRNN(5,return_sequences=True)
model.add(rnn_layer)
 
 
model.compile('rmsprop', 'mse')
output_array = model.predict(X)
print(output_array)

此处在使用Embedding层的时候并没有传递关键字参数mask_zero=True,很多人觉得如果是传入了关键字参数mask_zero=True,那么0所对应的单词转化成向量之后应该全部是0,这是不正确的,这个地方不管有没有传入关键字参数mask_zero=True,输出的结果都是一样的,单词0都不会全部为0向量。

既然关键字参数mask_zero=True有没有都一样,那还需要他有什么用?这其实是后面的使用要用到的地方,因为Embedding层只能放在网络的第一层,用来对数据进行处理,当后面要跟循环层的时候,关键字参数mask_zero=True就发挥出作用了。
 

代码如下,只需要修改上面代码的一个地方,将false改为True即可:

model.add(ks.layers.Embedding(100, 3, input_length=3,mask_zero=True))

总结:Embedding的关键字参数mask_zero=True不会改变Word2vector的结果,即不是讲所有补充的0全部变为0向量,这个很重要,关键字参数mask_zero=True的作用是决定了后面的“循环层”是否会将补充的0单词参与运算,如果设置为True,就是“覆盖掉0”的意思,自然就不参与运算,如果设置为False,就是不覆盖0,0也会参与运算的意思。

 

3. TensorFlow

TensorFlow中对变长rnn的支持靠tf.dynamic_rnn

import tensorflow as tf
import numpy as np
import pprint
 
#样本数据为(samples,timesteps,features)的形式,其中samples=4,features=3,timesteps不固定,第二个样本只有一个步长,第四个样本只有2个步长
train_X = np.array([
[[0, 1, 2], [9, 8, 7],[3,6,8]], 
[[3, 4, 5]], 
[[6, 7, 8], [6, 5, 4],[1,7,4]], 
[[9, 0, 1], [3, 7, 4]]
])
#样本数据为(samples,timesteps,features)的形式,其中samples=4,timesteps=3,features=3,其中第二个、第四个样本所缺的样本自动补零
train_X = np.array([
[[0, 1, 2], [9, 8, 7],[3,6,8]], 
[[3, 4, 5], [0, 0, 0],[0,0,0]], 
[[6, 7, 8], [6, 5, 4],[1,7,4]], 
[[9, 0, 1], [3, 7, 4],[0,0,0]]
])
 
#  tensorflow处理变长时间序列的处理方式,首先每一个循环的cell里面有5个神经元
basic_cell=tf.nn.rnn_cell.BasicRNNCell(5)
 
#创建一个容纳训练数据的容器placeholder
X=tf.placeholder(tf.float32,shape=[None,3,3])
 
# 构建一个向量,这个向量专门用来存储每一个样本中的timesteps的数目,这个是核心所在
seq_length = tf.placeholder(tf.int32, [None])
 
#在使用dynamic_rnn的时候,传递关键字参数 sequence_length
outputs, states = tf.nn.dynamic_rnn(basic_cell, X, dtype=tf.float32,sequence_length=seq_length)
 
#实际上就是没一个样本的步长数所组成的一个数组
seq_length_batch = np.array([3, 1, 3, 2])
 
#在我们运行RNN的时候,需要将输入X和样本长度seq_length都传输进去,如下:
with tf.Session() as sess:
    sess.run(tf.global_variables_initializer())
    outputs_val, states_val = sess.run(
        [outputs, states], feed_dict={X:train_X,seq_length:seq_length_batch})
    
    pprint.pprint(outputs_val)
    pprint.pprint(states_val)
    print('==============================================')
    print(np.shape(outputs_val))
    print(np.shape(states_val))

 

由于TensorFlow是静态图模型,普通的rnn在计算图搭建好后,所有batch中的sequence长度都要是一样的,而dynamic_rnn只需要每个batch内部的sequence长度一样就可以了。sequence_length是每一句话未padding的实际长度,当rnn计算到padding的地方就不再更新权重,而是直接输出和上一个时刻相同的hidden state。

Tensorflow中对变长序列的输入是通过在dynamic_rnn()函数中的sequence_length参数来指定的,这个参数是一个一维数组,每一个数组的元素为所对应的那一组样本的timesteps数目,而填充的01根本就没有参与运算,只不过是一个“空壳子”,为了保持输入数据的维度完整性而存在的
 

 

4. Pytorch

像pytorch这种动态图模型就比较方便了,可以像写python代码一样任意的用while和for循环,每一次运行都会从新建立计算图。比如我们可以将batch设为1,每一步都只输入一句话,运行的时候从新设置网络中的rnn的sequence长度,这是可以的。当然,batch为1的话模型运行起来会比较慢,下面介绍另一种方法。

主要是用这三个函数的用法。

  1. torch.nn.utils.rnn.PackedSequence()
  2. torch.nn.utils.rnn.pack_padded_sequence()
  3. torch.nn.utils.rnn.pad_packed_sequence()

1、torch.nn.utils.rnn.PackedSequence()

NOTE: 这个类的实例不能手动创建。它们只能被 pack_padded_sequence() 实例化。

PackedSequence对象包括:

  • 一个data对象:一个torch.Variable(令牌的总数,每个令牌的维度),在这个简单的例子中有五个令牌序列(用整数表示):(18,1)
  • 一个batch_sizes对象:每个时间步长的令牌数列表,在这个例子中为:[6,5,2,4,1]

用pack_padded_sequence函数来构造这个对象非常的简单:

 

[深度学习] RNN对于变长序列的处理方法, 为什么RNN需要mask

PackedSequence对象有一个很不错的特性,就是我们无需对序列解包(这一步操作非常慢)即可直接在PackedSequence数据变量上执行许多操作。特别是我们可以对令牌执行任何操作(即对令牌的顺序/上下文不敏感)。

 

2、torch.nn.utils.rnn.pack_padded_sequence()

这里的pack,理解成压紧比较好。 将一个填充过的变长序列压紧。(填充时候,会有冗余,所以压紧一下)

输入的形状可以是(T×B×* )。T是最长序列长度,B是batch size,*代表任意维度(可以是0)。如果batch_first=True的话,那么相应的 input size 就是 (B×T×*)。

Variable中保存的序列,应该按序列长度的长短排序,长的在前,短的在后(特别注意需要进行排序)。即input[:,0]代表的是最长的序列,input[:, B-1]保存的是最短的序列

NOTE: 只要是维度大于等于2的input都可以作为这个函数的参数。你可以用它来打包labels,然后用RNN的输出和打包后的labels来计算loss。通过PackedSequence对象的.data属性可以获取 Variable

参数说明:

  • input (Variable) – 变长序列 被填充后的 batch
  • lengths (list[int]) – Variable 中 每个序列的长度。(知道了每个序列的长度,才能知道每个序列处理到多长停止
  • batch_first (bool, optional) – 如果是True,input的形状应该是B*T*size。

返回值:

一个PackedSequence 对象。

具体代码如下:

embed_input_x_packed = pack_padded_sequence(embed_input_x, sentence_lens, batch_first=True)
encoder_outputs_packed, (h_last, c_last) = self.lstm(embed_input_x_packed)

此时,返回的h_last和c_last就是剔除padding字符后的hidden state和cell state,都是Variable类型的。代表的意思如下(各个句子的表示,lstm只会作用到它实际长度的句子,而不是通过无用的padding字符,下图用红色的打钩来表示):

 

[深度学习] RNN对于变长序列的处理方法, 为什么RNN需要mask

但是返回的output是PackedSequence类型的,可以使用:

encoder_outputs, _ = pad_packed_sequence(encoder_outputs_packed, batch_first=True)

将encoderoutputs在转换为Variable类型,得到的_代表各个句子的长度。

3、torch.nn.utils.rnn.pad_packed_sequence()

填充packed_sequence

上面提到的函数的功能是将一个填充后的变长序列压紧。 这个操作和pack_padded_sequence()是相反的。把压紧的序列再填充回来。

返回的Varaible的值的size是 T×B×*T 是最长序列的长度,B 是 batch_size,如果 batch_first=True,那么返回值是B×T×*

Batch中的元素将会以它们长度的逆序排列。

参数说明:

  • sequence (PackedSequence) – 将要被填充的 batch

  • batch_first (bool, optional) – 如果为True,返回的数据的格式为 B×T×*

返回值:

    一个tuple,包含被填充后的序列,和batch中序列的长度列表。

 

4.1. Pytorch代码举例

将原始数据padding后,和sequence_length一起传入pack中。注意句子需要按实际长度从长到短排序,sequence_length也要相应对齐。

import torch
import torch.nn as nn
from torch.nn import utils as nn_utils

batch_size = 2
max_length = 3
hidden_size = 2
n_layers = 1

print("1.--------------")
tensor_in = torch.FloatTensor([[1, 0, 0], [1, 2, 3]]).resize_(2, 3, 1)
seq_lengths = [1, 3]  # list of integers holding information about the batch size at each sequence step
print("seq_lengths:\n", seq_lengths)
print("tensor_in:\n", tensor_in)

print("2.--------------")
seq_lens = torch.IntTensor([1, 3])
_, idx_sort = torch.sort(seq_lens, dim=0, descending=True)
_, idx_unsort = torch.sort(idx_sort, dim=0)
order_seq_lengths = torch.index_select(seq_lens, dim=0, index=idx_sort)
order_tensor_in = torch.index_select(tensor_in, dim=0, index=idx_sort)
print("order_seq_lengths:\n", order_seq_lengths)
print("order_tensor_in:\n", order_tensor_in)

print("3.--------------")
x_packed = nn_utils.rnn.pack_padded_sequence(order_tensor_in, order_seq_lengths, batch_first=True)
rnn = nn.RNN(1, hidden_size, n_layers, batch_first=True)
h0 = torch.randn(n_layers, batch_size, hidden_size)
y_packed, h_n = rnn(x_packed, h0)
print('x_packed:\n', x_packed)
print('y_packed:\n', y_packed)

print("4.--------------")
y_sort, length = nn_utils.rnn.pad_packed_sequence(y_packed, batch_first=True)
print("sort unpacked output:\n", y_sort)
print(y_sort.shape)

print("5.--------------")
# unsort output to original order
y = torch.index_select(y_sort, dim=0, index=idx_unsort)
print("org unpacked output:\n", y)
print(y.shape)

print("6.--------------")
# unsort output to original order
last_h = torch.index_select(h_n[-1], dim=0, index=idx_unsort)
print("last hidden state:\n", last_h)


[深度学习] RNN对于变长序列的处理方法, 为什么RNN需要mask  [深度学习] RNN对于变长序列的处理方法, 为什么RNN需要mask

[深度学习] RNN对于变长序列的处理方法, 为什么RNN需要mask

 

4.2. Pytorch代码说明

pack函数会生成一个PackedSequence,包含data和batch_size。注意上面pack函数设置的batch_first为true,输入的维度是(batch_size, sequence_length, 1),也就是(2,3,1),我们将数据排列好后如下

[1,2,3]

[1,0,0]

data的数据形式是从第一列开始的一个一个的值,不包括0。batch_sizes是第一列有两个有效值,第二列有一个,第三列有一个。这样排列的原因是batch做矩阵运算的时候网络是先计算所有句子的第一位,然后第二位,第三位。

理解这里的PackedSequence是关键。

前面说到,RNN其实就是在循环地 forward。在上面这个例子中,它每次 forward 的数据是这样的:

[深度学习] RNN对于变长序列的处理方法, 为什么RNN需要mask

第一个序列中,由于两个样本都有数据,所以可以看作是 batch_size=2 的输入,后面两个序列只有第一个样本有数据,所以可以看作是 batch_size=1 的输入。因此,我们其实可以把这三个序列的数据分解为三个 batch 样本,只不过 batch 的大小分别为 2,1,1。到这里你应该清楚PackedSequence里的databatch_size是什么东西了吧,其实就是把我们的输入数据重新整理打包成data,同时根据我们传入的 seqlist 计算batch_size,然后,RNN会根据batch_size从打包好的data里面取数据,然后一遍遍的执行 forward 函数。

理解这一步后,主要难点就解决了。

RNN的输出

从文档中可以看出,RNN输出两个东西:outputh_n。其中,h_n是跑完整个时间序列后 hidden state 的数值。但output又是什么呢?

之前不是说过原始的RNN只输出 hidden state 吗,为什么这里又会有一个output?其实,这个output并不是我们理解的网络最后的 output vector,而是每次 forward 后计算得到的 hidden state。毕竟h_n只保留了最后一步的 hidden state,但中间的 hidden state 也有可能会参与计算,所以 pytorch 把中间每一步输出的 hidden state 都放到output中,因此,你可以发现这个output的维度是(seq_len, batch, num_directions * hidden_size)

不过,如果你之前用pack_padded_sequence打包过数据,那么为了保证输入输出的一致性,pytorch 也会把output打包成一个PackedSequence对象,我们将上面例子的数据输入RNN ,看看输入与输出是什么样子的:

 

[深度学习] RNN对于变长序列的处理方法, 为什么RNN需要mask

输出的PackedSequence中包含两部分,其中data才是我们要的output。但这个output的 shape 并不是(seq_len, batch, num_directions * hidden_size),因为 pytorch 已经把输入数据中那些填充的 0 去掉了,因此输出来的数据对应的是真实的序列长度。

我们要把它重新填充回一个方方正正的 tensor 才方便处理,这里会用到另一个相反的操作函数torch.nn.utils.pad_packed_sequence

[深度学习] RNN对于变长序列的处理方法, 为什么RNN需要mask

现在,这个output的 shape 就是一个标准形式了。

输入输出顺序就一一对应了。

接下来可以从y中取出( hidden state) 或者一般直接使用y,接个全连接层fc之类的,得到真正意义上的 output vector。

取出last hidden state 

[深度学习] RNN对于变长序列的处理方法, 为什么RNN需要mask
最后要注意一点,因为pack_padded_sequence把输入数据按照 seq_lenseq_len 从大到小重新排序了,所以后面在计算 loss 的时候,要么把output的顺序重新调整回去,要么把 target 数据的顺序也按照新的 seq_lenseq_len 重新排序。代码中已经调整回原来的顺序了。当 target 是 label 时,调整起来还算方便,但如果 target 也是序列类型的数据,可能会多点体力活。

 

 

 

 

 

这样综上所述,RNN在处理类似变长的句子序列的时候,我们就可以配套使用torch.nn.utils.rnn.pack_padded_sequence()以及torch.nn.utils.rnn.pad_packed_sequence()来避免padding对句子表示的影响

 

参考:

  1. https://zhuanlan.zhihu.com/p/34418001
  2. https://blog.csdn.net/gt362ll/article/details/83653475
  3. https://blog.csdn.net/u010223750/article/details/71079036
  4. https://www.cnblogs.com/lindaxin/p/8052043.html
  5. https://www.cnblogs.com/jermmyhsu/p/10020308.html
  6. https://blog.csdn.net/qq_27825451/article/details/88991529
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请联系我们举报,一经查实,本站将立刻删除。

发布者:全栈程序员-站长,转载请注明出处:https://javaforall.net/179077.html原文链接:https://javaforall.net

(0)
上一篇 2022年8月30日 下午5:16
下一篇 2022年8月30日 下午5:16


相关推荐

  • 初始化列表中初始化数组

    初始化列表中初始化数组学习了一下前缀树 需要用到一个结构体保存两个指针 如下 structNode Node nexts 0 nullptr nexts 1 nullptr Node nexts 2 能不能将初始化操作放在初始化列表中呢 如下以上两种写法都有问题 突然想到 数组应该用大括号初始化啊就酱

    2026年3月26日
    2
  • java详细学习路线及路线图

    java详细学习路线及路线图java详细路线:原文出自点击打开链接本文将告诉你学习Java需要达到的30个目标,学习过程中可能遇到的问题,及学习路线。希望能够对你的学习有所帮助。对比一下自己,你已经掌握了这30条中的多少条了呢?路线Java发展到现在,按应用来分主要分为三大块:J2SE,J2ME和J2EE。这三块相互补充,应用范围不同。J2SE就是Java2的标准版,主要用于…

    2022年6月12日
    31
  • 【原创】通过 ioctl + FIONREAD 判定数据可读「建议收藏」

    【原创】通过 ioctl + FIONREAD 判定数据可读「建议收藏」【原创】通过ioctl+FIONREAD判定数据可读摩云飞 2016-05-1209:57:51 浏览470 评论0libevent ioctl FIONREAD摘要: 在排查业务bug的过程中,看到如下两种输出信息: TCP连接正常情况下,进行数据读取 14:00:38epoll_ctl(26,EPOLL_CTL_MOD,31,{EPOLLIN

    2022年7月23日
    17
  • 多元线性回归推导过程

    多元线性回归推导过程接上篇 人工智能开篇常用算法一多元线性回归详解 1 此次我们来学习人工智能的第一个算法 多元线性回归 文章会包含必要的数学知识回顾 大部分比较简单 数学功底好的朋友只需要浏览标题 简单了解需要哪些数学知识即可 本章主要包括以下内容数学基础知识回顾什么是多元线性回归多元线性回归的推导过程详解如何

    2025年8月1日
    5
  • 新手小白学JAVA 冒泡排序

    新手小白学JAVA 冒泡排序3冒泡排序bubble3.1概念冒泡排序(BubbleSort),是一种计算机科学领域的较简单的排序算法。它重复地走访过要排序的元素列,依次比较两个相邻的元素,如果他们的顺序(如从大到小、首字母从A到Z)错误就把他们交换过来。走访元素的工作是重复地进行直到没有相邻元素需要交换,也就是说该元素已经排序完成。这个算法的名字由来是因为越大的元素会经由交换慢慢“浮”到数列的顶端(升序或降序排列),就如同碳酸饮料中二氧化碳的气泡最终会上浮到顶端一样,故名“冒泡排序”。3.2形式相邻比较,从小到大

    2022年7月19日
    20
  • ofbiz初级教程

    ofbiz初级教程本教程是 ofbiz 基本应用 它涵盖了 OFBiz 应用程序开发过程的基本原理 目标是使开发人员熟悉最佳实践 编码惯例 基本控制流程以及开发人员对 OFBiz 定制所需的所有其他方面 nbsp nbsp nbsp nbsp nbsp nbsp nbsp nbsp 本教程将帮助您在 OFBiz 中构建您的第一个 演示应用程序 nbsp nbsp nbsp nbsp nbsp nbsp 概述 OFBiz 简介 nbsp nbsp nbsp nbsp nbsp nbsp 设置和运行 OFBiz nbsp nbsp nbsp nbsp nbsp nbsp 下载 ApacheOFBiz

    2026年3月17日
    2

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

关注全栈程序员社区公众号