深度学习 - 53.Bert 简介与 Keras-Bert 常用示例

news/2024/11/16 22:32:32/

目录

一.引言

二.Bert 简介

1.Embedding Layer

2.Encoder layer

3.Pre-training 与 Fine-Tuning

三.Keras-Bert 常用 Demo

1.获取预训练模型

2.加载预训练模型

3.Fill Text 

4.IsCorrelation

5.Get Embedding

6.完整代码

四. Fine-Tuning

五.Bert VS OpenAI GPT

六.总结


一.引言

Bert 是 Google 在 18 年发布的 <Pre-training of Deep Bidirectional Transformers for Language Understanding> 中提到的预训练模型,其适用于 NLP 场景且可以基于自己的文本数据进行微调,下面简单介绍下其原理以及 Keras-Bert 的一些基本应用。

二.Bert 简介

Bert 主要由一个 Embedding 层和多个 Encoder 层堆叠而成,下面做简单介绍。

1.Embedding Layer

在构建 bert 输入时,会在每个 Text 的最前面增加一个特殊符号 '[CLS]',除此之外,不同 Sentence 之间还会加入 '[SEP]' 分隔符,标识一个句子的结束。

以上图的句子为例:

sentence_1 = "my dog is cute"
sentence_2 = "he likes playing"
print('Tokens:', tokenizer.tokenize(first=sentence_1, second=sentence_2))
indices, segments = tokenizer.encode(first=sentence_1, second=sentence_2)
print('Indices:', indices)
print('Segments:', segments)

Token 可以认为是分词以及增加特殊符号标识后的句子,通过 tokenizer 转换得到其 index。

Segment 代表每个字符属于第几个句子,上面第一个句子对应索引为0,第二个句子为1。

Position 代表每个字符在整个 Text 的位置信息索引。

Bert 实际使用中输入层使用了 Token Embedding、Segment Embedding 与 Position Embedding 三类向量,将每个类型的 Embedding concat 后再将对应位置的 Embedding 相加就得到原始的 Input。这里 Bert 预训练模型的 Embedding 维度为 768。

2.Encoder layer

Bert 在模型结构上主要使用了 Encoder 的堆叠,其中应用了 Multi-Head Attention 与 Scaled Dot-Product,关于 Multi-Head Attention 与 Scaled Dot Product 我们前面 Attention 的文章已经做了介绍。其可以看作是只使用了 Transformer 的 Encoder 结构,在 Input Embedding 的基础上 Add 了 Position Embedding,随后经过一层 Multi-Head Attention 并且将 Input 与 Attention 的结果相加,避免信息丢失,随后经过一层前馈网络,再执行一次 Add & Norm 进入下一个 Encoder。

  

3.Pre-training 与 Fine-Tuning

BERT 的全面预训练模型和微调模型。预训练和微调中除了输出层外其余部分使用了相同的架构。相同的预训练模型参数用于初始化不同下游任务的模型。在微调过程中,对所有参数进行微调。根据任务场景不同,Bert 可以完成不同的任务:

• Sentence Pair Classification Tasks 文本对分类任务

• Single Sentence Classification Tasks 单句分类任务

• Question Answering Task 问答任务

• Single Sentence Tagging Task 单句标记任务

上面不同类型任务图下给出了不同的数据集,可用于 Bert 模型的微调,大家也可以使用自己场景的文本问答、新闻报刊等数据进行训练。

 

三.Keras-Bert 常用 Demo

下述代码运行使用的环境为:

import tensorflow as tf
import platformprint("TF: %s Keras: %s Python: %s" % (tf.__version__, tf.keras.__version__, platform.python_version()))
TF: 2.4.0-rc0 Keras: 2.4.0 Python: 3.8.6

1.获取预训练模型

一般情况下,我们主要使用预训练好的 Bert 进行一些基本的 NLP 任务或者通过 Fine-Tuning 对 Bert 进行一些微调,这里我们加载预训练模型 Bert-Base uncased_L-12_H-768_A-12,其模型名称代表 12-Layer、768-Hidden, 12-Heads 共计约 110M parameters,可以通过 wget 获取该预训练模型:

 wget -q https://storage.googleapis.com/bert_models/2018_10_18/uncased_L-12_H-768_A-12.zip

除了 Bert-Base 外,还有 Bert-Large,其包含 340M 个参数的模型,包含 24-Layer 以及 1024-Hidden 以及 16-Heads。一些基础的 Bert 模型可以到 Bert 的 github 选择下载:

 

2.加载预训练模型

import numpy as np
from keras_bert import load_vocabulary, load_trained_model_from_checkpoint, Tokenizer, get_checkpoint_paths
from keras_bert.datasets import get_pretrained, PretrainedListclass Bert:_token_dict = None  # word ->index_token_dict_inv = None  # index -> word_model = None_export_model = None_tokenizer = Nonedef __init__(self, _model_path):self._model_path = model_pathself.loadBertModel(self._model_path)self._tokenizer = Tokenizer(self._token_dict)def loadBertModel(self, path):paths = get_checkpoint_paths(path)# 加载预训练模型self._model = load_trained_model_from_checkpoint(paths.config, paths.checkpoint, training=True, seq_len=None)self._export_model = load_trained_model_from_checkpoint(paths.config, paths.checkpoint, seq_len=10)self._model.summary(line_length=120)# 字典映射与反映射self._token_dict = load_vocabulary(paths.vocab)self._token_dict_inv = {v: k for k, v in self._token_dict.items()}

通过 load_trained_model_from_checkpoint 读取预训练模型,seq_len = None 的模型主要处理不定长的 sentence 问题,seq_len 固定的 export 模型主要用于 Embedding 获取。token_dict 和 token_dict_inv 为 kv 相反的 map,token_dict 形式如下,共包含不同的符号 21128 个:

顺便调用 model.summary() 看下模型结构:

•  Embedding

包含 Token、Segment 与 Position Embedding,最后经过 Dropout 与 Norm 进入后续逻辑。

•  Encoder

将 Embedding 层得到的 Embedding-Norm 进行 MultiHeadSelfAttention,以及 Dropout 以及 Norm 随后接 FeedForward 前馈网络接着继续 Dropout。注意两个红框位置,MultiHeadAttention 与 FeedForward 后得到的向量斗鱼原始输入的向量进行了 Add 操作:

这里 Add 操作可以避免随着 Encoder 堆叠层数的增加,原始信息的不断衰减。

• Output

最后的输出层 MLM (masked) 代表 Masked Language Model 可以进行序列重建,NSP 代表 Next Sentence Prediction 可以进行句子间的二分类任务,例如判断后一句话与前一句话是否有关联。由于加载的是 Bert-Base,所以最后的 Layer 是 Encoder-12,且模型的 Total params 约为 102882442 即 100m。

3.Fill Text 

句子填字可以实现句子补全与填空,让 bert 填入最合适 [概率最大的词]。

    # 填字def fillText(self, _text, _st, _end):tokens = self._tokenizer.tokenize(_text)for i in range(_st, _end + 1):tokens[i] = '[MASK]'print('Tokens:', tokens)# 映射后的索引、代表句子、代表Mask掩码indices = np.array([[self._token_dict[token] for token in tokens]])segments = np.array([[0] * len(tokens)])masks = np.array([[0] * len(tokens)])for i in range(_st, _end + 1):masks[0][i] = 1# 填字预测predicts = self._model.predict([indices, segments, masks])[0].argmax(axis=-1).tolist()print('Fill with: ', list(map(lambda x: self._token_dict_inv[x], predicts[0][_st:_end + 1])))

text 为对应填词的文本,st 与 end 可以指定 mask 掩码的位置然后让 bert 在这个范围内进行填充,注意由于 bert Input Embedding 时会在开头添加 '[CLS]',所有对应位置的索引需要 +1,所以下面示例中 1、2 对应的是 '数学': 

    # 填充字符text = '数学是利用符号语言研究数量、结构、变化以及空间等概念的一门学科'st, end = 1, 2bert.fillText(text, st, end)st, end = 4, 5bert.fillText(text, st, end)

 原文的前五个字为 '数学是利用',我们分别将 '数学' 和 '利用' 进行 MASK 处理,这里分别填充了 '数学' 和 '使用',基本匹配:

 

4.IsCorrelation

是否相关判断 sentence_1 和 sentence_2 在语料训练后的文本场景下,前后语句是否有关联。

    # 两个句子是否有关联def hasCorrelation(self, _sentence_1, _sentence_2):indices, segments = self._tokenizer.encode(first=_sentence_1, second=_sentence_2)masks = np.array([[0] * len(indices)])predicts = self._model.predict([np.array([indices]), np.array([segments]), masks])[1]is_correlation = bool(np.argmax(predicts, axis=-1)[0])print('%s && %s is has Correlation: ' % (_sentence_1, _sentence_2), is_correlation)return is_correlation

 给定三个学科相关的语句,让 Bert-Base 判断前后语句是否存在关联:

    # 判断前后逻辑是否一致sentence_1 = '数学是利用符号语言研究數量、结构、变化以及空间等概念的一門学科。'sentence_2 = '从某种角度看屬於形式科學的一種。'sentence_3 = '任何一个希尔伯特空间都有一族标准正交基。'bert.hasCorrelation(sentence_1, sentence_2)bert.hasCorrelation(sentence_1, sentence_3)

sentence_1 与 sentence_2 无关,sentenct_1 与 sentenct_3 相关:

 

5.Get Embedding

上篇介绍多样性时介绍了 MMR 算法,其通过预训练向量计算不同 item 之间的 sim(i,j),这里 bert 就可以作为预训练 Embedding 获取的模型。

• 获取词向量

获取向量时需要用  expert_model,且 encode 的 max_len 参数要与模型的 max_len 参数对应。这里实际获取的向量维度为 768,大家也可以不使用 [:5] 截断。

    def getCharEmbedding(self, _text):tokens = self._tokenizer.tokenize(_text)print('Tokens:', tokens)indices, segments = self._tokenizer.encode(first=_text, max_len=10)predicts = self._export_model.predict([np.array([indices]), np.array([segments])])[0]for i, token in enumerate(tokens):print(token, predicts[i].tolist()[:5])

 

• 获取文本向量

    def getTextEmbedding(self, _text):tokens = self._tokenizer.tokenize(text)print('Tokens:', tokens)indices, segments = self._tokenizer.encode(first=text, max_len=10)predicts = self._export_model.predict([np.array([indices]), np.array([segments])])[0]pooled = np.array(np.sum(predicts, axis=0))print('Pooled:', pooled.shape)return pooled

虽然传入的 text 只有 6 个字符,但是默认了 max_len = 10, 所以 tokenizer 对原句进行了 Padding,最后 pooling 的方式不局限于 sum pooling ,也可以 mean_pooling、max_pooling 等,输出的向量维度为 max_len x 768,由于后四位是 padding 得来的,如果想要更准确的向量,可以通过 mask 擦除对应 padding 的向量。

MMR 时我们可以通过传入文本的内容再 getTextEmbedding 获取文本 Embedding 然后计算向量相似度,不过这里是 Base-Bert,如果有自己文本的训练微调,效果会更好。 

6.完整代码

import numpy as np
from keras_bert import load_vocabulary, load_trained_model_from_checkpoint, Tokenizer, get_checkpoint_paths
from keras_bert.datasets import get_pretrained, PretrainedListclass Bert:_token_dict = None  # word ->index_token_dict_inv = None  # index -> word_model = None_export_model = None_tokenizer = Nonedef __init__(self, _model_path):self._model_path = model_pathself.loadBertModel(self._model_path)self._tokenizer = Tokenizer(self._token_dict)def loadBertModel(self, path):paths = get_checkpoint_paths(path)# 加载预训练模型self._model = load_trained_model_from_checkpoint(paths.config, paths.checkpoint, training=True, seq_len=None)self._export_model = load_trained_model_from_checkpoint(paths.config, paths.checkpoint, seq_len=10)self._model.summary(line_length=120)# 字典映射与反映射self._token_dict = load_vocabulary(paths.vocab)self._token_dict_inv = {v: k for k, v in self._token_dict.items()}# 填字def fillText(self, _text, _st, _end):tokens = self._tokenizer.tokenize(_text)for i in range(_st, _end + 1):tokens[i] = '[MASK]'print('Tokens:', tokens)# 映射后的索引、代表句子、代表Mask掩码indices = np.array([[self._token_dict[token] for token in tokens]])segments = np.array([[0] * len(tokens)])masks = np.array([[0] * len(tokens)])for i in range(_st, _end + 1):masks[0][i] = 1# 填字预测predicts = self._model.predict([indices, segments, masks])[0].argmax(axis=-1).tolist()print('Fill with: ', list(map(lambda x: self._token_dict_inv[x], predicts[0][_st:_end + 1])))# 两个句子是否有关联def hasCorrelation(self, _sentence_1, _sentence_2):indices, segments = self._tokenizer.encode(first=_sentence_1, second=_sentence_2)masks = np.array([[0] * len(indices)])predicts = self._model.predict([np.array([indices]), np.array([segments]), masks])[1]is_correlation = bool(np.argmax(predicts, axis=-1)[0])print('%s && %s is has Correlation: ' % (_sentence_1, _sentence_2), is_correlation)return is_correlationdef getCharEmbedding(self, _text):tokens = self._tokenizer.tokenize(_text)print('Tokens:', tokens)indices, segments = self._tokenizer.encode(first=_text, max_len=10)predicts = self._export_model.predict([np.array([indices]), np.array([segments])])[0]for i, token in enumerate(tokens):print(token, predicts[i].tolist()[:5])def getTextEmbedding(self, _text):tokens = self._tokenizer.tokenize(text)print('Tokens:', tokens)indices, segments = self._tokenizer.encode(first=text, max_len=10)print('Indices:', indices)print('Segments:', segments)predicts = self._export_model.predict([np.array([indices]), np.array([segments])])[0]pooled = np.array(np.sum(predicts, axis=0))print('Pooled:', pooled.shape)return pooledif __name__ == '__main__':# 获取模型路径model_path = get_pretrained(PretrainedList.chinese_base)# 加载模型bert = Bert(model_path)# 填充字符text = '数学是利用符号语言研究数量、结构、变化以及空间等概念的一门学科'st, end = 1, 2bert.fillText(text, st, end)st, end = 4, 5bert.fillText(text, st, end)# 判断前后逻辑是否一致sentence_1 = '数学是利用符号语言研究數量、结构、变化以及空间等概念的一門学科。'sentence_2 = '从某种角度看屬於形式科學的一種。'sentence_3 = '任何一个希尔伯特空间都有一族标准正交基。'bert.hasCorrelation(sentence_1, sentence_2)bert.hasCorrelation(sentence_1, sentence_3)# 获取字符 Embeddingtext = '语言模型'bert.getCharEmbedding(text)# 获取 Text Pooling 后的 Embeddingbert.getTextEmbedding(text)

代码中给出了模型加载以及上面几个常用方法示例。

四. Fine-Tuning

Kreas-Bert 给出了基于 TPU 的 Imdb 数据的 Fine-Tuning 示例,由于原代码版本为 TF1,博主环境为 TF2,博主对代码进行了修改实现 TF2 的兼容,但是训练第一个 Epoch 时会无报错退出,这里给出代码,如果有可以跑通的同学可以评论区交流 ~

import codecs
from keras_bert import load_trained_model_from_checkpoint
import os
import numpy as np
from tqdm import tqdm
from keras_bert import Tokenizer
import tensorflow as tftf.compat.v1.disable_eager_execution()
SEQ_LEN = 128
BATCH_SIZE = 128
EPOCHS = 5
LR = 1e-4if __name__ == '__main__':pretrained_path = './uncased_L-12_H-768_A-12'config_path = os.path.join(pretrained_path, 'bert_config.json')checkpoint_path = os.path.join(pretrained_path, 'bert_model.ckpt')vocab_path = os.path.join(pretrained_path, 'vocab.txt')# TF_KERAS must be added to environment variables in order to use TPUos.environ['TF_KERAS'] = '1'token_dict = {}with codecs.open(vocab_path, 'r', 'utf8') as reader:for line in reader:token = line.strip()token_dict[token] = len(token_dict)# with strategy.scope():model = load_trained_model_from_checkpoint(config_path,checkpoint_path,training=True,trainable=True,seq_len=SEQ_LEN,)dataset = tf.keras.utils.get_file(fname="aclImdb.tar.gz",origin="http://ai.stanford.edu/~amaas/data/sentiment/aclImdb_v1.tar.gz",extract=True,)tokenizer = Tokenizer(token_dict)def load_data(path):global tokenizerindices, sentiments = [], []for folder, sentiment in (('neg', 0), ('pos', 1)):folder = os.path.join(path, folder)for name in tqdm(os.listdir(folder)):with open(os.path.join(folder, name), 'r') as reader:text = reader.read()ids, segments = tokenizer.encode(text, max_len=SEQ_LEN)indices.append(ids)sentiments.append(sentiment)items = list(zip(indices, sentiments))np.random.shuffle(items)indices, sentiments = zip(*items)indices = np.array(indices)mod = indices.shape[0] % BATCH_SIZEif mod > 0:indices, sentiments = indices[:-mod], sentiments[:-mod]return [indices, np.zeros_like(indices)], np.array(sentiments)train_path = os.path.join(os.path.dirname(dataset), 'aclImdb', 'train')test_path = os.path.join(os.path.dirname(dataset), 'aclImdb', 'test')train_x, train_y = load_data(train_path)test_x, test_y = load_data(test_path)from tensorflow.python import kerasfrom keras_radam import RAdaminputs = model.inputs[:2]dense = model.get_layer('NSP-Dense').outputoutputs = keras.layers.Dense(units=2, activation='softmax')(dense)model = keras.models.Model(inputs, outputs)model.compile(RAdam(lr=LR),loss='sparse_categorical_crossentropy',metrics=['sparse_categorical_accuracy'],)sess = tf.compat.v1.keras.backend.get_session()uninitialized_variables = set([i.decode('ascii') for i in sess.run(tf.compat.v1.report_uninitialized_variables())])init_op = tf.compat.v1.variables_initializer([v for v in tf.compat.v1.global_variables() if v.name.split(':')[0] in uninitialized_variables])sess.run(init_op)model.fit(train_x,train_y,epochs=EPOCHS,batch_size=BATCH_SIZE,)predicts = model.predict(test_x, verbose=True).argmax(axis=-1)print(np.sum(test_y == predicts) / test_y.shape[0])

五.Bert VS OpenAI GPT

论文中给出了 Bert、OpenAI GPT 以及 ELMo 的预训练模型架构差异。 BERT 使用双向 Transformer、OpenAI GPT使用从左到右的 Transformer、ELMo 使用独立训练的从左到右和从右到左的 lstm 的连接来为下游任务生成特征。在这三种表示中,只有 BERT 同时以所有层中的左右上下文为条件。除了架构差异之外,BERT 和 OpenAI GPT 是一种微调方法,而 ELMo 是一种基于特性的方法。当时对标的 GPT 其实是 GPT-2,这里提供一个简单的 demo 供大家参考:

#!/usr/bin/env Python
# coding=utf-8from transformers import GPT2LMHeadModel, GPT2Tokenizer
import torch# 初始化GPT2模型的Tokenizer类.
tokenizer = GPT2Tokenizer.from_pretrained("gpt2")
# 初始化GPT2模型, 此处以初始化GPT2LMHeadModel()类的方式调用GPT2模型.
model = GPT2LMHeadModel.from_pretrained('gpt2')# GPT模型第一次迭代的输入的上下文内容, 将其编码以序列化.同时, generated也用来存储GPT2模型所有迭代生成的token索引.
generated = tokenizer.encode("The Tower bridge")  # generated: [11708] => [11708, 13]
# 将序列化后的第一次迭代的上下文内容转化为pytorch中的tensor形式.
context = torch.tensor([generated])  # context: tensor([[11708]])
# 第一次迭代时还无past_key_values元组.
past_key_values = Noneiter_num = 30for i in range(iter_num):'''此时模型model返回的output为CausalLMOutputWithPastAndCrossAttentions类,模型返回的logits以及past_key_values对象为其中的属性,CausalLMOutputWithPastAndCrossAttentions(loss=loss, # tensor([[[-34.2719, -33.7875, -36.4804,  ..., -40.9417, -40.9629, -33.9127]]], grad_fn=<UnsafeViewBackward0>)logits=lm_logits,past_key_values=transformer_outputs.past_key_values, # hidden_states=transformer_outputs.hidden_states,attentions=transformer_outputs.attentions,cross_attentions=transformer_outputs.cross_attentions,)
'''output = model(context, past_key_values=past_key_values)past_key_values = output.past_key_values# 此时获取GPT2模型计算的输出结果hidden_states张量中第二维度最后一个元素的argmax值, 得出的argmax值即为此次GPT2模型迭代# 计算生成的下一个token. 注意, 此时若是第一次迭代, 输出结果hidden_states张量的形状为(batch_size, sel_len, n_state);# 此时若是第二次及之后的迭代, 输出结果hidden_states张量的形状为(batch_size, 1, n_state), all_head_size=n_state=nx=768.logits = output.logitsv = logits[..., -1, :]token = torch.argmax(output.logits[..., -1, :])  # token: tensor(13)# 将本次迭代生成的token的张量变为二维张量, 以作为下一次GPT2模型迭代计算的上下文context.context = token.unsqueeze(0)# 将本次迭代计算生成的token的序列索引变为列表存入generatedgenerated += [token.tolist()]# 将generated中所有的token的索引转化为token字符.
sequence = tokenizer.decode(generated)
sequence = sequence.split(".")[:-1]
print(sequence)

给定 'The Tower Bridge' 和迭代次数,GPT-2 会不断迭代并通过 argmax 获取概率最大的词并 += 添加至 generated 中,最后将 generated decode 得到文本:

['The Tower bridge is a great place to get to know the city', " It's a great place to get to know the city"]

六.总结

昨天 MMR 算法中提到了 Bert 生成文本向量,今天趁热打铁简单介绍下 Bert。工业场景下,可以利用 Bert 和 GPT 进行业务场景的微调并应用于文案、文案 Embedding 生成等任务。

参考:

google-research - bert

Pre-training of Deep Bidirectional Transformers for Language Understanding

CyberZHG / keras-bert Public

更多推荐算法相关深度学习:深度学习导读专栏 


http://www.ppmy.cn/news/146750.html

相关文章

T100报错

今天T100系统老是报错 提示 查无此笔 资料 后来 调试发现是 insert xrcd表报错 把语句拉出来 拿到数据库后 提示空间不足&#xff0c;发给运维好了。加了表空间就好了 查询表空间SQL SELECT a.tablespace_name "表空间名称", total / (1024 * 1024) "表空间…

Java、储物柜难题

一个学校有100个储物柜和100个学生。所有的储物柜在上学的第一天都是关着的。随着学生进入&#xff0c;第一个学生S1打开了每个柜子。第二个学生S2从第二个柜子L2开始&#xff0c;关闭相隔1的柜子。学生3&#xff08;S3&#xff09;从第三个柜子L3改变每第三个柜子的状态&#…

LE5010

LE5010是凌思微出的集成BLE功能芯片 参考文档&#xff1a;https://ls-ble-sdk.readthedocs.io/zh/latest/index.html 参数 32位Cortex-M0内核&#xff0c;最高频率64M&#xff0c;最大64KB SRAM&#xff0c;最大512KB flash。支持BLE5.0/5.1&#xff0c;支持MESH。 低功耗功…

数据可视化 立体柱状图 柱状图

立体柱状图 1、首先通过标签方式直接引入构建好的 echarts 文件 <!DOCTYPE html> <html> <head><meta charset"utf-8"><!-- 引入 ECharts 文件 --><script src"echarts.min.js"></script> </head> <…

HTML5(九)——超强的 SVG 动画

SVG 动画有很多种实现方法&#xff0c;也有很大SVG动画库&#xff0c;现在我们就来介绍 svg动画实现方法都有哪些&#xff1f; 一、SVG 的 animation SVG animation 有五大元素&#xff0c;他们控制着各种不同类型的动画&#xff0c;分别为&#xff1a; setanimateanimateCo…

打印机配置及故障排查解决方案

爱普生 M系列机型打印输出有空白条纹或模糊不清&#xff0c;如何解决&#xff1f; - 爱普生产品常见问题 - 爱普生中国 L系列机型打印输出有空白条纹或严重偏色&#xff0c;如何解决&#xff1f;(无运输锁) - 爱普生产品常见问题 - 爱普生中国 爱普生系列配网及微信打印 联…

龙芯1b(LS1B200)使用LVGL7.0.1组件的初次体验

由比赛入坑龙芯1b&#xff08;LS1B200&#xff09;&#xff0c;需要对板上驱动进行开发&#xff0c;使用LVGL库来做UI界面控制驱动。 网上资料难以查找&#xff0c;在本文中记录学习。 实现效果&#xff1a; 使用LVGL库的基本步骤&#xff1a; 1.硬件和需求设置LV_COLOR_DEPTH&…

HTML5+CSS大作业——简约个性高逼格博客(5页) web网页制作期末大作业模板

HTML5CSS大作业——简约个性高逼格博客(5页) web网页制作期末大作业模板 常见网页设计作业题材有 个人、 美食、 公司、 学校、 旅游、 电商、 宠物、 电器、 茶叶、 家居、 酒店、 舞蹈、 动漫、 明星、 服装、 体育、 化妆品、 物流、 环保、 书籍、 婚纱、 军事、 游戏、 节…